diff --git a/.github/workflows/4_6_2_Core_Unit_Tests_Win.yaml b/.github/workflows/4_6_2_Core_Unit_Tests_Win.yaml new file mode 100644 index 000000000..4455dbedf --- /dev/null +++ b/.github/workflows/4_6_2_Core_Unit_Tests_Win.yaml @@ -0,0 +1,113 @@ +name: build and test .NET 4.6.2 Windows + +on: + push: + pull_request: + branches: [ release, development ] + paths: + - '**.cs' + - '**.csproj' + +env: + DOTNET_VERSION: '9.0.x' # The .NET SDK version to use + +jobs: + build-and-test: + # if: ${{ ! always() }} + name: build-and-test-windows + runs-on: windows-latest + steps: + - name: Clone webprofusion/certify + uses: actions/checkout@master + with: + path: ./certify + + - name: Clone webprofusion/anvil + uses: actions/checkout@master + with: + repository: webprofusion/anvil + ref: refs/heads/main + path: ./libs/anvil + + - name: Clone webprofusion/certify-plugins (development branch push) + if: ${{ github.event_name == 'push' && (contains(github.ref_name, '_dev') || github.ref_name == 'development') }} + uses: actions/checkout@master + with: + repository: webprofusion/certify-plugins + ref: refs/heads/development + path: ./certify-plugins + + - name: Clone webprofusion/certify-plugins (release branch push) + if: ${{ github.event_name == 'push' && (contains(github.ref_name, '_rel') || github.ref_name == 'release') }} + uses: actions/checkout@master + with: + repository: webprofusion/certify-plugins + ref: refs/heads/release + path: ./certify-plugins + + - name: Clone webprofusion/certify-plugins (pull request) + if: ${{ github.event_name == 'pull_request' }} + uses: actions/checkout@master + with: + repository: webprofusion/certify-plugins + ref: ${{ github.base_ref }} + path: ./certify-plugins + + - name: Setup .NET Core + uses: actions/setup-dotnet@master + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Setup Step CLI + run: | + Invoke-WebRequest -Method 'GET' -uri 'https://dl.smallstep.com/gh-release/cli/docs-cli-install/v0.24.4/step_windows_0.24.4_amd64.zip' -Outfile 'C:\temp\step_windows_0.24.4_amd64.zip' + tar -oxzf C:\temp\step_windows_0.24.4_amd64.zip -C "C:\Program Files" + echo "C:\Program Files\step_0.24.4\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + + - name: Pull step-ca Docker Image + run: docker pull webprofusion/step-ca-win + + - name: Cache NuGet Dependencies + uses: actions/cache@v3 + with: + path: ~/.nuget/packages + # Look to see if there is a cache hit for the corresponding requirements file + key: ${{ runner.os }}-4.6.2-nuget-${{ hashFiles('./certify/src/Certify.Tests/Certify.Core.Tests.Unit/*.csproj') }} + restore-keys: | + ${{ runner.os }}-4.6.2-nuget + + - name: Install Dependencies & Build Certify.Core.Tests.Unit + run: | + dotnet tool install --global dotnet-reportgenerator-globaltool --version 5.2.0 + dotnet add package GitHubActionsTestLogger + dotnet build -c Debug -f net462 --property WarningLevel=0 /clp:ErrorsOnly + working-directory: ./certify/src/Certify.Tests/Certify.Core.Tests.Unit + + - name: Run Certify.Core.Tests.Unit Tests + run: | + $env:GITHUB_WORKSPACE="$env:GITHUB_WORKSPACE\certify" + $env:GITHUB_STEP_SUMMARY=".\TestResults-${{ runner.os }}\test-summary.md" + dotnet test --no-build -f net462 -l "GitHubActions;summary.includePassedTests=true;summary.includeSkippedTests=true;annotations.messageFormat=@error\n@trace" + working-directory: ./certify/src/Certify.Tests/Certify.Core.Tests.Unit + + - name: Generated Test Results Report + run: | + echo "# Test Results" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 + (Get-Content -Path .\TestResults-${{ runner.os }}\test-summary.md).Replace('
', '
') | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append + working-directory: ./certify/src/Certify.Tests/Certify.Core.Tests.Unit + if: ${{ always() }} + + - name: Generated Test Coverage Report + run: | + reportgenerator -reports:./TestResults-${{ runner.os }}/*/*.cobertura.xml -targetdir:./TestResults-${{ runner.os }} -reporttypes:MarkdownSummaryGithub "-title:Test Coverage" + Get-Content -Path ./TestResults-${{ runner.os }}/SummaryGithub.md | Out-File -FilePath $env:GITHUB_STEP_SUMMARY + working-directory: ./certify/src/Certify.Tests/Certify.Core.Tests.Unit + if: ${{ always() }} + + # - name: Upload dotnet test Artifacts + # uses: actions/upload-artifact@master + # with: + # name: dotnet-results-${{ runner.os }}-${{ env.DOTNET_VERSION }} + # path: ./certify/src/Certify.Tests/Certify.Core.Tests.Unit/TestResults-4_6_2-${{ runner.os }} + # # Use always() to always run this step to publish test results when there are test failures + # if: ${{ always() }} diff --git a/.github/workflows/DotNetCore_Unit_Tests_Linux.yaml b/.github/workflows/DotNetCore_Unit_Tests_Linux.yaml new file mode 100644 index 000000000..94acd9076 --- /dev/null +++ b/.github/workflows/DotNetCore_Unit_Tests_Linux.yaml @@ -0,0 +1,116 @@ +name: build and test .NET Core 9.0 Linux + +on: + push: + pull_request: + branches: [ release, development ] + paths: + - '**.cs' + - '**.csproj' + +env: + DOTNET_VERSION: '9.0.x' # The .NET SDK version to use + +jobs: + build-and-test: + + name: build-and-test-linux + runs-on: ubuntu-latest + steps: + - name: Clone webprofusion/certify + uses: actions/checkout@master + with: + path: ./certify + + - name: Clone webprofusion/anvil + uses: actions/checkout@master + with: + repository: webprofusion/anvil + ref: refs/heads/main + path: ./libs/anvil + + - name: Clone webprofusion/certify-plugins (development branch push) + if: ${{ github.event_name == 'push' && (contains(github.ref_name, '_dev') || github.ref_name == 'development') }} + uses: actions/checkout@master + with: + repository: webprofusion/certify-plugins + ref: refs/heads/development + path: ./certify-plugins + + - name: Clone webprofusion/certify-plugins (release branch push) + if: ${{ github.event_name == 'push' && (contains(github.ref_name, '_rel') || github.ref_name == 'release') }} + uses: actions/checkout@master + with: + repository: webprofusion/certify-plugins + ref: refs/heads/release + path: ./certify-plugins + + - name: Clone webprofusion/certify-plugins (pull request) + if: ${{ github.event_name == 'pull_request' }} + uses: actions/checkout@master + with: + repository: webprofusion/certify-plugins + ref: ${{ github.base_ref }} + path: ./certify-plugins + + - name: Setup .NET Core + uses: actions/setup-dotnet@master + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Setup Step CLI + run: | + wget https://dl.smallstep.com/gh-release/cli/docs-cli-install/v0.23.0/step-cli_0.23.0_amd64.deb + sudo dpkg -i step-cli_0.23.0_amd64.deb + + - name: Pull step-ca Docker Image + run: docker pull smallstep/step-ca + + - name: Cache NuGet Dependencies + uses: actions/cache@v3 + with: + path: ~/.nuget/packages + # Look to see if there is a cache hit for the corresponding requirements file + key: ${{ runner.os }}-${{ env.DOTNET_VERSION }}-nuget-${{ hashFiles('./certify/src/Certify.Tests/Certify.Core.Tests.Unit/*.csproj') }} + restore-keys: | + ${{ runner.os }}-${{ env.DOTNET_VERSION }}-nuget + + - name: Install Dependencies & Build Certify.Core.Tests.Unit + run: | + dotnet tool install --global dotnet-reportgenerator-globaltool --version 5.3.8 + dotnet add package GitHubActionsTestLogger + dotnet restore -f net9.0 + pwd + ls + dotnet build -c Debug -f net9.0 --property WarningLevel=0 /clp:ErrorsOnly + working-directory: ./certify/src/Certify.Tests/Certify.Core.Tests.Unit + + - name: Run Certify.Core.Tests.Unit Tests + run: | + export GITHUB_WORKSPACE="$GITHUB_WORKSPACE/certify" + export GITHUB_STEP_SUMMARY="./TestResults-${{ runner.os }}/test-summary.md" + dotnet test --no-build -f net9.0 -l "GitHubActions;summary.includePassedTests=true;summary.includeSkippedTests=true;annotations.messageFormat=@error\n@trace" + working-directory: ./certify/src/Certify.Tests/Certify.Core.Tests.Unit + + - name: Generate Test Results Report + run: | + echo "# Test Results" > $GITHUB_STEP_SUMMARY + sed -i 's/
/
/g' ./TestResults-${{ runner.os }}/test-summary.md + cat ./TestResults-${{ runner.os }}/test-summary.md >> $GITHUB_STEP_SUMMARY + working-directory: ./certify/src/Certify.Tests/Certify.Core.Tests.Unit + if: ${{ always() }} + + - name: Generated Test Coverage Report + run: | + reportgenerator -reports:./TestResults-${{ runner.os }}/*/*.cobertura.xml -targetdir:./TestResults-${{ runner.os }} -reporttypes:MarkdownSummaryGithub "-title:Test Coverage" + cat ./TestResults-${{ runner.os }}/SummaryGithub.md > $GITHUB_STEP_SUMMARY + working-directory: ./certify/src/Certify.Tests/Certify.Core.Tests.Unit + if: ${{ always() }} + + # - name: Upload dotnet test Artifacts + # uses: actions/upload-artifact@master + # with: + # name: dotnet-results-${{ runner.os }}-${{ env.DOTNET_VERSION }} + # path: ./certify/src/Certify.Tests/Certify.Core.Tests.Unit/TestResults-9_0-${{ runner.os }} + # # Use always() to always run this step to publish test results when there are test failures + # if: ${{ always() }} diff --git a/.github/workflows/DotNetCore_Unit_Tests_Win.yaml b/.github/workflows/DotNetCore_Unit_Tests_Win.yaml new file mode 100644 index 000000000..1094fbe39 --- /dev/null +++ b/.github/workflows/DotNetCore_Unit_Tests_Win.yaml @@ -0,0 +1,114 @@ +name: build and test .NET Core 9.0 Windows + +on: + push: + pull_request: + branches: [ release, development ] + paths: + - '**.cs' + - '**.csproj' + +env: + DOTNET_VERSION: '9.0.x' # The .NET SDK version to use + +jobs: + build-and-test: + + name: build-and-test-windows + runs-on: windows-latest + steps: + - name: Clone webprofusion/certify + uses: actions/checkout@master + with: + path: ./certify + + - name: Clone webprofusion/anvil + uses: actions/checkout@master + with: + repository: webprofusion/anvil + ref: refs/heads/main + path: ./libs/anvil + + - name: Clone webprofusion/certify-plugins (development branch push) + if: ${{ github.event_name == 'push' && (contains(github.ref_name, '_dev') || github.ref_name == 'development') }} + uses: actions/checkout@master + with: + repository: webprofusion/certify-plugins + ref: refs/heads/development + path: ./certify-plugins + + - name: Clone webprofusion/certify-plugins (release branch push) + if: ${{ github.event_name == 'push' && (contains(github.ref_name, '_rel') || github.ref_name == 'release') }} + uses: actions/checkout@master + with: + repository: webprofusion/certify-plugins + ref: refs/heads/release + path: ./certify-plugins + + - name: Clone webprofusion/certify-plugins (pull request) + if: ${{ github.event_name == 'pull_request' }} + uses: actions/checkout@master + with: + repository: webprofusion/certify-plugins + ref: ${{ github.base_ref }} + path: ./certify-plugins + + - name: Setup .NET Core + uses: actions/setup-dotnet@master + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Setup Step CLI + run: | + Invoke-WebRequest -Method 'GET' -uri 'https://dl.smallstep.com/gh-release/cli/docs-cli-install/v0.24.4/step_windows_0.24.4_amd64.zip' -Outfile 'C:\temp\step_windows_0.24.4_amd64.zip' + tar -oxzf C:\temp\step_windows_0.24.4_amd64.zip -C "C:\Program Files" + echo "C:\Program Files\step_0.24.4\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + + - name: Pull step-ca Docker Image + run: docker pull webprofusion/step-ca-win + + - name: Cache NuGet Dependencies + uses: actions/cache@v3 + with: + path: ~/.nuget/packages + # Look to see if there is a cache hit for the corresponding requirements file + key: ${{ runner.os }}-${{ env.DOTNET_VERSION }}-nuget-${{ hashFiles('./certify/src/Certify.Tests/Certify.Core.Tests.Unit/*.csproj') }} + restore-keys: | + ${{ runner.os }}-${{ env.DOTNET_VERSION }}-nuget + + - name: Install Dependencies & Build Certify.Core.Tests.Unit + run: | + dotnet tool install --global dotnet-reportgenerator-globaltool --version 5.2.0 + dotnet add package GitHubActionsTestLogger + dotnet restore -f net9.0 + dotnet build Certify.Core.Tests.Unit.csproj -c Debug -f net9.0 --property WarningLevel=0 /clp:ErrorsOnly + working-directory: ./certify/src/Certify.Tests/Certify.Core.Tests.Unit + + - name: Run Certify.Core.Tests.Unit Tests + run: | + $env:GITHUB_WORKSPACE="$env:GITHUB_WORKSPACE\certify" + $env:GITHUB_STEP_SUMMARY=".\TestResults-${{ runner.os }}\test-summary.md" + dotnet test --no-build -f net9.0 -l "GitHubActions;summary.includePassedTests=true;summary.includeSkippedTests=true;annotations.messageFormat=@error\n@trace" + working-directory: ./certify/src/Certify.Tests/Certify.Core.Tests.Unit + + - name: Generate Test Results Report + run: | + echo "# Test Results" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 + (Get-Content -Path .\TestResults-${{ runner.os }}\test-summary.md).Replace('
', '
') | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append + working-directory: ./certify/src/Certify.Tests/Certify.Core.Tests.Unit + if: ${{ always() }} + + - name: Generated Test Coverage Report + run: | + reportgenerator -reports:./TestResults-${{ runner.os }}/*/*.cobertura.xml -targetdir:./TestResults-${{ runner.os }} -reporttypes:MarkdownSummaryGithub "-title:Test Coverage" + Get-Content -Path ./TestResults-${{ runner.os }}/SummaryGithub.md | Out-File -FilePath $env:GITHUB_STEP_SUMMARY + working-directory: ./certify/src/Certify.Tests/Certify.Core.Tests.Unit + if: ${{ always() }} + + # - name: Upload dotnet test Artifacts + # uses: actions/upload-artifact@master + # with: + # name: dotnet-results-${{ runner.os }}-${{ env.DOTNET_VERSION }} + # path: ./certify/src/Certify.Tests/Certify.Core.Tests.Unit/TestResults-9_0-${{ runner.os }} + # # Use always() to always run this step to publish test results when there are test failures + # if: ${{ always() }} diff --git a/Directory.Build.props b/Directory.Build.props index e37c0efed..49a4da4fe 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,14 +1,19 @@ - 6.1.0 - 6.1.0 + 7.0.0 + 7.0.0 Webprofusion Pty Ltd Webprofusion Pty Ltd - Certify Community Edition [via github] + Certify Certificate Manager - Community Edition [dev version via github] https://certifytheweb.com https://github.com/webprofusion/certify false true - portable + full + latest + + + + portable diff --git a/README.md b/README.md index cb6b87835..2e3612c3a 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,7 @@ Windows ACME Certificate Manager, powered by [Let's Encrypt](https://letsencrypt **Certify The Web is used by hundreds of thousands of organisations to manage millions of certificates each month** and is the perfect solution for administrators who want visibility of certificate management for their domains. Centralised dashboard status reporting is also available. -![Stars]( -https://img.shields.io/github/stars/webprofusion/certify.svg) +**If you use our app, spread the word and don't forget to Star us on GitHub!** ![Certify App Screenshot](docs/images/app-screenshot.png) diff --git a/docs/images/VS_Container_Debug_Attach_To_Process_Window.png b/docs/images/VS_Container_Debug_Attach_To_Process_Window.png new file mode 100644 index 000000000..c2ff17001 Binary files /dev/null and b/docs/images/VS_Container_Debug_Attach_To_Process_Window.png differ diff --git a/docs/images/VS_Container_Debug_Select_Code_Type_Window.png b/docs/images/VS_Container_Debug_Select_Code_Type_Window.png new file mode 100644 index 000000000..3aebaf79e Binary files /dev/null and b/docs/images/VS_Container_Debug_Select_Code_Type_Window.png differ diff --git a/docs/testing.md b/docs/testing.md index 00c00ba28..5d97e74c7 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -3,8 +3,15 @@ Testing Configuration - In Visual Studio (or other Test UI such as AxoCover), set execution environment to 64-bit to ensure tests load. - Units tests are for discreet function testing or limited component dependency tests. + - `CertifyManagerAccountTests` require an existing Prod and Staging ACME account for letsencrypt.org to exist. It also requires a `.env.test_accounts` file in the directory `C:\ProgramData\certify\Tests` with values for `RESTORE_KEY_PEM`, `RESTORE_ACCOUNT_URI`, and `RESTORE_ACCOUNT_EMAIL` for an existing letsencrypt.org ACME account + ```.env + RESTORE_KEY_PEM="-----BEGIN EC PRIVATE KEY-----\r\nMHcCAQEEINL5koIn4o+an+EwyDQEd4Ggnxra5j7Oro13M5klKmhaoAoGCCqGSM49\r\nAwEHoUQDQgAEPF7u1CLMe9FIBQo0MVmv7vlvqGOdSERG5nRLkNKTDUgBRxkXGqY+\r\nGbnnzXUb7j4g7VN7CuEy0SpCdFItD+63hQ==\r\n-----END EC PRIVATE KEY-----\r\n" + RESTORE_ACCOUNT_URI=https://acme-staging-v02.api.letsencrypt.org/acme/acct/123456789 + RESTORE_ACCOUNT_EMAIL=admin.8c635b@test.com - Integration tests exercise multiple components and may interact with ACME services etc. Required elements include: - IIS Installed on local machine + * A non-enabled site in IIS is needed for TestCertifyManagerGetPrimaryWebSitesIncludeStoppedSites() in CertifyManagerServerTypeTests.cs + - Must set IncludeExternalPlugins to true in C:\ProgramData\certify\appsettings.json and run copy-plugins.bat from certify-internal - The debug version of the app must be configured with a contact against staging Let's Encrypt servers - Completing HTTP challenges requires that the machine can respond to port 80 requests from the internet (such as the Let's Encrypt staging server checks) - DNS API Credentials test and DNS Challenges require the respective DNS credentials by configured as saved credentials in the UI (see config below) @@ -26,8 +33,7 @@ Testing Configuration "Cloudflare_ZoneId": "5265262gdd562s4x6xd64zxczxcv", "Cloudflare_TestDomain": "anothertest.com" } -``` -In addition, the test domain for some tests can be set using the CERTIFYSSLDOMAIN environment variable. - +- In addition, the test domain for some tests can be set using the CERTIFY_TESTDOMAIN environment variable. + diff --git a/scripts/chocolatey/tools/chocolateyinstall.ps1 b/scripts/chocolatey/tools/chocolateyinstall.ps1 index 8ef9065d2..b12117449 100644 --- a/scripts/chocolatey/tools/chocolateyinstall.ps1 +++ b/scripts/chocolatey/tools/chocolateyinstall.ps1 @@ -1,4 +1,4 @@ -$ErrorActionPreference = 'Stop'; +$ErrorActionPreference = 'Stop'; $toolsDir = "$(Split-Path -parent $MyInvocation.MyCommand.Definition)" $url64 = 'https://certifytheweb.s3.amazonaws.com/downloads/archive/CertifyTheWebSetup_V6.1.2.exe' diff --git a/src/Certify.Server/Certify.Service.Worker/.dockerignore b/src/.dockerignore similarity index 78% rename from src/Certify.Server/Certify.Service.Worker/.dockerignore rename to src/.dockerignore index 3729ff0cd..fe1152bdb 100644 --- a/src/Certify.Server/Certify.Service.Worker/.dockerignore +++ b/src/.dockerignore @@ -22,4 +22,9 @@ **/secrets.dev.yaml **/values.dev.yaml LICENSE -README.md \ No newline at end of file +README.md +!**/.gitignore +!.git/HEAD +!.git/config +!.git/packed-refs +!.git/refs/heads/** \ No newline at end of file diff --git a/src/Certify.Aspire/Certify.Aspire.AppHost/Certify.Aspire.AppHost.csproj b/src/Certify.Aspire/Certify.Aspire.AppHost/Certify.Aspire.AppHost.csproj new file mode 100644 index 000000000..a8688f6a1 --- /dev/null +++ b/src/Certify.Aspire/Certify.Aspire.AppHost/Certify.Aspire.AppHost.csproj @@ -0,0 +1,22 @@ + + + + Exe + net9.0; + enable + enable + true + + + + + + + + + + + + + + diff --git a/src/Certify.Aspire/Certify.Aspire.AppHost/Program.cs b/src/Certify.Aspire/Certify.Aspire.AppHost/Program.cs new file mode 100644 index 000000000..27052143f --- /dev/null +++ b/src/Certify.Aspire/Certify.Aspire.AppHost/Program.cs @@ -0,0 +1,17 @@ +var builder = DistributedApplication.CreateBuilder(args); + +var useIndependentServices = false; + +if (useIndependentServices) +{ + builder.AddProject("certifyserverhubapi"); + + builder.AddProject("certifyservercore"); +} +else +{ + // use combined hubservice + builder.AddProject("certify-server-hubservice"); +} + +builder.Build().Run(); diff --git a/src/Certify.Aspire/Certify.Aspire.AppHost/Properties/launchSettings.json b/src/Certify.Aspire/Certify.Aspire.AppHost/Properties/launchSettings.json new file mode 100644 index 000000000..1c4e230f3 --- /dev/null +++ b/src/Certify.Aspire/Certify.Aspire.AppHost/Properties/launchSettings.json @@ -0,0 +1,17 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:15155", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:16075", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22109" + } + } + } +} diff --git a/src/Certify.Aspire/Certify.Aspire.AppHost/appsettings.Development.json b/src/Certify.Aspire/Certify.Aspire.AppHost/appsettings.Development.json new file mode 100644 index 000000000..0c208ae91 --- /dev/null +++ b/src/Certify.Aspire/Certify.Aspire.AppHost/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/Certify.Aspire/Certify.Aspire.AppHost/appsettings.json b/src/Certify.Aspire/Certify.Aspire.AppHost/appsettings.json new file mode 100644 index 000000000..31c092aa4 --- /dev/null +++ b/src/Certify.Aspire/Certify.Aspire.AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/src/Certify.Aspire/Certify.Aspire.ServiceDefaults/Certify.Aspire.ServiceDefaults.csproj b/src/Certify.Aspire/Certify.Aspire.ServiceDefaults/Certify.Aspire.ServiceDefaults.csproj new file mode 100644 index 000000000..e4f8adcef --- /dev/null +++ b/src/Certify.Aspire/Certify.Aspire.ServiceDefaults/Certify.Aspire.ServiceDefaults.csproj @@ -0,0 +1,24 @@ + + + + Library + net9.0; + enable + enable + true + + + + + + + + + + + + + + + + diff --git a/src/Certify.Aspire/Certify.Aspire.ServiceDefaults/Extensions.cs b/src/Certify.Aspire/Certify.Aspire.ServiceDefaults/Extensions.cs new file mode 100644 index 000000000..7249ebecb --- /dev/null +++ b/src/Certify.Aspire/Certify.Aspire.ServiceDefaults/Extensions.cs @@ -0,0 +1,119 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; +using OpenTelemetry.Logs; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace Microsoft.Extensions.Hosting; + +public static class Extensions +{ + public static IHostApplicationBuilder AddServiceDefaults(this IHostApplicationBuilder builder) + { + builder.ConfigureOpenTelemetry(); + + builder.AddDefaultHealthChecks(); + + builder.Services.AddServiceDiscovery(); + + builder.Services.ConfigureHttpClientDefaults(http => + { + // Turn on resilience by default + http.AddStandardResilienceHandler(); + + // Turn on service discovery by default + http.AddServiceDiscovery(); + }); + + return builder; + } + + public static IHostApplicationBuilder ConfigureOpenTelemetry(this IHostApplicationBuilder builder) + { + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics.AddRuntimeInstrumentation() + .AddBuiltInMeters(); + }) + .WithTracing(tracing => + { + if (builder.Environment.IsDevelopment()) + { + // We want to view all traces in development + tracing.SetSampler(new AlwaysOnSampler()); + } + + tracing.AddAspNetCoreInstrumentation() + .AddGrpcClientInstrumentation() + .AddHttpClientInstrumentation(); + }); + + builder.AddOpenTelemetryExporters(); + + return builder; + } + + private static IHostApplicationBuilder AddOpenTelemetryExporters(this IHostApplicationBuilder builder) + { + var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + + if (useOtlpExporter) + { + builder.Services.Configure(logging => logging.AddOtlpExporter()); + builder.Services.ConfigureOpenTelemetryMeterProvider(metrics => metrics.AddOtlpExporter()); + builder.Services.ConfigureOpenTelemetryTracerProvider(tracing => tracing.AddOtlpExporter()); + } + + // Uncomment the following lines to enable the Prometheus exporter (requires the OpenTelemetry.Exporter.Prometheus.AspNetCore package) + // builder.Services.AddOpenTelemetry() + // .WithMetrics(metrics => metrics.AddPrometheusExporter()); + + // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.Exporter package) + // builder.Services.AddOpenTelemetry() + // .UseAzureMonitor(); + + return builder; + } + + public static IHostApplicationBuilder AddDefaultHealthChecks(this IHostApplicationBuilder builder) + { + builder.Services.AddHealthChecks() + // Add a default liveness check to ensure app is responsive + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + return builder; + } + + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + // Uncomment the following line to enable the Prometheus endpoint (requires the OpenTelemetry.Exporter.Prometheus.AspNetCore package) + // app.MapPrometheusScrapingEndpoint(); + + // All health checks must pass for app to be considered ready to accept traffic after starting + app.MapHealthChecks("/health"); + + // Only health checks tagged with the "live" tag must pass for app to be considered alive + app.MapHealthChecks("/alive", new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live") + }); + + return app; + } + + private static MeterProviderBuilder AddBuiltInMeters(this MeterProviderBuilder meterProviderBuilder) => + meterProviderBuilder.AddMeter( + "Microsoft.AspNetCore.Hosting", + "Microsoft.AspNetCore.Server.Kestrel", + "System.Net.Http"); +} diff --git a/src/Certify.CLI/Certify.CLI.csproj b/src/Certify.CLI/Certify.CLI.csproj index b2a6714ee..0656cf864 100644 --- a/src/Certify.CLI/Certify.CLI.csproj +++ b/src/Certify.CLI/Certify.CLI.csproj @@ -1,23 +1,13 @@ - + - net462 + net462;net9.0 Debug;Release;Debug;Release Certify Exe - AnyCPU;x64 + AnyCPU - x64 - false - bin\Debug\ - DEBUG;TRACE - prompt - 4 - ..\CodeAnalysis.ruleset - false - - - x64 + AnyCPU false bin\Debug\ DEBUG;TRACE @@ -35,15 +25,6 @@ 4 false - - AnyCPU - true - bin\Release\ - TRACE - prompt - 4 - false - Debug AnyCPU @@ -79,4 +60,8 @@ + + + + \ No newline at end of file diff --git a/src/Certify.CLI/CertifyCLI.Backup.cs b/src/Certify.CLI/CertifyCLI.Backup.cs index c17faf9cb..284b95f65 100644 --- a/src/Certify.CLI/CertifyCLI.Backup.cs +++ b/src/Certify.CLI/CertifyCLI.Backup.cs @@ -2,8 +2,7 @@ using System.IO; using System.Linq; using System.Threading.Tasks; - -using Certify.Config.Migration; +using Certify.Models.Config.Migration; using Newtonsoft.Json; namespace Certify.CLI @@ -27,7 +26,7 @@ public async Task PerformBackupExport(string[] args) var exportRequest = new ExportRequest { IsPreviewMode = false, Settings = new ExportSettings { EncryptionSecret = secret, ExportAllStoredCredentials = true } }; - var export = await _certifyClient.PerformExport(exportRequest); + var export = await _certifyClient.PerformExport(exportRequest, authContext: null); System.IO.File.WriteAllText(filename, JsonConvert.SerializeObject(export)); @@ -65,7 +64,7 @@ public async Task PerformBackupImport(string[] args) return; } - var importSteps = await _certifyClient.PerformImport(importRequest); + var importSteps = await _certifyClient.PerformImport(importRequest, authContext: null); foreach (var s in importSteps) { diff --git a/src/Certify.CLI/CertifyCLI.RunCertDiagnostics.cs b/src/Certify.CLI/CertifyCLI.RunCertDiagnostics.cs index 5b3b33d67..417f5f8fc 100644 --- a/src/Certify.CLI/CertifyCLI.RunCertDiagnostics.cs +++ b/src/Certify.CLI/CertifyCLI.RunCertDiagnostics.cs @@ -6,7 +6,7 @@ using System.Threading.Tasks; using Certify.Management; using Certify.Models; -using Serilog; +using Microsoft.Extensions.Logging; namespace Certify.CLI { @@ -279,11 +279,7 @@ public async Task FindPendingAuthorizations(bool autoFix) var c = new CertifyManager(); await c.Init(); - var log = new LoggerConfiguration() - .WriteTo.Debug() - .CreateLogger(); - - var logger = new Loggy(log); + var logger = new Loggy(LoggerFactory.Create(builder => builder.AddDebug()).CreateLogger()); foreach (var url in orderUrls) { diff --git a/src/Certify.CLI/CertifyCLI,StoredCredentials.cs b/src/Certify.CLI/CertifyCLI.StoredCredentials.cs similarity index 52% rename from src/Certify.CLI/CertifyCLI,StoredCredentials.cs rename to src/Certify.CLI/CertifyCLI.StoredCredentials.cs index 4f3be5af4..3c4d20934 100644 --- a/src/Certify.CLI/CertifyCLI,StoredCredentials.cs +++ b/src/Certify.CLI/CertifyCLI.StoredCredentials.cs @@ -1,8 +1,6 @@ using System; -using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; -using Certify.Models; using Certify.Models.Config; using Newtonsoft.Json; @@ -27,25 +25,32 @@ internal async Task UpdateStoredCredential(string[] args) var cred = new StoredCredential { StorageKey = storageKey, - DateCreated = DateTime.Now, + DateCreated = DateTime.UtcNow, ProviderType = credentialType, Secret = secretValue, Title = title }; - var result = await _certifyClient.UpdateCredentials(cred); - - if (result != null) + try { - var resultObject = new { Status = "OK", Message = "Credential updated", StorageKey = result?.StorageKey }; - var output = JsonConvert.SerializeObject(resultObject, Formatting.Indented); - Console.WriteLine(output); + var result = await _certifyClient.UpdateCredentials(cred); + if (result != null) + { + + var resultObject = new { Status = "OK", Message = "Credential updated", StorageKey = result?.StorageKey }; + var output = JsonConvert.SerializeObject(resultObject, Formatting.Indented); + Console.WriteLine(output); + } + else + { + var resultObject = new { Status = "Error", Message = "Credential update failed" }; + var output = JsonConvert.SerializeObject(resultObject, Formatting.Indented); + Console.WriteLine(output); + } } - else + catch (Exception ex) { - var resultObject = new { Status = "Error", Message = "Credential update failed" }; - var output = JsonConvert.SerializeObject(resultObject, Formatting.Indented); - Console.WriteLine(output); + Console.WriteLine($"Error updating credentials: {ex.Message}"); } } @@ -57,5 +62,10 @@ internal async Task ListStoredCredentials(string[] args) Console.WriteLine(output); } + private void WriteOutput(object resultObject) + { + var output = JsonConvert.SerializeObject(resultObject, Formatting.Indented); + Console.WriteLine(output); + } } } diff --git a/src/Certify.CLI/Program.cs b/src/Certify.CLI/Program.cs index f732077ec..0cd9a10b3 100644 --- a/src/Certify.CLI/Program.cs +++ b/src/Certify.CLI/Program.cs @@ -178,7 +178,7 @@ private static async Task Main(string[] args) { await p.UpdateStoredCredential(args); } - + if (command == "credential" && args.Contains("list")) { await p.ListStoredCredentials(args); diff --git a/src/Certify.Client/Certify.Client.csproj b/src/Certify.Client/Certify.Client.csproj index c224410c3..35bfd602b 100644 --- a/src/Certify.Client/Certify.Client.csproj +++ b/src/Certify.Client/Certify.Client.csproj @@ -1,9 +1,9 @@  - netstandard2.0 + netstandard2.0;net9.0 AnyCPU - true + False @@ -16,17 +16,18 @@ - - - + + + - + + diff --git a/src/Certify.Client/CertifyApiClient.cs b/src/Certify.Client/CertifyApiClient.cs index 6b5dce441..37b4671d3 100644 --- a/src/Certify.Client/CertifyApiClient.cs +++ b/src/Certify.Client/CertifyApiClient.cs @@ -6,9 +6,10 @@ using System.Net.Http.Headers; using System.Threading; using System.Threading.Tasks; -using Certify.Config.Migration; using Certify.Models; using Certify.Models.Config; +using Certify.Models.Hub; +using Certify.Models.Reporting; using Certify.Models.Utils; using Certify.Shared; using Newtonsoft.Json; @@ -52,8 +53,14 @@ protected override Task SendAsync( ); } + public class AuthContext + { + public string UserId { get; set; } + public string Token { get; set; } + } + // This version of the client communicates with the Certify.Service instance on the local machine - public class CertifyApiClient : ICertifyInternalApiClient + public partial class CertifyApiClient : ICertifyInternalApiClient { private HttpClient _client; private readonly string _baseUri = "/api/"; @@ -132,19 +139,33 @@ public void SetConnectionAuthMode(string mode) CreateHttpClient(); } - private async Task FetchAsync(string endpoint) + private void SetAuthContextForRequest(HttpRequestMessage request, AuthContext authContext) + { + if (authContext != null) + { + request.Headers.Add("X-Context-User-Id", authContext.UserId); + } + } + + private async Task FetchAsync(string endpoint, AuthContext authContext) { try { - var response = await _client.GetAsync(_baseUri + endpoint); - if (response.IsSuccessStatusCode) - { - return await response.Content.ReadAsStringAsync(); - } - else + using (var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_baseUri + endpoint))) { - var error = await response.Content.ReadAsStringAsync(); - throw new ServiceCommsException($"Internal Service Error: {endpoint}: {error} "); + SetAuthContextForRequest(request, authContext); + + var response = await _client.SendAsync(request).ConfigureAwait(false); + + if (response.IsSuccessStatusCode) + { + return await response.Content.ReadAsStringAsync().ConfigureAwait(false); + } + else + { + var error = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + throw new ServiceCommsException($"Internal Service Error: {endpoint}: {error} "); + } } } catch (HttpRequestException exp) @@ -158,7 +179,7 @@ public class ServerErrorMsg public string Message; } - private async Task PostAsync(string endpoint, object data) + private async Task PostAsync(string endpoint, object data, AuthContext authContext) { if (data != null) { @@ -167,30 +188,37 @@ private async Task PostAsync(string endpoint, object data) try { - var response = await _client.PostAsync(_baseUri + endpoint, content); - if (response.IsSuccessStatusCode) - { - return response; - } - else + using (var request = new HttpRequestMessage(HttpMethod.Post, new Uri(_baseUri + endpoint))) { - var error = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + SetAuthContextForRequest(request, authContext); + + request.Content = content; - if (response.StatusCode == HttpStatusCode.Unauthorized) + var response = await _client.SendAsync(request).ConfigureAwait(false); + if (response.IsSuccessStatusCode) { - throw new ServiceCommsException($"API Access Denied: {endpoint}: {error}"); + return response; } else { + var error = await response.Content.ReadAsStringAsync().ConfigureAwait(false); - if (response.StatusCode == HttpStatusCode.InternalServerError && error.Contains("\"message\"")) + if (response.StatusCode == HttpStatusCode.Unauthorized) { - var err = JsonConvert.DeserializeObject(error); - throw new ServiceCommsException($"Internal Service Error: {endpoint}: {err.Message}"); + throw new ServiceCommsException($"API Access Denied: {endpoint}: {error}"); } else { - throw new ServiceCommsException($"Internal Service Error: {endpoint}: {error}"); + + if (response.StatusCode == HttpStatusCode.InternalServerError && error.Contains("\"message\"")) + { + var err = JsonConvert.DeserializeObject(error); + throw new ServiceCommsException($"Internal Service Error: {endpoint}: {err.Message}"); + } + else + { + throw new ServiceCommsException($"Internal Service Error: {endpoint}: {error}"); + } } } } @@ -202,42 +230,48 @@ private async Task PostAsync(string endpoint, object data) } else { - var response = await _client.PostAsync(_baseUri + endpoint, new StringContent("")); + var response = await _client.PostAsync(_baseUri + endpoint, new StringContent("")).ConfigureAwait(false); if (response.IsSuccessStatusCode) { return response; } else { - var error = await response.Content.ReadAsStringAsync(); + var error = await response.Content.ReadAsStringAsync().ConfigureAwait(false); throw new ServiceCommsException($"Internal Service Error: {endpoint}: {error}"); } } } - private async Task DeleteAsync(string endpoint) + private async Task DeleteAsync(string endpoint, AuthContext authContext) { - var response = await _client.DeleteAsync(_baseUri + endpoint); - if (response.IsSuccessStatusCode) + using (var request = new HttpRequestMessage(HttpMethod.Delete, new Uri(_baseUri + endpoint))) { - return response; - } - else - { - var error = await response.Content.ReadAsStringAsync(); - throw new ServiceCommsException($"Internal Service Error: {endpoint}: {error}"); + SetAuthContextForRequest(request, authContext); + + var response = await _client.SendAsync(request).ConfigureAwait(false); + + if (response.IsSuccessStatusCode) + { + return response; + } + else + { + var error = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + throw new ServiceCommsException($"Internal Service Error: {endpoint}: {error}"); + } } } #region System - public async Task GetAppVersion() => await FetchAsync("system/appversion"); + public async Task GetAppVersion(AuthContext authContext = null) => await FetchAsync("system/appversion", authContext); - public async Task CheckForUpdates() + public async Task CheckForUpdates(AuthContext authContext = null) { try { - var result = await FetchAsync("system/updatecheck"); + var result = await FetchAsync("system/updatecheck", authContext); return JsonConvert.DeserializeObject(result); } catch (Exception) @@ -247,97 +281,95 @@ public async Task CheckForUpdates() } } - public async Task> PerformServiceDiagnostics() + public async Task> PerformServiceDiagnostics(AuthContext authContext = null) { - var result = await FetchAsync("system/diagnostics"); + var result = await FetchAsync("system/diagnostics", authContext); return JsonConvert.DeserializeObject>(result); } - public async Task PerformExport(ExportRequest exportRequest) + public async Task UpdateManagementHub(string url, string joiningKey, AuthContext authContext = null) { - var result = await PostAsync("system/migration/export", exportRequest); - return JsonConvert.DeserializeObject(await result.Content.ReadAsStringAsync()); + var result = await PostAsync($"system/hub/update/", new { url, joiningKey }, authContext); + return JsonConvert.DeserializeObject(await result.Content.ReadAsStringAsync()); } - public async Task> PerformImport(ImportRequest importRequest) - { - var result = await PostAsync("system/migration/import", importRequest); - return JsonConvert.DeserializeObject>(await result.Content.ReadAsStringAsync()); - } - public async Task> SetDefaultDataStore(string dataStoreId) + public async Task> SetDefaultDataStore(string dataStoreId, AuthContext authContext = null) { - var result = await PostAsync($"system/datastores/setdefault/{dataStoreId}", null); + var result = await PostAsync($"system/datastores/setdefault/{dataStoreId}", null, authContext); return JsonConvert.DeserializeObject>(await result.Content.ReadAsStringAsync()); } - public async Task> GetDataStoreProviders() + + public async Task> GetDataStoreProviders(AuthContext authContext = null) { - var result = await FetchAsync("system/datastores/providers"); + var result = await FetchAsync("system/datastores/providers", authContext); return JsonConvert.DeserializeObject>(result); } - public async Task> GetDataStoreConnections() + + public async Task> GetDataStoreConnections(AuthContext authContext = null) { - var result = await FetchAsync("system/datastores/"); + var result = await FetchAsync("system/datastores/", authContext); return JsonConvert.DeserializeObject>(result); } - public async Task> CopyDataStore(string sourceId, string targetId) + + public async Task> CopyDataStore(string sourceId, string targetId, AuthContext authContext = null) { - var result = await PostAsync($"system/datastores/copy/{sourceId}/{targetId}", null); + var result = await PostAsync($"system/datastores/copy/{sourceId}/{targetId}", null, authContext: authContext); return JsonConvert.DeserializeObject>(await result.Content.ReadAsStringAsync()); } - public async Task> UpdateDataStoreConnection(DataStoreConnection dataStoreConnection) + public async Task> UpdateDataStoreConnection(DataStoreConnection dataStoreConnection, AuthContext authContext = null) { - var result = await PostAsync($"system/datastores/update", dataStoreConnection); + var result = await PostAsync($"system/datastores/update", dataStoreConnection, authContext); return JsonConvert.DeserializeObject>(await result.Content.ReadAsStringAsync()); } - public async Task> TestDataStoreConnection(DataStoreConnection dataStoreConnection) + public async Task> TestDataStoreConnection(DataStoreConnection dataStoreConnection, AuthContext authContext = null) { - var result = await PostAsync($"system/datastores/test", dataStoreConnection); + var result = await PostAsync($"system/datastores/test", dataStoreConnection, authContext); return JsonConvert.DeserializeObject>(await result.Content.ReadAsStringAsync()); } #endregion System #region Server - public async Task IsServerAvailable(StandardServerTypes serverType) + public async Task IsServerAvailable(StandardServerTypes serverType, AuthContext authContext = null) { - var result = await FetchAsync($"server/isavailable/{serverType}"); + var result = await FetchAsync($"server/isavailable/{serverType}", authContext); return bool.Parse(result); } - public async Task> GetServerSiteList(StandardServerTypes serverType, string itemId = null) + public async Task> GetServerSiteList(StandardServerTypes serverType, string itemId = null, AuthContext authContext = null) { if (string.IsNullOrEmpty(itemId)) { - var result = await FetchAsync($"server/sitelist/{serverType}"); + var result = await FetchAsync($"server/sitelist/{serverType}", authContext); return JsonConvert.DeserializeObject>(result); } else { - var result = await FetchAsync($"server/sitelist/{serverType}/{itemId}"); + var result = await FetchAsync($"server/sitelist/{serverType}/{itemId}", authContext); return JsonConvert.DeserializeObject>(result); } } - public async Task GetServerVersion(StandardServerTypes serverType) + public async Task GetServerVersion(StandardServerTypes serverType, AuthContext authContext = null) { - var result = await FetchAsync($"server/version/{serverType}"); + var result = await FetchAsync($"server/version/{serverType}", authContext); var versionString = JsonConvert.DeserializeObject(result, new Newtonsoft.Json.Converters.VersionConverter()); var version = Version.Parse(versionString); return version; } - public async Task> GetServerSiteDomains(StandardServerTypes serverType, string serverSiteId) + public async Task> GetServerSiteDomains(StandardServerTypes serverType, string serverSiteId, AuthContext authContext = null) { - var result = await FetchAsync($"server/sitedomains/{serverType}/{serverSiteId}"); + var result = await FetchAsync($"server/sitedomains/{serverType}/{serverSiteId}", authContext); return JsonConvert.DeserializeObject>(result); } - public async Task> RunConfigurationDiagnostics(StandardServerTypes serverType, string serverSiteId) + public async Task> RunConfigurationDiagnostics(StandardServerTypes serverType, string serverSiteId, AuthContext authContext = null) { - var results = await FetchAsync($"server/diagnostics/{serverType}/{serverSiteId}"); + var results = await FetchAsync($"server/diagnostics/{serverType}/{serverSiteId}", authContext); return JsonConvert.DeserializeObject>(results); } @@ -345,15 +377,15 @@ public async Task> RunConfigurationDiagnostics(StandardServerTy #region Preferences - public async Task GetPreferences() + public async Task GetPreferences(AuthContext authContext = null) { - var result = await FetchAsync("preferences/"); + var result = await FetchAsync("preferences/", authContext); return JsonConvert.DeserializeObject(result); } - public async Task SetPreferences(Preferences preferences) + public async Task SetPreferences(Preferences preferences, AuthContext authContext = null) { - _ = await PostAsync("preferences/", preferences); + _ = await PostAsync("preferences/", preferences, authContext); return true; } @@ -361,9 +393,9 @@ public async Task SetPreferences(Preferences preferences) #region Managed Certificates - public async Task> GetManagedCertificates(ManagedCertificateFilter filter) + public async Task> GetManagedCertificates(ManagedCertificateFilter filter, AuthContext authContext = null) { - var response = await PostAsync("managedcertificates/search/", filter); + var response = await PostAsync("managedcertificates/search/", filter, authContext); var serializer = new JsonSerializer(); using (var sr = new StreamReader(await response.Content.ReadAsStreamAsync())) @@ -379,9 +411,40 @@ public async Task> GetManagedCertificates(ManagedCertif } } - public async Task GetManagedCertificate(string managedItemId) + /// + /// Get search results, same as GetManagedCertificates but result has count of total results available as used when paging + /// + /// + /// + public async Task GetManagedCertificateSearchResult(ManagedCertificateFilter filter, AuthContext authContext = null) + { + var response = await PostAsync("managedcertificates/results/", filter, authContext).ConfigureAwait(false); + var serializer = new JsonSerializer(); + + using (var sr = new StreamReader(await response.Content.ReadAsStreamAsync().ConfigureAwait(false))) + using (var reader = new JsonTextReader(sr)) + { + var result = serializer.Deserialize(reader); + return result; + } + } + + public async Task GetManagedCertificateSummary(ManagedCertificateFilter filter, AuthContext authContext = null) + { + var response = await PostAsync("managedcertificates/summary/", filter, authContext); + var serializer = new JsonSerializer(); + + using (var sr = new StreamReader(await response.Content.ReadAsStreamAsync())) + using (var reader = new JsonTextReader(sr)) + { + var result = serializer.Deserialize(reader); + return result; + } + } + + public async Task GetManagedCertificate(string managedItemId, AuthContext authContext = null) { - var result = await FetchAsync($"managedcertificates/{managedItemId}"); + var result = await FetchAsync($"managedcertificates/{managedItemId}", authContext); var site = JsonConvert.DeserializeObject(result); if (site != null) { @@ -391,28 +454,28 @@ public async Task GetManagedCertificate(string managedItemId return site; } - public async Task UpdateManagedCertificate(ManagedCertificate site) + public async Task UpdateManagedCertificate(ManagedCertificate site, AuthContext authContext = null) { - var response = await PostAsync("managedcertificates/update", site); + var response = await PostAsync("managedcertificates/update", site, authContext); var json = await response.Content.ReadAsStringAsync(); return JsonConvert.DeserializeObject(json); } - public async Task DeleteManagedCertificate(string managedItemId) + public async Task DeleteManagedCertificate(string managedItemId, AuthContext authContext = null) { - var response = await DeleteAsync($"managedcertificates/delete/{managedItemId}"); + var response = await DeleteAsync($"managedcertificates/delete/{managedItemId}", authContext); return JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync()); } - public async Task RevokeManageSiteCertificate(string managedItemId) + public async Task RevokeManageSiteCertificate(string managedItemId, AuthContext authContext = null) { - var response = await FetchAsync($"managedcertificates/revoke/{managedItemId}"); + var response = await FetchAsync($"managedcertificates/revoke/{managedItemId}", authContext); return JsonConvert.DeserializeObject(response); } - public async Task> BeginAutoRenewal(RenewalSettings settings) + public async Task> BeginAutoRenewal(RenewalSettings settings, AuthContext authContext) { - var response = await PostAsync("managedcertificates/autorenew", settings); + var response = await PostAsync("managedcertificates/autorenew", settings, authContext); var serializer = new JsonSerializer(); using (var sr = new StreamReader(await response.Content.ReadAsStreamAsync())) using (var reader = new JsonTextReader(sr)) @@ -422,11 +485,11 @@ public async Task> BeginAutoRenewal(RenewalSettin } } - public async Task BeginCertificateRequest(string managedItemId, bool resumePaused, bool isInteractive) + public async Task BeginCertificateRequest(string managedItemId, bool resumePaused, bool isInteractive, AuthContext authContext = null) { try { - var response = await FetchAsync($"managedcertificates/renewcert/{managedItemId}/{resumePaused}/{isInteractive}"); + var response = await FetchAsync($"managedcertificates/renewcert/{managedItemId}/{resumePaused}/{isInteractive}", authContext); return JsonConvert.DeserializeObject(response); } catch (Exception exp) @@ -434,94 +497,88 @@ public async Task BeginCertificateRequest(string manag return new CertificateRequestResult { IsSuccess = false, - Message = exp.ToString(), + Message = exp.Message.ToString(), Result = exp }; } } - public async Task CheckCertificateRequest(string managedItemId) + public async Task> TestChallengeConfiguration(ManagedCertificate site, AuthContext authContext = null) { - var json = await FetchAsync($"managedcertificates/requeststatus/{managedItemId}"); - return JsonConvert.DeserializeObject(json); - } - - public async Task> TestChallengeConfiguration(ManagedCertificate site) - { - var response = await PostAsync($"managedcertificates/testconfig", site); + var response = await PostAsync($"managedcertificates/testconfig", site, authContext); return JsonConvert.DeserializeObject>(await response.Content.ReadAsStringAsync()); } - public async Task> PerformChallengeCleanup(ManagedCertificate site) + public async Task> PerformChallengeCleanup(ManagedCertificate site, AuthContext authContext = null) { - var response = await PostAsync($"managedcertificates/challengecleanup", site); + var response = await PostAsync($"managedcertificates/challengecleanup", site, authContext); return JsonConvert.DeserializeObject>(await response.Content.ReadAsStringAsync()); } - public async Task> GetDnsProviderZones(string providerTypeId, string credentialsId) + public async Task> GetDnsProviderZones(string providerTypeId, string credentialId, AuthContext authContext = null) { - var json = await FetchAsync($"managedcertificates/dnszones/{providerTypeId}/{credentialsId}"); + var json = await FetchAsync($"managedcertificates/dnszones/{providerTypeId}/{credentialId}", authContext); return JsonConvert.DeserializeObject>(json); } - public async Task> PreviewActions(ManagedCertificate site) + public async Task> PreviewActions(ManagedCertificate site, AuthContext authContext = null) { - var response = await PostAsync($"managedcertificates/preview", site); + var response = await PostAsync($"managedcertificates/preview", site, authContext); return JsonConvert.DeserializeObject>(await response.Content.ReadAsStringAsync()); } - public async Task> RedeployManagedCertificates(bool isPreviewOnly, bool includeDeploymentTasks) + public async Task> RedeployManagedCertificates(bool isPreviewOnly, bool includeDeploymentTasks, AuthContext authContext = null) { - var response = await FetchAsync($"managedcertificates/redeploy/{isPreviewOnly}/{includeDeploymentTasks}"); + var response = await FetchAsync($"managedcertificates/redeploy/{isPreviewOnly}/{includeDeploymentTasks}", authContext); return JsonConvert.DeserializeObject>(response); } - public async Task ReapplyCertificateBindings(string managedItemId, bool isPreviewOnly, bool includeDeploymentTasks) + public async Task ReapplyCertificateBindings(string managedItemId, bool isPreviewOnly, bool includeDeploymentTasks, AuthContext authContext = null) { - var response = await FetchAsync($"managedcertificates/reapply/{managedItemId}/{isPreviewOnly}/{includeDeploymentTasks}"); + var response = await FetchAsync($"managedcertificates/reapply/{managedItemId}/{isPreviewOnly}/{includeDeploymentTasks}", authContext); return JsonConvert.DeserializeObject(response); } - public async Task RefetchCertificate(string managedItemId) + public async Task RefetchCertificate(string managedItemId, AuthContext authContext = null) { - var response = await FetchAsync($"managedcertificates/fetch/{managedItemId}/{false}"); + var response = await FetchAsync($"managedcertificates/fetch/{managedItemId}/{false}", authContext); return JsonConvert.DeserializeObject(response); } - public async Task> GetChallengeAPIList() + public async Task> GetChallengeAPIList(AuthContext authContext = null) { - var response = await FetchAsync($"managedcertificates/challengeapis/"); + var response = await FetchAsync($"managedcertificates/challengeapis/", authContext); return JsonConvert.DeserializeObject>(response); } - public async Task> GetCurrentChallenges(string type, string key) + public async Task> GetCurrentChallenges(string type, string key, AuthContext authContext = null) { - var result = await FetchAsync($"managedcertificates/currentchallenges/{type}/{key}"); + var result = await FetchAsync($"managedcertificates/currentchallenges/{type}/{key}", authContext); return JsonConvert.DeserializeObject>(result); } - public async Task> GetDeploymentProviderList() + public async Task> GetDeploymentProviderList(AuthContext authContext = null) { - var response = await FetchAsync($"managedcertificates/deploymentproviders/"); + var response = await FetchAsync($"managedcertificates/deploymentproviders/", authContext); return JsonConvert.DeserializeObject>(response); } - public async Task GetDeploymentProviderDefinition(string id, Config.DeploymentTaskConfig config) + public async Task GetDeploymentProviderDefinition(string id, Config.DeploymentTaskConfig config, AuthContext authContext) { - var response = await PostAsync($"managedcertificates/deploymentprovider/{id}", config); + var response = await PostAsync($"managedcertificates/deploymentprovider/{id}", config, authContext); return JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync()); } - public async Task> PerformDeployment(string managedCertificateId, string taskId, bool isPreviewOnly, bool forceTaskExecute) + public async Task> PerformDeployment(string managedCertificateId, string taskId, bool isPreviewOnly, bool forceTaskExecute, AuthContext authContext) { if (!forceTaskExecute) { if (string.IsNullOrEmpty(taskId)) { - var response = await FetchAsync($"managedcertificates/performdeployment/{isPreviewOnly}/{managedCertificateId}"); + var response = await FetchAsync($"managedcertificates/performdeployment/{isPreviewOnly}/{managedCertificateId}", authContext); return JsonConvert.DeserializeObject>(response); } else { - var response = await FetchAsync($"managedcertificates/performdeployment/{isPreviewOnly}/{managedCertificateId}/{taskId}"); + var response = await FetchAsync($"managedcertificates/performdeployment/{isPreviewOnly}/{managedCertificateId}/{taskId}", authContext); return JsonConvert.DeserializeObject>(response); } } @@ -529,32 +586,32 @@ public async Task> PerformDeployment(string managedCertificateI { if (string.IsNullOrEmpty(taskId)) { - var response = await FetchAsync($"managedcertificates/performforceddeployment/{isPreviewOnly}/{managedCertificateId}"); + var response = await FetchAsync($"managedcertificates/performforceddeployment/{isPreviewOnly}/{managedCertificateId}", authContext); return JsonConvert.DeserializeObject>(response); } else { - var response = await FetchAsync($"managedcertificates/performforceddeployment/{isPreviewOnly}/{managedCertificateId}/{taskId}"); + var response = await FetchAsync($"managedcertificates/performforceddeployment/{isPreviewOnly}/{managedCertificateId}/{taskId}", authContext); return JsonConvert.DeserializeObject>(response); } } } - public async Task> ValidateDeploymentTask(DeploymentTaskValidationInfo info) + public async Task> ValidateDeploymentTask(DeploymentTaskValidationInfo info, AuthContext authContext = null) { - var result = await PostAsync($"managedcertificates/validatedeploymenttask", info); + var result = await PostAsync($"managedcertificates/validatedeploymenttask", info, authContext); return JsonConvert.DeserializeObject>(await result.Content.ReadAsStringAsync()); } - public async Task GetItemLog(string id, int limit) + public async Task GetItemLog(string id, int limit, AuthContext authContext = null) { - var response = await FetchAsync($"managedcertificates/log/{id}/{limit}"); - return JsonConvert.DeserializeObject(response); + var response = await FetchAsync($"managedcertificates/log/{id}/{limit}", authContext); + return JsonConvert.DeserializeObject(response); } - public async Task> PerformManagedCertMaintenance(string id = null) + public async Task> PerformManagedCertMaintenance(string id = null, AuthContext authContext = null) { - var result = await FetchAsync($"managedcertificates/maintenance/{id}"); + var result = await FetchAsync($"managedcertificates/maintenance/{id}", authContext); return JsonConvert.DeserializeObject>(result); } @@ -562,50 +619,51 @@ public async Task> PerformManagedCertMaintenance(string id = #region Accounts - public async Task> GetCertificateAuthorities() + public async Task> GetCertificateAuthorities(AuthContext authContext = null) { - var result = await FetchAsync("accounts/authorities"); + var result = await FetchAsync("accounts/authorities", authContext); return JsonConvert.DeserializeObject>(result); } - public async Task UpdateCertificateAuthority(CertificateAuthority ca) + + public async Task UpdateCertificateAuthority(CertificateAuthority ca, AuthContext authContext = null) { - var result = await PostAsync("accounts/authorities", ca); + var result = await PostAsync("accounts/authorities", ca, authContext); return JsonConvert.DeserializeObject(await result.Content.ReadAsStringAsync()); } - public async Task DeleteCertificateAuthority(string id) + public async Task DeleteCertificateAuthority(string id, AuthContext authContext = null) { - var result = await DeleteAsync("accounts/authorities/" + id); + var result = await DeleteAsync("accounts/authorities/" + id, authContext); return JsonConvert.DeserializeObject(await result.Content.ReadAsStringAsync()); } - public async Task> GetAccounts() + public async Task> GetAccounts(AuthContext authContext = null) { - var result = await FetchAsync("accounts"); + var result = await FetchAsync("accounts", authContext); return JsonConvert.DeserializeObject>(result); } - public async Task AddAccount(ContactRegistration contact) + public async Task AddAccount(ContactRegistration contact, AuthContext authContext = null) { - var result = await PostAsync("accounts", contact); + var result = await PostAsync("accounts", contact, authContext); return JsonConvert.DeserializeObject(await result.Content.ReadAsStringAsync()); } - public async Task UpdateAccountContact(ContactRegistration contact) + public async Task UpdateAccountContact(ContactRegistration contact, AuthContext authContext = null) { - var result = await PostAsync($"accounts/update/{contact.StorageKey}", contact); + var result = await PostAsync($"accounts/update/{contact.StorageKey}", contact, authContext); return JsonConvert.DeserializeObject(await result.Content.ReadAsStringAsync()); } - public async Task RemoveAccount(string storageKey, bool deactivate) + public async Task RemoveAccount(string storageKey, bool deactivate, AuthContext authContext = null) { - var result = await DeleteAsync($"accounts/remove/{storageKey}/{deactivate}"); + var result = await DeleteAsync($"accounts/remove/{storageKey}/{deactivate}", authContext); return JsonConvert.DeserializeObject(await result.Content.ReadAsStringAsync()); } - public async Task ChangeAccountKey(string storageKey, string newKeyPEM) + public async Task ChangeAccountKey(string storageKey, string newKeyPEM, AuthContext authContext = null) { - var result = await PostAsync($"accounts/changekey/{storageKey}", new { newKeyPem = newKeyPEM }); + var result = await PostAsync($"accounts/changekey/{storageKey}", new { newKeyPem = newKeyPEM }, authContext); return JsonConvert.DeserializeObject(await result.Content.ReadAsStringAsync()); } @@ -613,42 +671,42 @@ public async Task ChangeAccountKey(string storageKey, string newKe #region Credentials - public async Task> GetCredentials() + public async Task> GetCredentials(AuthContext authContext = null) { - var result = await FetchAsync("credentials"); + var result = await FetchAsync("credentials", authContext); return JsonConvert.DeserializeObject>(result); } - public async Task UpdateCredentials(StoredCredential credential) + public async Task UpdateCredentials(StoredCredential credential, AuthContext authContext = null) { - var result = await PostAsync("credentials", credential); + var result = await PostAsync("credentials", credential, authContext); return JsonConvert.DeserializeObject(await result.Content.ReadAsStringAsync()); } - public async Task DeleteCredential(string credentialKey) + public async Task DeleteCredential(string credentialKey, AuthContext authContext = null) { - var result = await DeleteAsync($"credentials/{credentialKey}"); + var result = await DeleteAsync($"credentials/{credentialKey}", authContext); return JsonConvert.DeserializeObject(await result.Content.ReadAsStringAsync()); } - public async Task TestCredentials(string credentialKey) + public async Task TestCredentials(string credentialKey, AuthContext authContext = null) { - var result = await PostAsync($"credentials/{credentialKey}/test", new { }); + var result = await PostAsync($"credentials/{credentialKey}/test", new { }, authContext); return JsonConvert.DeserializeObject(await result.Content.ReadAsStringAsync()); } #endregion #region Auth - public async Task GetAuthKeyWindows() + public async Task GetAuthKeyWindows(AuthContext authContext) { - var result = await FetchAsync("auth/windows"); + var result = await FetchAsync("auth/windows", authContext); return JsonConvert.DeserializeObject(result); } - public async Task GetAccessToken(string key) + public async Task GetAccessToken(string key, AuthContext authContext) { - var result = await PostAsync("auth/token", new { Key = key }); + var result = await PostAsync("auth/token", new { Key = key }, authContext); _accessToken = JsonConvert.DeserializeObject(await result.Content.ReadAsStringAsync()); if (!string.IsNullOrEmpty(_accessToken)) @@ -659,9 +717,9 @@ public async Task GetAccessToken(string key) return _accessToken; } - public async Task GetAccessToken(string username, string password) + public async Task GetAccessToken(string username, string password, AuthContext authContext = null) { - var result = await PostAsync("auth/token", new { Username = username, Password = password }); + var result = await PostAsync("auth/token", new { Username = username, Password = password }, authContext); _accessToken = JsonConvert.DeserializeObject(await result.Content.ReadAsStringAsync()); if (!string.IsNullOrEmpty(_accessToken)) @@ -672,9 +730,9 @@ public async Task GetAccessToken(string username, string password) return _accessToken; } - public async Task RefreshAccessToken() + public async Task RefreshAccessToken(AuthContext authContext) { - var result = await PostAsync("auth/refresh", new { Token = _accessToken }); + var result = await PostAsync("auth/refresh", new { Token = _accessToken }, authContext); _accessToken = JsonConvert.DeserializeObject(await result.Content.ReadAsStringAsync()); if (!string.IsNullOrEmpty(_accessToken)) @@ -685,6 +743,22 @@ public async Task RefreshAccessToken() return _refreshToken; } + public async Task> GetAccessSecurityPrinciples(AuthContext authContext) + { + var result = await FetchAsync("access/securityprinciples", authContext); + return JsonToObject>(result); + } + + public async Task CheckApiTokenHasAccess(AccessToken token, AccessCheck check, AuthContext authContext = null) + { + var result = await PostAsync("access/apitoken/check", new AccessTokenCheck { Check = check, Token = token }, authContext); + return JsonConvert.DeserializeObject(await result.Content.ReadAsStringAsync()); + } + #endregion + private T JsonToObject(string json) + { + return JsonConvert.DeserializeObject(json); + } } } diff --git a/src/Certify.Client/CertifyServiceClient.cs b/src/Certify.Client/CertifyServiceClient.cs index 0760be343..2f743fc06 100644 --- a/src/Certify.Client/CertifyServiceClient.cs +++ b/src/Certify.Client/CertifyServiceClient.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.IO; using System.Threading.Tasks; using Certify.Models; @@ -156,5 +156,10 @@ public ServerConnection GetConnectionInfo() { return _connectionConfig; } + + public string GetStatusHubUri() + { + return _statusHubUri; + } } } diff --git a/src/Certify.Client/ICertifyClient.cs b/src/Certify.Client/ICertifyClient.cs index 844e8994e..6f19571c4 100644 --- a/src/Certify.Client/ICertifyClient.cs +++ b/src/Certify.Client/ICertifyClient.cs @@ -1,9 +1,10 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; -using Certify.Config.Migration; using Certify.Models; using Certify.Models.Config; +using Certify.Models.Hub; +using Certify.Models.Reporting; using Certify.Models.Utils; using Certify.Shared; @@ -13,121 +14,79 @@ namespace Certify.Client /// /// Base API /// - public interface ICertifyInternalApiClient + public partial interface ICertifyInternalApiClient { - #region System - - Task GetAppVersion(); - - Task CheckForUpdates(); - - Task> PerformServiceDiagnostics(); - Task> PerformManagedCertMaintenance(string id = null); - - Task PerformExport(ExportRequest exportRequest); - Task> PerformImport(ImportRequest importRequest); - - Task> SetDefaultDataStore(string dataStoreId); - Task> GetDataStoreProviders(); - Task> GetDataStoreConnections(); - Task> CopyDataStore(string sourceId, string targetId); - Task> UpdateDataStoreConnection(DataStoreConnection dataStoreConnection); - Task> TestDataStoreConnection(DataStoreConnection dataStoreConnection); - + Task GetAppVersion(AuthContext authContext = null); + Task CheckForUpdates(AuthContext authContext = null); + Task> PerformServiceDiagnostics(AuthContext authContext = null); + Task> PerformManagedCertMaintenance(string id = null, AuthContext authContext = null); + Task> SetDefaultDataStore(string dataStoreId, AuthContext authContext = null); + Task> GetDataStoreProviders(AuthContext authContext = null); + Task> GetDataStoreConnections(AuthContext authContext = null); + Task> CopyDataStore(string sourceId, string targetId, AuthContext authContext = null); + Task> UpdateDataStoreConnection(DataStoreConnection dataStoreConnection, AuthContext authContext = null); + Task> TestDataStoreConnection(DataStoreConnection dataStoreConnection, AuthContext authContext = null); + + Task UpdateManagementHub(string url, string joiningKey, AuthContext authContext = null); + Task CheckApiTokenHasAccess(AccessToken token, AccessCheck check, AuthContext authContext = null); #endregion System #region Server - - Task IsServerAvailable(StandardServerTypes serverType); - - Task> GetServerSiteList(StandardServerTypes serverType, string itemId = null); - - Task GetServerVersion(StandardServerTypes serverType); - - Task> GetServerSiteDomains(StandardServerTypes serverType, string serverSiteId); - - Task> RunConfigurationDiagnostics(StandardServerTypes serverType, string serverSiteId); - - Task> GetCurrentChallenges(string type, string key); - + Task IsServerAvailable(StandardServerTypes serverType, AuthContext authContext = null); + Task> GetServerSiteList(StandardServerTypes serverType, string itemId = null, AuthContext authContext = null); + Task GetServerVersion(StandardServerTypes serverType, AuthContext authContext = null); + Task> GetServerSiteDomains(StandardServerTypes serverType, string serverSiteId, AuthContext authContext = null); + Task> RunConfigurationDiagnostics(StandardServerTypes serverType, string serverSiteId, AuthContext authContext = null); + Task> GetCurrentChallenges(string type, string key, AuthContext authContext = null); #endregion Server #region Preferences - - Task GetPreferences(); - - Task SetPreferences(Preferences preferences); - + Task GetPreferences(AuthContext authContext = null); + Task SetPreferences(Preferences preferences, AuthContext authContext = null); #endregion Preferences #region Credentials - - Task> GetCredentials(); - - Task UpdateCredentials(StoredCredential credential); - - Task DeleteCredential(string credentialKey); - - Task TestCredentials(string credentialKey); - + Task> GetCredentials(AuthContext authContext = null); + Task UpdateCredentials(StoredCredential credential, AuthContext authContext = null); + Task DeleteCredential(string credentialKey, AuthContext authContext = null); + Task TestCredentials(string credentialKey, AuthContext authContext = null); #endregion Credentials #region Managed Certificates - - Task> GetManagedCertificates(ManagedCertificateFilter filter); - - Task GetManagedCertificate(string managedItemId); - - Task UpdateManagedCertificate(ManagedCertificate site); - - Task DeleteManagedCertificate(string managedItemId); - - Task RevokeManageSiteCertificate(string managedItemId); - - Task> BeginAutoRenewal(RenewalSettings settings); - - Task> RedeployManagedCertificates(bool isPreviewOnly, bool includeDeploymentTasks); - - Task ReapplyCertificateBindings(string managedItemId, bool isPreviewOnly, bool includeDeploymentTasks); - - Task RefetchCertificate(string managedItemId); - - Task BeginCertificateRequest(string managedItemId, bool resumePaused, bool isInteractive); - - Task CheckCertificateRequest(string managedItemId); - - Task> TestChallengeConfiguration(ManagedCertificate site); - Task> PerformChallengeCleanup(ManagedCertificate site); - - Task> GetDnsProviderZones(string providerTypeId, string credentialsId); - - Task> PreviewActions(ManagedCertificate site); - - Task> GetChallengeAPIList(); - - Task> GetDeploymentProviderList(); - - Task GetDeploymentProviderDefinition(string id, Config.DeploymentTaskConfig config); - - Task> PerformDeployment(string managedCertificateId, string taskId, bool isPreviewOnly, bool forceTaskExecute); - - Task> ValidateDeploymentTask(DeploymentTaskValidationInfo info); - - Task GetItemLog(string id, int limit); - + Task> GetManagedCertificates(ManagedCertificateFilter filter, AuthContext authContext = null); + Task GetManagedCertificateSearchResult(ManagedCertificateFilter filter, AuthContext authContext = null); + Task GetManagedCertificateSummary(ManagedCertificateFilter filter, AuthContext authContext = null); + Task GetManagedCertificate(string managedItemId, AuthContext authContext = null); + Task UpdateManagedCertificate(ManagedCertificate site, AuthContext authContext = null); + Task DeleteManagedCertificate(string managedItemId, AuthContext authContext = null); + Task RevokeManageSiteCertificate(string managedItemId, AuthContext authContext = null); + Task> BeginAutoRenewal(RenewalSettings settings, AuthContext authContext = null); + Task> RedeployManagedCertificates(bool isPreviewOnly, bool includeDeploymentTasks, AuthContext authContext = null); + Task ReapplyCertificateBindings(string managedItemId, bool isPreviewOnly, bool includeDeploymentTasks, AuthContext authContext = null); + Task RefetchCertificate(string managedItemId, AuthContext authContext = null); + Task BeginCertificateRequest(string managedItemId, bool resumePaused, bool isInteractive, AuthContext authContext = null); + Task> TestChallengeConfiguration(ManagedCertificate site, AuthContext authContext = null); + Task> PerformChallengeCleanup(ManagedCertificate site, AuthContext authContext = null); + Task> GetDnsProviderZones(string providerTypeId, string credentialId, AuthContext authContext = null); + Task> PreviewActions(ManagedCertificate site, AuthContext authContext = null); + Task> GetChallengeAPIList(AuthContext authContext = null); + Task> GetDeploymentProviderList(AuthContext authContext = null); + Task GetDeploymentProviderDefinition(string id, Config.DeploymentTaskConfig config, AuthContext authContext = null); + Task> PerformDeployment(string managedCertificateId, string taskId, bool isPreviewOnly, bool forceTaskExecute, AuthContext authContext = null); + Task> ValidateDeploymentTask(DeploymentTaskValidationInfo info, AuthContext authContext = null); + Task GetItemLog(string id, int limit, AuthContext authContext = null); #endregion Managed Certificates #region Accounts - Task> GetCertificateAuthorities(); - Task UpdateCertificateAuthority(CertificateAuthority ca); - Task DeleteCertificateAuthority(string id); - Task> GetAccounts(); - Task AddAccount(ContactRegistration contact); - Task UpdateAccountContact(ContactRegistration contact); - Task RemoveAccount(string storageKey, bool deactivate); - Task ChangeAccountKey(string storageKey, string newKeyPEM = null); - + Task> GetCertificateAuthorities(AuthContext authContext = null); + Task UpdateCertificateAuthority(CertificateAuthority ca, AuthContext authContext = null); + Task DeleteCertificateAuthority(string id, AuthContext authContext = null); + Task> GetAccounts(AuthContext authContext = null); + Task AddAccount(ContactRegistration contact, AuthContext authContext = null); + Task UpdateAccountContact(ContactRegistration contact, AuthContext authContext = null); + Task RemoveAccount(string storageKey, bool deactivate, AuthContext authContext = null); + Task ChangeAccountKey(string storageKey, string newKeyPEM = null, AuthContext authContext = null); #endregion Accounts } @@ -136,17 +95,11 @@ public interface ICertifyInternalApiClient /// public interface ICertifyClient : ICertifyInternalApiClient { - event Action OnMessageFromService; - event Action OnRequestProgressStateUpdated; - event Action OnManagedCertificateUpdated; - Task ConnectStatusStreamAsync(); - Shared.ServerConnection GetConnectionInfo(); - Task EnsureServiceHubConnected(); } } diff --git a/src/Certify.Client/IManagementServerClient.cs b/src/Certify.Client/IManagementServerClient.cs new file mode 100644 index 000000000..1f40a555f --- /dev/null +++ b/src/Certify.Client/IManagementServerClient.cs @@ -0,0 +1,21 @@ +using System; +using System.Threading.Tasks; +using Certify.Models.Hub; + +namespace Certify.Client +{ + public interface IManagementServerClient + { + event Action OnConnectionClosed; + event Action OnConnectionReconnected; + event Action OnConnectionReconnecting; + event Func> OnGetCommandResult; + event Func OnGetInstanceItems; + + Task ConnectAsync(); + Task Disconnect(); + bool IsConnected(); + void SendInstanceInfo(Guid commandId, bool isCommandResponse = true); + void SendNotificationToManagementHub(string msgCommandType, object updateMsg); + } +} \ No newline at end of file diff --git a/src/Certify.Client/ManagementServerClient.cs b/src/Certify.Client/ManagementServerClient.cs new file mode 100644 index 000000000..d122015f9 --- /dev/null +++ b/src/Certify.Client/ManagementServerClient.cs @@ -0,0 +1,160 @@ +using System; +using System.Threading.Tasks; +using Certify.Models.Hub; +using Microsoft.AspNetCore.SignalR.Client; +using Microsoft.Extensions.DependencyInjection; + +namespace Certify.Client +{ + /// + /// Implements hub communication with a central management server + /// + public class ManagementServerClient : IManagementServerClient + { + + public event Action OnConnectionReconnecting; + + public event Action OnConnectionReconnected; + + public event Action OnConnectionClosed; + + public event Func OnGetInstanceItems; + public event Func> OnGetCommandResult; + + private HubConnection _connection; + + private string _hubUri = ""; + + private ManagedInstanceInfo _instanceInfo; + + public ManagementServerClient(string hubUri, ManagedInstanceInfo instanceInfo) + { + _hubUri = $"{hubUri}"; + _instanceInfo = instanceInfo; + } + + public bool IsConnected() + { + if (_connection == null || _connection?.State == HubConnectionState.Disconnected) + { + return false; + } + + return true; + } + + public async Task ConnectAsync() + { + var allowUntrusted = true; + + _connection = new HubConnectionBuilder() + + .WithUrl(_hubUri, opts => + { + opts.HttpMessageHandlerFactory = (message) => + { + if (message is System.Net.Http.HttpClientHandler clientHandler) + { + if (allowUntrusted) + { + // allow invalid/untrusted tls cert + clientHandler.ServerCertificateCustomValidationCallback += + (sender, certificate, chain, sslPolicyErrors) => true; + } + } + + return message; + }; + + opts.UseStatefulReconnect = true; + + }) + .WithAutomaticReconnect() + .AddMessagePackProtocol() + .Build(); + + _connection.On(ManagementHubMessages.SendCommandRequest, (Action)((s) => + { + PerformRequestedCommand(s); + })); + + await _connection.StartAsync(); + + _connection.Closed += async (error) => + { + await Task.Delay(new Random().Next(0, 5) * 1000); + await _connection.StartAsync(); + }; + + } + + public async Task Disconnect() + { + await _connection.StopAsync(); + + } + private void PerformRequestedCommand(InstanceCommandRequest cmd) + { + System.Diagnostics.Debug.WriteLine($"Got command from management server {cmd}"); + + if (cmd.CommandType == ManagementHubCommands.GetInstanceInfo) + { + SendInstanceInfo(cmd.CommandId); + } + else + { + var task = OnGetCommandResult?.Invoke(cmd); + if (task != null) + { + if (cmd.CommandType != ManagementHubCommands.Reconnect) + { + _connection.SendAsync(ManagementHubMessages.ReceiveCommandResult, task.Result).Wait(); + } + else + { + task.Wait(); + } + } + } + } + + /// + /// Send instance info back to the management hub + /// + /// Unique ID for this command, New Guid if command is not a response + /// If false, message is not being sent in response to an existing query + public void SendInstanceInfo(Guid commandId, bool isCommandResponse = true) + { + // send this clients instance ID back to the hub to identify it in the connection: should send a shared secret before this to confirm this client knows and is not impersonating another instance + var result = new InstanceCommandResult + { + CommandId = commandId, + CommandType = ManagementHubCommands.GetInstanceInfo, + Value = System.Text.Json.JsonSerializer.Serialize(_instanceInfo), + IsCommandResponse = isCommandResponse + }; + + result.ObjectValue = _instanceInfo; + _connection.SendAsync(ManagementHubMessages.ReceiveCommandResult, result); + } + + /// + /// Send mgmt hub a general notification message to be actioned + /// + public void SendNotificationToManagementHub(string msgCommandType, object updateMsg) + { + var result = new InstanceCommandResult + { + CommandId = Guid.NewGuid(), + InstanceId = _instanceInfo.InstanceId, + CommandType = msgCommandType, + Value = System.Text.Json.JsonSerializer.Serialize(updateMsg), + ObjectValue = updateMsg, + IsCommandResponse = false + }; + + result.ObjectValue = updateMsg; + _connection.SendAsync(ManagementHubMessages.ReceiveCommandResult, result); + } + } +} diff --git a/src/Certify.Core.Service.sln b/src/Certify.Core.Service.sln index a6b5fb3f5..271562c99 100644 --- a/src/Certify.Core.Service.sln +++ b/src/Certify.Core.Service.sln @@ -69,6 +69,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Certify.Providers.ACME.Anvi EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Certify.ACME.Anvil", "..\..\libs\anvil\src\Certify.ACME.Anvil\Certify.ACME.Anvil.csproj", "{443202E1-B6E5-4625-BC3E-B3CB54CF4055}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Certify.Server.Hub.Api", "Certify.Server\Certify.Server.Hub.Api\Certify.Server.Hub.Api.csproj", "{2DB50C13-7535-4D01-8EA5-1839F1472D7B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -277,6 +279,14 @@ Global {443202E1-B6E5-4625-BC3E-B3CB54CF4055}.Release|Any CPU.Build.0 = Release|Any CPU {443202E1-B6E5-4625-BC3E-B3CB54CF4055}.Release|x64.ActiveCfg = Release|Any CPU {443202E1-B6E5-4625-BC3E-B3CB54CF4055}.Release|x64.Build.0 = Release|Any CPU + {2DB50C13-7535-4D01-8EA5-1839F1472D7B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2DB50C13-7535-4D01-8EA5-1839F1472D7B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2DB50C13-7535-4D01-8EA5-1839F1472D7B}.Debug|x64.ActiveCfg = Debug|Any CPU + {2DB50C13-7535-4D01-8EA5-1839F1472D7B}.Debug|x64.Build.0 = Debug|Any CPU + {2DB50C13-7535-4D01-8EA5-1839F1472D7B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2DB50C13-7535-4D01-8EA5-1839F1472D7B}.Release|Any CPU.Build.0 = Release|Any CPU + {2DB50C13-7535-4D01-8EA5-1839F1472D7B}.Release|x64.ActiveCfg = Release|Any CPU + {2DB50C13-7535-4D01-8EA5-1839F1472D7B}.Release|x64.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/Certify.Core/Certify.Core.csproj b/src/Certify.Core/Certify.Core.csproj index 144ad0de9..54b5481ba 100644 --- a/src/Certify.Core/Certify.Core.csproj +++ b/src/Certify.Core/Certify.Core.csproj @@ -1,96 +1,84 @@ - - - net462;netstandard2.0;netstandard2.1 - Debug;Release; - AnyCPU - - - - false - bin\Debug\ - DEBUG;TRACE - prompt - 4 - x64 - - - false - bin\Debug\ - DEBUG;TRACE - prompt - 4 - x64 - - - - true - bin\Release\ - TRACE - prompt - 4 - x64 - - - - true - bin\Release\ - TRACE - prompt - 4 - x64 - - - - bin\x64\Debug\ - DEBUG;TRACE - - x64 - prompt - MinimumRecommendedRules.ruleset - - - bin\x64\Release\ - TRACE - true - - x64 - prompt - MinimumRecommendedRules.ruleset - - - Debug - AnyCPU - {58881E46-4A76-47B9-9725-FA7C5F0090D0} - Library - Properties - Certify.Core - Certify.Core - v4.6.2 - 512 - - PackageReference - - - - - - - - - - - - - - - - - + + + net462;net9.0 + Debug;Release; + AnyCPU + latest + + - - - CertifyManager.cs - - + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + AnyCPU + + + + + true + bin\Release\ + TRACE + prompt + 4 + AnyCPU + + + + + Debug + AnyCPU + {58881E46-4A76-47B9-9725-FA7C5F0090D0} + Library + Properties + Certify.Core + Certify.Core + v4.6.2 + 512 + + PackageReference + + + 1701;1702;CA1068 + + + 1701;1702;CA1068 + + + 1701;1702;CA1068 + + + 1701;1702;CA1068 + + + + + + + + + + + + + + + + + + + + + + + + all + analyzers + + + + + \ No newline at end of file diff --git a/src/Certify.Core/Management/Access/AccessControl.cs b/src/Certify.Core/Management/Access/AccessControl.cs index 4c79e939a..fb5fabcf0 100644 --- a/src/Certify.Core/Management/Access/AccessControl.cs +++ b/src/Certify.Core/Management/Access/AccessControl.cs @@ -1,245 +1,345 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Security.Cryptography; +using System.Text; using System.Threading.Tasks; +using Certify.Models.Config; +using Certify.Models.Hub; using Certify.Models.Providers; +using Certify.Providers; namespace Certify.Core.Management.Access { - public enum SecurityPrincipleType - { - User = 1, - Application = 2 - } - public class SecurityPrinciple + public class AccessControl : IAccessControl { - public string Id { get; set; } - public string Username { get; set; } - public string Password { get; set; } - public string Email { get; set; } - public string Description { get; set; } - - /// - /// If true, user is a mapping to an external AD/LDAP group or user - /// - public bool IsDirectoryMapping { get; set; } - - public List SystemRoleIds { get; set; } - - public SecurityPrincipleType PrincipleType { get; set; } - - public string AuthKey { get; set; } - } - - public class StandardRoles - { - public static Role Administrator { get; } = new Role("sysadmin", "Administrator", "Certify Server Administrator"); - public static Role DomainOwner { get; } = new Role("domain_owner", "Domain Owner", "Controls certificate access for a given domain"); - public static Role DomainRequestor { get; } = new Role("subdomain_requestor", "Subdomain Requestor", "Can request new certs for subdomains on a given domain"); - public static Role CertificateConsumer { get; } = new Role("cert_consumer", "Certificate Consumer", "User of a given certificate"); - - } - - public class ResourceTypes - { - public static string System { get; } = "system"; - public static string Domain { get; } = "domain"; - } - - public class Role - { - public string Id { get; set; } - public string Title { get; set; } - public string Description { get; set; } + private IConfigurationStore _store; + private ILog _log; - public Role() { } - public Role(string id, string title, string description) + public AccessControl(ILog log, IConfigurationStore store) { - Id = id; - Title = title; - Description = description; + _store = store; + _log = log; } - } - - public class ResourceAssignedRole - { - public string PrincipleId { get; set; } - public string RoleId { get; set; } - } - /// - /// Define a domain or resource and who the controlling users are - /// - public class ResourceProfile - { - public string Id { get; set; } = new Guid().ToString(); - public string ResourceType { get; set; } - public string Identifier { get; set; } - public List AssignedRoles { get; set; } - // public List DefaultChallenges { get; set; } - } - - public interface IObjectStore - { - Task Save(string id, object item); - Task Load(string id); - } - public class AccessControl - { - private IObjectStore _store; - private ILog _log; + public async Task AuditWarning(string template, params object[] propertyvalues) + { + _log?.Warning(template, propertyvalues); + } - public AccessControl(ILog log, IObjectStore store) + public async Task AuditError(string template, params object[] propertyvalues) { - _store = store; - _log = log; + _log?.Error(template, propertyvalues); } - public async Task> GetSystemRoles() + public async Task AuditInformation(string template, params object[] propertyvalues) { + _log?.Information(template, propertyvalues); + } - return await Task.FromResult(new List + /// + /// Check if the system has been initialized with a security principle + /// + /// + public async Task IsInitialized() + { + var list = await GetSecurityPrinciples("system"); + if (list.Count != 0) { - StandardRoles.Administrator, - StandardRoles.DomainOwner, - StandardRoles.CertificateConsumer - }); + return true; + } + else + { + return false; + } + } + + public async Task> GetRoles(string contextUserId) + { + return await _store.GetItems(nameof(Role)); } - public async Task> GetSecurityPrinciples() + public async Task> GetSecurityPrinciples(string contextUserId) { - return await _store.Load>("principles"); + return await _store.GetItems(nameof(SecurityPrinciple)); } - public async Task AddSecurityPrinciple(SecurityPrinciple principle, string contextUserId, bool bypassIntegrityCheck = false) + public async Task AddSecurityPrinciple(string contextUserId, SecurityPrinciple principle, bool bypassIntegrityCheck = false) { - if (!await IsPrincipleInRole(contextUserId, StandardRoles.Administrator.Id, contextUserId) && !bypassIntegrityCheck) + if (!bypassIntegrityCheck && !await IsPrincipleInRole(contextUserId, contextUserId, StandardRoles.Administrator.Id)) + { + await AuditWarning("User {contextUserId} attempted to use AddSecurityPrinciple [{principleId}] without being in required role.", contextUserId, principle?.Id); + return false; + } + + var existing = await GetSecurityPrinciple(contextUserId, principle.Id); + if (existing != null) { - _log?.Warning($"User {contextUserId} attempted to use AddSecurityPrinciple [{principle?.Id}] without being in required role."); + await AuditWarning("User {contextUserId} attempted to use AddSecurityPrinciple [{principleId}] which already exists.", contextUserId, principle?.Id); return false; } - var principles = await GetSecurityPrinciples(); - principles.Add(principle); - await _store.Save>("principles", principles); + if (!string.IsNullOrWhiteSpace(principle.Password)) + { + principle.Password = HashPassword(principle.Password); + } + else + { + principle.Password = HashPassword(Guid.NewGuid().ToString()); + } + + principle.AvatarUrl = GetAvatarUrlForPrinciple(principle); - _log?.Information($"User {contextUserId} added security principle [{principle?.Id}] {principle?.Username}"); + await _store.Add(nameof(SecurityPrinciple), principle); + + await AuditInformation("User {contextUserId} added security principle [{principleId}] {username}", contextUserId, principle?.Id, principle?.Username); return true; } - public async Task UpdateSecurityPrinciple(SecurityPrinciple principle, string contextUserId) + public string GetAvatarUrlForPrinciple(SecurityPrinciple principle) + { + return string.IsNullOrWhiteSpace(principle.Email) ? "https://gravatar.com/avatar/00000000000000000000000000000000" : $"https://gravatar.com/avatar/{GetSHA256Hash(principle.Email.Trim().ToLower())}"; + } + + public async Task UpdateSecurityPrinciple(string contextUserId, SecurityPrinciple principle) { - if (!await IsPrincipleInRole(contextUserId, StandardRoles.Administrator.Id, contextUserId)) + if (!await IsPrincipleInRole(contextUserId, contextUserId, StandardRoles.Administrator.Id)) { - _log?.Warning($"User {contextUserId} attempted to use UpdateSecurityPrinciple [{principle?.Id}] without being in required role."); + await AuditWarning("User {contextUserId} attempted to use UpdateSecurityPrinciple [{principleId}] without being in required role.", contextUserId, principle?.Id); return false; } - var principles = await GetSecurityPrinciples(); + try + { + var updateSp = await _store.Get(nameof(SecurityPrinciple), principle.Id); + updateSp.Email = principle.Email; + updateSp.Description = principle.Description; + updateSp.Title = principle.Title; - var existing = principles.Find(p => p.Id == principle.Id); - if (existing != null) + updateSp.AvatarUrl = GetAvatarUrlForPrinciple(principle); + + await _store.Update(nameof(SecurityPrinciple), updateSp); + } + catch { - principles.Remove(existing); + await AuditWarning("User {contextUserId} attempted to use UpdateSecurityPrinciple [{principleId}], but was not successful", contextUserId, principle?.Id); + return false; } - principles.Add(principle); - await _store.Save>("principles", principles); - - _log?.Information($"User {contextUserId} updated security principle [{principle?.Id}] {principle?.Username}"); + await AuditInformation("User {contextUserId} updated security principle [{principleId}] {principleUsername}", contextUserId, principle?.Id, principle?.Username); return true; } /// /// delete a single security principle /// - /// /// + /// /// - public async Task DeleteSecurityPrinciple(string id, string contextUserId) + public async Task DeleteSecurityPrinciple(string contextUserId, string id, bool allowSelfDelete = false) { - if (!await IsPrincipleInRole(contextUserId, StandardRoles.Administrator.Id, contextUserId)) + if (!await IsPrincipleInRole(contextUserId, contextUserId, StandardRoles.Administrator.Id)) { - _log?.Warning($"User {contextUserId} attempted to use DeleteSecurityPrinciple [{id}] without being in required role."); + await AuditWarning("User {contextUserId} attempted to use DeleteSecurityPrinciple [{id}] without being in required role.", contextUserId, id); return false; } - if (id == contextUserId) + if (!allowSelfDelete && id == contextUserId) { - _log?.Information($"User {contextUserId} tried to delete themselves."); + await AuditWarning("User {contextUserId} tried to delete themselves.", contextUserId); return false; } - var principles = await GetSecurityPrinciples(); + var existing = await GetSecurityPrinciple(contextUserId, id); - var existing = principles.Find(p => p.Id == id); - if (existing != null) + var deleted = await _store.Delete(nameof(SecurityPrinciple), id); + + if (deleted != true) + { + await AuditWarning("User {contextUserId} attempted to delete security principle [{id}] {existingUsername}, but was not successful", contextUserId, id, existing?.Username); + return false; + } + + var assignedRoles = await GetAssignedRoles(contextUserId, id); + foreach (var a in assignedRoles) { - principles.Remove(existing); + await _store.Delete(nameof(AssignedRole), a.Id); } - await _store.Save>("principles", principles); + await AuditInformation("User {contextUserId} deleted security principle [{id}] {existingUsername}", contextUserId, id, existing?.Username); - // TODO: remove assigned roles within all resource profiles + return true; + } - var allResourceProfiles = await GetResourceProfiles(id, contextUserId); - foreach (var r in allResourceProfiles) + public async Task GetSecurityPrinciple(string contextUserId, string id) + { + try { - if (r.AssignedRoles.Any(ro => ro.PrincipleId == id)) - { - var newAssignedRoles = r.AssignedRoles.Where(ra => ra.PrincipleId != id).ToList(); - r.AssignedRoles = newAssignedRoles; - } + return await _store.Get(nameof(SecurityPrinciple), id); + } + catch (Exception exp) + { + await AuditError("User {contextUserId} attempted to retrieve security principle [{id}] but was not successful : {exp}", contextUserId, id, exp); + + return default; } + } - await _store.Save>("resourceprofiles", allResourceProfiles); + public async Task GetSecurityPrincipleByUsername(string contextUserId, string username) + { + if (string.IsNullOrWhiteSpace(username)) + { + return default; + } - _log?.Information($"User {contextUserId} deleted security principle [{id}] {existing?.Username}"); + var list = await GetSecurityPrinciples(contextUserId); - return true; + return list?.SingleOrDefault(sp => sp.Username?.ToLowerInvariant() == username.ToLowerInvariant()); } - public async Task IsAuthorised(string principleId, string roleId, string resourceType, string identifier, string contextUserId) + /// + /// Check if a security principle has access to the given resource action + /// + /// Security principle performing access check + /// Security principle to check access for + /// resource type being accessed + /// resource action required + /// optional resource identifier, if access is limited by specific resource + /// optional scoped assigned roles to limit access to (for scoped access token checks etc) + /// + public async Task IsSecurityPrincipleAuthorised(string contextUserId, AccessCheck check) { - var resourceProfiles = await GetResourceProfiles(principleId, contextUserId); + // to determine is a principle has access to perform a particular action + // for each group the principle is part of + + // TODO: cache results for performance based on last update of access control config, which will be largely static + + // get all assigned roles (all users) + var allAssignedRoles = await _store.GetItems(nameof(AssignedRole)); + + // get all defined roles + var allRoles = await _store.GetItems(nameof(Role)); - if (resourceProfiles.Any(r => r.ResourceType == resourceType && r.Identifier == identifier && r.AssignedRoles.Any(a => a.PrincipleId == principleId && a.RoleId == roleId))) + // get all defined policies + var allPolicies = await _store.GetItems(nameof(ResourcePolicy)); + + // get the assigned roles for this specific security principle + var spAssignedRoles = allAssignedRoles.Where(a => a.SecurityPrincipleId == check.SecurityPrincipleId); + + // if scoped assigned role ID specified (access token check etc), reduce scope of assigned roles to check + if (check.ScopedAssignedRoles?.Any() == true) { - // principle has an exactly matching role granted for this resource - return true; + spAssignedRoles = spAssignedRoles.Where(a => check.ScopedAssignedRoles.Contains(a.Id)); } - if (resourceType == ResourceTypes.Domain && !identifier.Trim().StartsWith("*") && identifier.Contains(".")) + // get all role definitions included in the principles assigned roles + var spAssignedRoleDefinitions = allRoles.Where(r => spAssignedRoles.Any(t => t.RoleId == r.Id)); + + var spSpecificAssignedRoles = spAssignedRoles.Where(a => spAssignedRoleDefinitions.Any(r => r.Id == a.RoleId)); + + // get all resource policies included in the principles assigned roles + var spAssignedPolicies = allPolicies.Where(r => spAssignedRoleDefinitions.Any(p => p.Policies.Contains(r.Id))); + + // check an assigned policy allows the required resource action + if (spAssignedPolicies.Any(a => a.ResourceActions.Contains(check.ResourceActionId))) { - // get wildcard for respective domain identifier - var identifierComponents = identifier.Split('.'); - var wildcard = "*." + string.Join(".", identifierComponents.Skip(1)); + // if any of the service principles assigned roles are restricted by resource type, + // check for identifier matches (e.g. role assignment restricted on domains ) - if (resourceProfiles.Any(r => r.ResourceType == resourceType && r.Identifier == wildcard && r.AssignedRoles.Any(a => a.PrincipleId == principleId && a.RoleId == roleId))) + if (spSpecificAssignedRoles.Any(a => a.IncludedResources?.Any(r => r.ResourceType == check.ResourceType) == true)) + { + var allIncludedResources = spSpecificAssignedRoles.SelectMany(a => a.IncludedResources).Distinct(); + + if (check.ResourceType == ResourceTypes.Domain && !check.Identifier.Trim().StartsWith("*") && check.Identifier.Contains(".")) + { + // get wildcard for respective domain identifier + var identifierComponents = check.Identifier.Split('.'); + + var wildcard = "*." + string.Join(".", identifierComponents.Skip(1)); + + // search for matching identifier + + foreach (var includedResource in allIncludedResources) + { + if (includedResource.ResourceType == check.ResourceType && includedResource.Identifier == wildcard) + { + return true; + } + else if (includedResource.ResourceType == check.ResourceType && includedResource.Identifier == check.Identifier) + { + return true; + } + } + } + + // no match + return false; + } + else { - // principle has an matching role granted for this resource as a wildcard return true; } } + else + { + return false; + } + } + + public async Task IsAccessTokenAuthorised(string contextUserId, AccessToken accessToken, AccessCheck check) + { + // resolve security principle from access token + + var assignedTokens = await _store.GetItems(nameof(AssignedAccessToken)); + + // check if a non-expired/non-revoked access token exists matching the given client ID + var knownAssignedToken = assignedTokens.SingleOrDefault(t => t.AccessTokens.Any(a => a.ClientId == accessToken.ClientId && a.Secret == accessToken.Secret && a.DateRevoked == null && (a.DateExpiry == null || a.DateExpiry >= DateTimeOffset.UtcNow))); + + if (knownAssignedToken == null) + { + return new ActionResult("Access token unknown, expired or revoked.", false); + } + + // check related principle has access + + var scopedCheck = new AccessCheck + { + SecurityPrincipleId = knownAssignedToken.SecurityPrincipleId, + ResourceActionId = check.ResourceActionId, + Identifier = check.Identifier, + ResourceType = check.ResourceType, + ScopedAssignedRoles = knownAssignedToken.ScopedAssignedRoles + }; + + var isAuthorised = await IsSecurityPrincipleAuthorised(contextUserId, scopedCheck); - return false; + if (isAuthorised) + { + // TODO: check token scope restrictions + + return new ActionResult("OK", true); + } + else + { + return new ActionResult("Access token not authorized or invalid for action, resource or identifier", false); + } } /// /// Check security principle is in a given role at the system level /// + /// /// /// - /// /// - public async Task IsPrincipleInRole(string id, string roleId, string contextUserId) + public async Task IsPrincipleInRole(string contextUserId, string id, string roleId) { - var resourceProfiles = await GetResourceProfiles(id, contextUserId); + var assignedRoles = await _store.GetItems(nameof(AssignedRole)); - if (resourceProfiles.Any(r => r.ResourceType == ResourceTypes.System && r.AssignedRoles.Any(a => a.PrincipleId == id && a.RoleId == roleId))) + if (assignedRoles.Any(a => a.RoleId == roleId && a.SecurityPrincipleId == id)) { return true; } @@ -249,46 +349,284 @@ public async Task IsPrincipleInRole(string id, string roleId, string conte } } + public async Task AddResourcePolicy(string contextUserId, ResourcePolicy resourceProfile, bool bypassIntegrityCheck = false) + { + if (!bypassIntegrityCheck && !await IsPrincipleInRole(contextUserId, contextUserId, StandardRoles.Administrator.Id)) + { + await AuditWarning("User {contextUserId} attempted to use AddResourcePolicy [{resourceProfileId}] without being in required role.", contextUserId, resourceProfile?.Id); + return false; + } + + await _store.Add(nameof(ResourcePolicy), resourceProfile); + + await AuditInformation("User {contextUserId} added resource policy [{resourceProfile.Id}]", contextUserId, resourceProfile?.Id); + return true; + } + + public async Task UpdateSecurityPrinciplePassword(string contextUserId, SecurityPrinciplePasswordUpdate passwordUpdate) + { + if (passwordUpdate.SecurityPrincipleId != contextUserId && !await IsPrincipleInRole(contextUserId, contextUserId, StandardRoles.Administrator.Id)) + { + await AuditWarning("User {contextUserId} attempted to use updated password for [{id}] without being in required role.", contextUserId, passwordUpdate.SecurityPrincipleId); + return false; + } + + var updated = false; + + var principle = await GetSecurityPrinciple(contextUserId, passwordUpdate.SecurityPrincipleId); + + if (IsPasswordValid(passwordUpdate.Password, principle.Password)) + { + try + { + var updateSp = await _store.Get(nameof(SecurityPrinciple), principle.Id); + updateSp.Password = HashPassword(passwordUpdate.NewPassword); + await _store.Update(nameof(SecurityPrinciple), updateSp); + updated = true; + } + catch + { + await AuditWarning("User {contextUserId} attempted to use UpdateSecurityPrinciple password [{principleId}], but was not successful", contextUserId, principle?.Id); + return false; + } + } + else + { + await AuditInformation("Previous password did not match while updating security principle password", contextUserId, principle.Username, principle.Id); + } + + if (updated) + { + await AuditInformation("User {contextUserId} updated password for [{username} - {id}]", contextUserId, principle.Username, principle.Id); + } + else + { + + await AuditWarning("User {contextUserId} failed to update password for [{username} - {id}]", contextUserId, principle.Username, principle.Id); + } + + return updated; + } + + public bool IsPasswordValid(string password, string currentHash) + { + if (string.IsNullOrWhiteSpace(currentHash) && string.IsNullOrWhiteSpace(password)) + { + return true; + } + + var components = currentHash.Split('.'); + + // hash provided password with same salt to compare result + return currentHash == HashPassword(password, components[1]); + } + /// - /// return list of resources this user has some access to + /// Hash password, optionally using the provided salt or generating new salt /// - /// + /// + /// /// - public async Task> GetResourceProfiles(string userId, string contextUserId) + public string HashPassword(string password, string saltString = null) + { + var iterations = 600000; + var salt = new byte[24]; + + if (saltString == null) + { + RandomNumberGenerator.Create().GetBytes(salt); + } + else + { + salt = Convert.FromBase64String(saltString); + } +#if NET8_0_OR_GREATER + var pbkdf2 = new Rfc2898DeriveBytes(password, salt, iterations, HashAlgorithmName.SHA512); +#else + var pbkdf2 = new Rfc2898DeriveBytes(password, salt, iterations); +#endif + + var hash = pbkdf2.GetBytes(24); + + var hashed = $"v1.{Convert.ToBase64String(salt)}.{Convert.ToBase64String(hash)}"; + + return hashed; + } + + public async Task AddRole(string contextUserId, Role r, bool bypassIntegrityCheck = false) + { + if (!bypassIntegrityCheck && !await IsPrincipleInRole(contextUserId, contextUserId, StandardRoles.Administrator.Id)) + { + await AuditWarning("User {contextUserId} attempted to add an role action without being in required role.", contextUserId); + return false; + } + + await _store.Add(nameof(Role), r); + return true; + } + + public async Task AddAssignedRole(string contextUserId, AssignedRole r, bool bypassIntegrityCheck = false) + { + if (!bypassIntegrityCheck && !await IsPrincipleInRole(contextUserId, contextUserId, StandardRoles.Administrator.Id)) + { + await AuditWarning("User {contextUserId} attempted to add an assigned role without being in required role.", contextUserId); + return false; + } + + await _store.Add(nameof(AssignedRole), r); + return true; + } + + public async Task AddResourceAction(string contextUserId, ResourceAction action, bool bypassIntegrityCheck = false) { - var allResourceProfiles = await _store.Load>("resourceprofiles"); + if (!bypassIntegrityCheck && !await IsPrincipleInRole(contextUserId, contextUserId, StandardRoles.Administrator.Id)) + { + await AuditWarning("User {contextUserId} attempted to add a resource action without being in required role.", contextUserId); + return false; + } + + await _store.Add(nameof(ResourceAction), action); + return true; + } - if (userId != null) + public async Task> GetAssignedRoles(string contextUserId, string id) + { + if (id != contextUserId && !await IsPrincipleInRole(contextUserId, contextUserId, StandardRoles.Administrator.Id)) { - var filteredprofiles = allResourceProfiles.Where(r => r.AssignedRoles.Any(ra => ra.PrincipleId == userId)); + await AuditWarning("User {contextUserId} attempted to read assigned role for [{id}] without being in required role.", contextUserId, id); + return null; + } + + var assignedRoles = await _store.GetItems(nameof(AssignedRole)); + + return assignedRoles.Where(r => r.SecurityPrincipleId == id).ToList(); + } + + public async Task GetSecurityPrincipleRoleStatus(string contextUserId, string id) + { + if (id != contextUserId && !await IsPrincipleInRole(contextUserId, contextUserId, StandardRoles.Administrator.Id)) + { + await AuditWarning("User {contextUserId} attempted to read role status role for [{id}] without being in required role.", contextUserId, id); + return null; + } + + var allAssignedRoles = await _store.GetItems(nameof(AssignedRole)); + var allRoles = await _store.GetItems(nameof(Role)); + var allPolicies = await _store.GetItems(nameof(ResourcePolicy)); + var allActions = await _store.GetItems(nameof(ResourceAction)); + + var spAssignedRoles = allAssignedRoles.Where(a => a.SecurityPrincipleId == id); + var spRoles = allRoles.Where(r => spAssignedRoles.Any(t => t.RoleId == r.Id)); + var spPolicies = allPolicies.Where(r => spRoles.Any(p => p.Policies.Contains(r.Id))); + var spActions = allActions.Where(r => spPolicies.Any(p => p.ResourceActions.Contains(r.Id))); + + var roleStatus = new RoleStatus + { + AssignedRoles = spAssignedRoles, + Roles = spRoles, + Policies = spPolicies, + Action = spActions + }; + + return roleStatus; + } + + public async Task UpdateAssignedRoles(string contextUserId, SecurityPrincipleAssignedRoleUpdate update) + { + if (!await IsPrincipleInRole(contextUserId, contextUserId, StandardRoles.Administrator.Id)) + { + await AuditWarning("User {contextUserId} attempted to update assigned role for [{id}] without being in required role.", contextUserId, update.SecurityPrincipleId); + return false; + } - foreach (var f in filteredprofiles) + // remove items from assigned roles + var existing = await GetAssignedRoles(contextUserId, update.SecurityPrincipleId); + foreach (var deleted in update.RemovedAssignedRoles) + { + var e = existing.FirstOrDefault(r => r.RoleId == deleted.RoleId); + if (e != null) + { + await _store.Delete(nameof(AssignedRole), e.Id); + } + } + + // add items to assigned roles + existing = await GetAssignedRoles(contextUserId, update.SecurityPrincipleId); + foreach (var added in update.AddedAssignedRoles) + { + if (!existing.Exists(r => r.RoleId == added.RoleId)) { - f.AssignedRoles = f.AssignedRoles.Where(a => a.PrincipleId == userId).ToList(); + await _store.Add(nameof(AssignedRole), added); } + } + + return true; + } + + public async Task CheckSecurityPrinciplePassword(string contextUserId, SecurityPrinciplePasswordCheck passwordCheck) + { + var principle = string.IsNullOrWhiteSpace(passwordCheck.SecurityPrincipleId) ? + await GetSecurityPrincipleByUsername(contextUserId, passwordCheck.Username) : + await GetSecurityPrinciple(contextUserId, passwordCheck.SecurityPrincipleId); - return filteredprofiles.ToList(); + if (principle != null && IsPasswordValid(passwordCheck.Password, principle.Password)) + { + return new SecurityPrincipleCheckResponse { IsSuccess = true, SecurityPrinciple = principle }; } else { - return allResourceProfiles; + if (principle == null) + { + return new SecurityPrincipleCheckResponse { IsSuccess = false, Message = "Invalid security principle" }; + } + else + { + return new SecurityPrincipleCheckResponse { IsSuccess = false, Message = "Invalid password" }; + } + } + } + + public async Task> GetAssignedAccessTokens(string contextUserId) + { + if (!await IsPrincipleInRole(contextUserId, contextUserId, StandardRoles.Administrator.Id)) + { + await AuditWarning("User {contextUserId} attempted to list assigned access tokens without being in required role.", contextUserId); + return null; } + + return await _store.GetItems(nameof(AssignedAccessToken)); } - public async Task AddResourceProfile(ResourceProfile resourceProfile, string contextUserId, bool bypassIntegrityCheck = false) + public async Task AddAssignedAccessToken(string contextUserId, AssignedAccessToken a) { - if (!await IsPrincipleInRole(contextUserId, StandardRoles.Administrator.Id, contextUserId) && !bypassIntegrityCheck) + if (!await IsPrincipleInRole(contextUserId, contextUserId, StandardRoles.Administrator.Id)) { - _log?.Warning($"User {contextUserId} attempted to use AddResourceProfile [{resourceProfile.Identifier}] without being in required role."); + await AuditWarning("User {contextUserId} attempted to add an assigned access token without being in required role.", contextUserId); return false; } - var profiles = await GetResourceProfiles(null, contextUserId); - profiles.Add(resourceProfile); - await _store.Save>("resourceprofiles", profiles); + await _store.Add(nameof(AssignedAccessToken), a); - _log?.Information($"User {contextUserId} added resource profile [{resourceProfile.Identifier}]"); return true; } + + public string GetSHA256Hash(string val) + { + using (var sha256Hash = SHA256.Create()) + { + var data = sha256Hash.ComputeHash(Encoding.UTF8.GetBytes(val)); + var sBuilder = new StringBuilder(); + + // Loop through each byte of the hashed data + // and format each one as a hexadecimal string. + for (var i = 0; i < data.Length; i++) + { + sBuilder.Append(data[i].ToString("x2")); + } + + // Return the hexadecimal string. + return sBuilder.ToString(); + } + } } } diff --git a/src/Certify.Core/Management/Access/IAccessControl.cs b/src/Certify.Core/Management/Access/IAccessControl.cs new file mode 100644 index 000000000..ce6669e26 --- /dev/null +++ b/src/Certify.Core/Management/Access/IAccessControl.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Certify.Models.Hub; + +namespace Certify.Core.Management.Access +{ + public interface IAccessControl + { + Task AddResourcePolicy(string contextUserId, ResourcePolicy resourceProfile, bool bypassIntegrityCheck = false); + Task AddSecurityPrinciple(string contextUserId, SecurityPrinciple principle, bool bypassIntegrityCheck = false); + Task DeleteSecurityPrinciple(string contextUserId, string id, bool allowSelfDelete = false); + Task> GetSecurityPrinciples(string contextUserId); + Task GetSecurityPrinciple(string contextUserId, string id); + + /// + /// Get the list of standard roles built-in to the system + /// + /// + Task> GetRoles(string contextUserId); + Task IsSecurityPrincipleAuthorised(string contextUserId, AccessCheck check); + Task IsAccessTokenAuthorised(string contextUserId, AccessToken accessToken, AccessCheck check); + Task IsPrincipleInRole(string contextUserId, string id, string roleId); + Task> GetAssignedRoles(string contextUserId, string id); + Task GetSecurityPrincipleRoleStatus(string contextUserId, string id); + Task UpdateSecurityPrinciple(string contextUserId, SecurityPrinciple principle); + Task UpdateAssignedRoles(string contextUserId, SecurityPrincipleAssignedRoleUpdate update); + Task UpdateSecurityPrinciplePassword(string contextUserId, SecurityPrinciplePasswordUpdate passwordUpdate); + Task CheckSecurityPrinciplePassword(string contextUserId, SecurityPrinciplePasswordCheck passwordCheck); + + Task AddRole(string contextUserId, Role role, bool bypassIntegrityCheck = false); + Task AddAssignedRole(string contextUserId, AssignedRole assignedRole, bool bypassIntegrityCheck = false); + Task AddResourceAction(string contextUserId, ResourceAction action, bool bypassIntegrityCheck = false); + + Task> GetAssignedAccessTokens(string contextUserId); + Task AddAssignedAccessToken(string contextUserId, AssignedAccessToken token); + Task IsInitialized(); + } +} diff --git a/src/Certify.Core/Management/BindingDeploymentManager.cs b/src/Certify.Core/Management/BindingDeploymentManager.cs index 96593849c..116718b6d 100644 --- a/src/Certify.Core/Management/BindingDeploymentManager.cs +++ b/src/Certify.Core/Management/BindingDeploymentManager.cs @@ -1,13 +1,12 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Net; +using System.Runtime.InteropServices; using System.Security.Cryptography.X509Certificates; using System.Threading.Tasks; using Certify.Management; using Certify.Models; using Certify.Models.Providers; -using Microsoft.Web.Administration; namespace Certify.Core.Management { @@ -56,6 +55,12 @@ public async Task> StoreAndDeploy(IBindingDeploymentTarget depl { var actions = new List(); + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + actions.Add(new ActionStep { Title = "Certificate Store and Deploy Skipped", Category = "CertificateStorage", Description = "Platform not supported for certificate store, skipping" }); + return actions; + } + var requestConfig = managedCertificate.RequestConfig; if (!isPreviewOnly) @@ -348,7 +353,7 @@ private async Task> DeployToAllTargetBindings(IBindingDeploymen { sslPort = int.Parse(requestConfig.BindingPort); - if (sslPort!=443) + if (sslPort != 443) { var step = new ActionStep(category, "Binding Port", $"A non-standard http port has been requested ({sslPort}) ."); bindingExplanationSteps.Add(step); @@ -408,7 +413,7 @@ private async Task> DeployToAllTargetBindings(IBindingDeploymen ); stepActions.First().Substeps = bindingExplanationSteps; - + actions.AddRange(stepActions); } else diff --git a/src/Certify.Core/Management/CertifyManager/CertifyManager.Account.cs b/src/Certify.Core/Management/CertifyManager/CertifyManager.Account.cs index c93efeb16..8ea57b480 100644 --- a/src/Certify.Core/Management/CertifyManager/CertifyManager.Account.cs +++ b/src/Certify.Core/Management/CertifyManager/CertifyManager.Account.cs @@ -7,13 +7,14 @@ using Certify.Models.Config; using Certify.Models.Providers; using Certify.Providers.ACME.Anvil; +using Microsoft.Extensions.Logging; using Newtonsoft.Json; namespace Certify.Management { public partial class CertifyManager { - private static object _accountsLock = new object(); + private static Lock _accountsLock = LockFactory.Create(); private List _accounts; /// @@ -86,7 +87,7 @@ private async Task GetACMEProvider(string storageKey, strin if (!_useWindowsNativeFeatures) { - newProvider.DefaultCertificateFormat = "pem"; + newProvider.DefaultCertificateFormat = "all"; } await newProvider.InitProvider(_serviceLog, account); @@ -184,7 +185,7 @@ public async Task GetAccountDetails(ManagedCertificate item, boo if (defaultMatchingAccount == null) { - var log = ManagedCertificateLog.GetLogger(item.Id, new Serilog.Core.LoggingLevelSwitch(Serilog.Events.LogEventLevel.Error)); + var log = ManagedCertificateLog.GetLogger(item.Id, LogLevel.Error); log?.Error($"Failed to match ACME account for managed certificate. Cannot continue request. :: {item.Name} CA: {currentCA} {(item.UseStagingMode ? "[Staging Mode]" : "[Production]")}"); return null; } @@ -416,7 +417,7 @@ public async Task RemoveAccount(string storageKey, bool includeAcc { _serviceLog?.Information($"Deleting account {storageKey}: " + account.AccountURI); - var resultOk = await _credentialsManager.Delete(_itemManager, storageKey); + var result = await _credentialsManager.Delete(_itemManager, storageKey); // invalidate accounts cache lock (_accountsLock) @@ -425,7 +426,7 @@ public async Task RemoveAccount(string storageKey, bool includeAcc } // attempt acme account deactivation - if (resultOk && includeAccountDeactivation && acmeProvider != null) + if (result.IsSuccess && includeAccountDeactivation && acmeProvider != null) { try { @@ -442,7 +443,7 @@ public async Task RemoveAccount(string storageKey, bool includeAcc } } - return new ActionResult("RemoveAccount", resultOk); + return new ActionResult("RemoveAccount", result.IsSuccess); } else { diff --git a/src/Certify.Core/Management/CertifyManager/CertifyManager.CertificateRequest.cs b/src/Certify.Core/Management/CertifyManager/CertifyManager.CertificateRequest.cs index 31041c9a6..cb3eb504a 100644 --- a/src/Certify.Core/Management/CertifyManager/CertifyManager.CertificateRequest.cs +++ b/src/Certify.Core/Management/CertifyManager/CertifyManager.CertificateRequest.cs @@ -85,9 +85,12 @@ public async Task> PerformRenewAll(RenewalSetting _itemManager, settings, prefs, - BeginTrackingProgress, + ReportProgress, IsManagedCertificateRunning, - (ManagedCertificate item, IProgress progress, bool isPreview, string reason) => { return PerformCertificateRequest(null, item, progress, skipRequest: isPreview, skipTasks: isPreview, reason: reason); }, + (ManagedCertificate item, IProgress progress, bool isPreview, string reason) => + { + return PerformCertificateRequest(null, item, progress, skipRequest: isPreview, skipTasks: isPreview, reason: reason); + }, progressTrackers); _isRenewAllInProgress = false; @@ -1069,9 +1072,6 @@ private async Task CompleteCertificateRequest(ILog log log?.Debug($"End of CompleteCertificateRequest."); - // cleanup progress tracking - _progressResults.TryRemove(managedCertificate.Id, out _); - return result; } diff --git a/src/Certify.Core/Management/CertifyManager/CertifyManager.DataStores.cs b/src/Certify.Core/Management/CertifyManager/CertifyManager.DataStores.cs index fd0f30faa..dbc88ae22 100644 --- a/src/Certify.Core/Management/CertifyManager/CertifyManager.DataStores.cs +++ b/src/Certify.Core/Management/CertifyManager/CertifyManager.DataStores.cs @@ -11,7 +11,7 @@ namespace Certify.Management { public partial class CertifyManager { - private object _dataStoreLocker = new object(); + private Lock _dataStoreLocker = LockFactory.Create(); private async Task GetManagedItemStoreProvider(DataStoreConnection dataStore) { @@ -80,11 +80,11 @@ private async Task GetCredentialManagerProvider(DataStoreCo { if (provider.ProviderCategoryId == "sqlite" && string.IsNullOrEmpty(dataStore.ConnectionConfig)) { - pr.Init("credentials", _useWindowsNativeFeatures, _serviceLog); + pr.Init(string.Empty, _serviceLog); } else { - pr.Init(dataStore.ConnectionConfig, _useWindowsNativeFeatures, _serviceLog); + pr.Init(dataStore.ConnectionConfig, _serviceLog); } if (!await pr.IsInitialised()) diff --git a/src/Certify.Core/Management/CertifyManager/CertifyManager.DeploymentTasks.cs b/src/Certify.Core/Management/CertifyManager/CertifyManager.DeploymentTasks.cs index 3508cc611..b276df72c 100644 --- a/src/Certify.Core/Management/CertifyManager/CertifyManager.DeploymentTasks.cs +++ b/src/Certify.Core/Management/CertifyManager/CertifyManager.DeploymentTasks.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Threading; @@ -252,7 +252,7 @@ private async Task> PerformTaskList(ILog log, bool isPreviewOnl task.TaskConfig.DateLastExecuted = DateTimeOffset.UtcNow; wasTaskExecuted = true; - taskResults = await task.Execute(log, _credentialsManager, result, cancellationToken: CancellationToken.None, new DeploymentContext { PowershellExecutionPolicy = _serverConfig.PowershellExecutionPolicy }, isPreviewOnly: isPreviewOnly); + taskResults = await task.Execute(log, _credentialsManager, result, new DeploymentContext { PowershellExecutionPolicy = _serverConfig.PowershellExecutionPolicy }, isPreviewOnly: isPreviewOnly, cancellationToken: CancellationToken.None); if (!isPreviewOnly) { @@ -390,7 +390,7 @@ public async Task> ValidateDeploymentTask(ManagedCertificate try { - var execParams = new DeploymentTaskExecutionParams(null, _credentialsManager, managedCertificate, taskConfig, credentials, true, provider?.GetDefinition(), CancellationToken.None, new DeploymentContext { PowershellExecutionPolicy = _serverConfig.PowershellExecutionPolicy }); + var execParams = new DeploymentTaskExecutionParams(null, _credentialsManager, managedCertificate, taskConfig, credentials, true, provider?.GetDefinition(), new DeploymentContext { PowershellExecutionPolicy = _serverConfig.PowershellExecutionPolicy }, CancellationToken.None); var validationResult = await provider.Validate(execParams); return validationResult; } diff --git a/src/Certify.Core/Management/CertifyManager/CertifyManager.ImportExport.cs b/src/Certify.Core/Management/CertifyManager/CertifyManager.ImportExport.cs index 1ec24b5fa..8a38455d5 100644 --- a/src/Certify.Core/Management/CertifyManager/CertifyManager.ImportExport.cs +++ b/src/Certify.Core/Management/CertifyManager/CertifyManager.ImportExport.cs @@ -1,9 +1,9 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; -using Certify.Config.Migration; using Certify.Core.Management; using Certify.Models; +using Certify.Models.Config.Migration; namespace Certify.Management { @@ -25,20 +25,24 @@ public async Task> PerformImport(ImportRequest importRequest) var hasError = false; if (!importResult.Any(i => i.HasError)) { - var deploySteps = new List(); - foreach (var m in importRequest.Package.Content.ManagedCertificates) + if (importRequest.Settings.IncludeDeployment) { - var managedCert = await GetManagedCertificate(m.Id); - if (managedCert != null && !string.IsNullOrEmpty(managedCert.CertificatePath)) + var deploySteps = new List(); + foreach (var m in importRequest.Package.Content.ManagedCertificates) { - var deployResult = await DeployCertificate(managedCert, null, isPreviewOnly: importRequest.IsPreviewMode); + var managedCert = await GetManagedCertificate(m.Id); - deploySteps.Add(new ActionStep { Category = "Deployment", HasError = !deployResult.IsSuccess, Key = managedCert.Id, Description = deployResult.Message }); + if (managedCert != null && !string.IsNullOrEmpty(managedCert.CertificatePath)) + { + var deployResult = await DeployCertificate(managedCert, null, isPreviewOnly: importRequest.IsPreviewMode); + + deploySteps.Add(new ActionStep { Category = "Deployment", HasError = !deployResult.IsSuccess, Key = managedCert.Id, Description = deployResult.Message }); + } } - } - importResult.Add(new ActionStep { Title = "Deployment" + (importRequest.IsPreviewMode ? " [Preview]" : ""), Substeps = deploySteps }); + importResult.Add(new ActionStep { Title = "Deployment" + (importRequest.IsPreviewMode ? " [Preview]" : ""), Substeps = deploySteps }); + } } else { diff --git a/src/Certify.Core/Management/CertifyManager/CertifyManager.Maintenance.cs b/src/Certify.Core/Management/CertifyManager/CertifyManager.Maintenance.cs index d9b618382..0f33bc531 100644 --- a/src/Certify.Core/Management/CertifyManager/CertifyManager.Maintenance.cs +++ b/src/Certify.Core/Management/CertifyManager/CertifyManager.Maintenance.cs @@ -5,14 +5,17 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using Certify.Core.Management.Access; using Certify.Models; using Certify.Models.Config; +using Certify.Models.Hub; using Certify.Models.Shared; namespace Certify.Management { - public partial class CertifyManager : ICertifyManager, IDisposable + public partial class CertifyManager { + /// /// Upgrade/migrate settings from previous version if applicable /// @@ -33,7 +36,99 @@ private async Task UpgradeSettings() await PerformServiceUpgrades(); CoreAppSettings.Current.CurrentServiceVersion = systemVersion; + + if (Environment.GetEnvironmentVariable("CERTIFY_ENABLE_MANAGEMENT_HUB")?.Equals("true", StringComparison.InvariantCultureIgnoreCase) == true) + { + CoreAppSettings.Current.IsManagementHubService = true; + } + SettingsManager.SaveAppSettings(); + + var accessControl = await GetCurrentAccessControl(); + + if (CoreAppSettings.Current.IsManagementHubService) + { + if (await accessControl.IsInitialized() == false) + { + await BootstrapAdminUserAndRoles(accessControl); + } + else + { + await UpdateStandardRoles(accessControl); + } + } + } + } + + private static async Task BootstrapAdminUserAndRoles(IAccessControl access) + { + // setup roles with policies + await UpdateStandardRoles(access); + + var adminSp = new SecurityPrinciple + { + Id = "admin_01", + Description = "Primary default admin", + PrincipleType = SecurityPrincipleType.User, + Username = "admin", + Password = "admin", + Provider = StandardIdentityProviders.INTERNAL + }; + + await access.AddSecurityPrinciple(adminSp.Id, adminSp, bypassIntegrityCheck: true); + + // assign security principles to roles + var assignedRoles = new List { + // administrator + new AssignedRole{ + Id= Guid.NewGuid().ToString(), + RoleId=StandardRoles.Administrator.Id, + SecurityPrincipleId=adminSp.Id + } + }; + + foreach (var r in assignedRoles) + { + // add roles and policy assignments to store + await access.AddAssignedRole(adminSp.Id, r, bypassIntegrityCheck: true); + } + } + + /// + /// Add/update standard system roles, policies and resource actions + /// + /// + /// + private static async Task UpdateStandardRoles(IAccessControl access) + { + // setup roles with policies + + var adminSvcPrinciple = "admin_01"; + + var actions = Policies.GetStandardResourceActions(); + + foreach (var action in actions) + { + await access.AddResourceAction(adminSvcPrinciple, action, bypassIntegrityCheck: true); + } + + // setup policies with actions + + var policies = Policies.GetStandardPolicies(); + + // add policies to store + foreach (var r in policies) + { + _ = await access.AddResourcePolicy(null, r, bypassIntegrityCheck: true); + } + + // setup roles with policies + var roles = Policies.GetStandardRoles(); + + foreach (var r in roles) + { + // add roles and policy assignments to store + await access.AddRole(adminSvcPrinciple, r, bypassIntegrityCheck: true); } } diff --git a/src/Certify.Core/Management/CertifyManager/CertifyManager.ManagedCertificates.cs b/src/Certify.Core/Management/CertifyManager/CertifyManager.ManagedCertificates.cs index 8bc72d6cc..9b17487fe 100644 --- a/src/Certify.Core/Management/CertifyManager/CertifyManager.ManagedCertificates.cs +++ b/src/Certify.Core/Management/CertifyManager/CertifyManager.ManagedCertificates.cs @@ -2,22 +2,44 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; +using System.IO; using System.Linq; using System.Threading.Tasks; using Certify.Models; +using Certify.Models.Config; +using Certify.Models.Hub; using Certify.Models.Providers; +using Certify.Models.Reporting; using Certify.Models.Shared; +using Certify.Shared.Core.Utils.PKI; namespace Certify.Management { public partial class CertifyManager { + public string InstanceId + { + get + { + return CoreAppSettings.Current.InstanceId; + } + } + /// /// Get managed certificate details by ID /// /// /// - public async Task GetManagedCertificate(string id) => await _itemManager.GetById(id); + public async Task GetManagedCertificate(string id) + { + var item = await _itemManager.GetById(id); + if (item != null) + { + item.InstanceId = InstanceId; + } + + return item; + } /// /// Get list of managed certificates based on then given filter criteria @@ -30,27 +52,43 @@ public async Task> GetManagedCertificates(ManagedCertif if (filter?.IncludeExternal == true) { - if (_pluginManager?.CertificateManagerProviders?.Any() == true) + var external = GetExternallyManagedCertificates(filter); + if (external != null) { - // TODO: cache providers/results - // check if we have any external sources of managed certificates - foreach (var p in _pluginManager.CertificateManagerProviders) + list.AddRange(await external); + } + } + + list.ForEach(i => i.InstanceId = InstanceId); + + return list; + } + + private async Task> GetExternallyManagedCertificates(ManagedCertificateFilter filter) + { + var externalList = new List(); + if (_pluginManager?.CertificateManagerProviders?.Any() == true) + { + // TODO: cache providers/results + + // check if we have any external sources of managed certificates + foreach (var p in _pluginManager.CertificateManagerProviders) + { + if (p != null) { - if (p != null) + var pluginType = p.GetType(); + var providers = p.GetProviders(pluginType); + + foreach (var cp in providers) { - var pluginType = p.GetType(); - var providers = p.GetProviders(pluginType); - foreach (var cp in providers) + if (cp?.IsEnabled == true) { try { - if (cp.IsEnabled) - { - var certManager = p.GetProvider(pluginType, cp.Id); - var certs = await certManager.GetManagedCertificates(filter); + var certManager = p.GetProvider(pluginType, cp.Id); + var certs = await certManager.GetManagedCertificates(filter); - list.AddRange(certs); - } + externalList.AddRange(certs); } catch (Exception ex) { @@ -58,15 +96,74 @@ public async Task> GetManagedCertificates(ManagedCertif } } } - else - { - _serviceLog?.Error($"Failed to create one or more certificate manager plugins"); - } + } + else + { + _serviceLog?.Error($"Failed to create one or more certificate manager plugins"); } } } - return list; + return externalList; + } + + /// + /// Get list of managed certificates based on then given filter criteria, as search result with total count + /// + /// + /// + public async Task GetManagedCertificateResults(ManagedCertificateFilter filter) + { + var result = new ManagedCertificateSearchResult(); + + var list = await _itemManager.Find(filter); + + list.ForEach(i => i.InstanceId = InstanceId); + + result.Results = list; + + if (filter?.IncludeExternal == true) + { + // TODO: overall set still has to be paged and sorted + var external = GetExternallyManagedCertificates(filter); + + if (external != null) + { + list.AddRange(await external); + list.ForEach(i => i.InstanceId = InstanceId); + result.Results = list; + } + } + + if (filter.PageSize > 0) + { + filter.PageSize = null; + filter.PageIndex = null; + result.TotalResults = await _itemManager.CountAll(filter); + } + + return result; + } + + public async Task GetManagedCertificateSummary(ManagedCertificateFilter filter) + { + var ms = await _itemManager.Find(filter); + + var summary = new StatusSummary(); + summary.InstanceId = InstanceId; + summary.Total = ms.Count; + summary.Healthy = ms.Count(c => c.Health == ManagedCertificateHealth.OK); + summary.Error = ms.Count(c => c.Health == ManagedCertificateHealth.Error); + summary.Warning = ms.Count(c => c.Health == ManagedCertificateHealth.Warning); + summary.AwaitingUser = ms.Count(c => c.Health == ManagedCertificateHealth.AwaitingUser); + summary.NoCertificate = ms.Count(c => c.CertificatePath == null); + + // count items with invalid config (e.g. multiple primary domains) + summary.InvalidConfig = ms.Count(c => c.DomainOptions.Count(d => d.IsPrimaryDomain) > 1); + + summary.TotalDomains = ms.Sum(s => s.RequestConfig.SubjectAlternativeNames.Count()); + + return summary; } /// @@ -82,6 +179,8 @@ public async Task UpdateManagedCertificate(ManagedCertificat // store managed cert in database store managedCert = await _itemManager.Update(managedCert); + managedCert.InstanceId = InstanceId; + // report request state to status hub clients _statusReporting?.ReportManagedCertificateUpdated(managedCert); @@ -147,7 +246,7 @@ private async Task UpdateManagedCertificateStatus(ManagedCertificate managedCert await ReportManagedCertificateStatus(managedCertificate); } - _tc?.TrackEvent("UpdateManagedCertificatesStatus_" + status.ToString()); + _tc?.TrackEvent("UpdateManagedCertificatesStatus_" + status); } private ConcurrentDictionary _statusReportQueue { get; set; } = new ConcurrentDictionary(); @@ -174,7 +273,7 @@ private async Task ReportManagedCertificateStatus(ManagedCertificate managedCert var report = new Models.Shared.RenewalStatusReport { - InstanceId = CoreAppSettings.Current.InstanceId, + InstanceId = this.InstanceId, MachineName = Environment.MachineName, PrimaryContactEmail = (await GetAccountDetails(managedCertificate, allowFailover: false))?.Email, ManagedSite = reportedCert, @@ -241,7 +340,7 @@ private async Task SendQueuedStatusReports() /// /// /// - public async Task DeleteManagedCertificate(string id) + public async Task DeleteManagedCertificate(string id) { if (!string.IsNullOrEmpty(id)) { @@ -249,8 +348,11 @@ public async Task DeleteManagedCertificate(string id) if (item != null) { await _itemManager.Delete(item); + return new ActionResult { IsSuccess = true, Message = "Deleted" }; } } + + return new ActionResult { IsSuccess = false, Message = "Delete failed." }; } /// @@ -431,11 +533,92 @@ public async Task> GeneratePreview(ManagedCertificate item) return await new PreviewManager().GeneratePreview(item, serverProvider, this, _credentialsManager); } - public async Task> GetDnsProviderZones(string providerTypeId, string credentialsId) + /// + /// Prepare an export of a managed certificate in the given format (if certificate present) + /// + /// + /// + public async Task> ExportCertificate(string managedCertId, string format) + { + var item = await GetManagedCertificate(managedCertId); + + if (string.IsNullOrEmpty(item?.CertificatePath) || !File.Exists(item?.CertificatePath)) + { + return new ActionResult("Source certificate file is not present. Export cannot continue.", false); + } + + if (string.IsNullOrWhiteSpace(format)) + { + format = "pfx"; + } + + try + { + var pfxData = File.ReadAllBytes(item.CertificatePath); + + var certPwd = ""; + + // if credential used for private key, check if we can decrypt that (unless we exporting PFX which is just a file copy) + if (!string.IsNullOrWhiteSpace(item.CertificatePasswordCredentialId) && format != "pfx") + { + var cred = await GetCredentialsManager().GetUnlockedCredentialsDictionary(item.CertificatePasswordCredentialId); + if (cred != null) + { + certPwd = cred["password"]; + } + else + { + return new ActionResult($"Export - the credentials for this export could not be unlocked or were not accessible {item.CertificatePasswordCredentialId}.", false); + } + } + + byte[] result = []; + + if (format == "pfx") + { + result = pfxData; + } + else if (format == "pem_key") + { + result = CertUtils.GetCertComponentsAsPEMBytes(pfxData, certPwd, ExportFlags.PrivateKey); + } + else if (format == "pem_fullchain") + { + result = CertUtils.GetCertComponentsAsPEMBytes(pfxData, certPwd, ExportFlags.EndEntityCertificate | ExportFlags.IntermediateCertificates); + } + else if (format == "pem_fullchain_key") + { + result = CertUtils.GetCertComponentsAsPEMBytes(pfxData, certPwd, ExportFlags.PrivateKey | ExportFlags.EndEntityCertificate | ExportFlags.IntermediateCertificates); + } + else if (format == "pem_fullchain_root") + { + result = CertUtils.GetCertComponentsAsPEMBytes(pfxData, certPwd, ExportFlags.EndEntityCertificate | ExportFlags.IntermediateCertificates | ExportFlags.RootCertificate); + } + else if (format == "pem_fullchain_root_key") + { + result = CertUtils.GetCertComponentsAsPEMBytes(pfxData, certPwd, ExportFlags.PrivateKey | ExportFlags.EndEntityCertificate | ExportFlags.IntermediateCertificates | ExportFlags.RootCertificate); + } + + if (result.Length == 0) + { + return new ActionResult($"Export - no files where selected for export or export could not be applied for source certificate.", false); + } + else + { + return new ActionResult { Result = result, IsSuccess = true }; + } + } + catch (Exception exp) + { + return new ActionResult($"Export - {exp}", false); + } + } + + public async Task> GetDnsProviderZones(string providerTypeId, string credentialId) { var dnsHelper = new Core.Management.Challenges.DnsChallengeHelper(_credentialsManager); - var result = await dnsHelper.GetDnsProvider(providerTypeId, credentialsId, null, _credentialsManager, _serviceLog); + var result = await dnsHelper.GetDnsProvider(providerTypeId, credentialId, null, _credentialsManager, _serviceLog); if (result.Provider != null) { @@ -449,7 +632,7 @@ public async Task> GetDnsProviderZones(string providerTypeId, stri } } - public async Task GetItemLog(string id, int limit) + public async Task GetItemLog(string id, int limit) { var logPath = ManagedCertificateLog.GetLogPath(id); @@ -457,24 +640,38 @@ public async Task GetItemLog(string id, int limit) { try { + LogItem[] results = []; // TODO: use reverse stream reader for large files + var stream = System.IO.File.Open(logPath, System.IO.FileMode.Open, System.IO.FileAccess.Read, System.IO.FileShare.ReadWrite); + using (var streamReader = new System.IO.StreamReader(stream)) + { + var str = await streamReader.ReadToEndAsync(); + stream.Close(); - var log = System.IO.File.ReadAllLines(logPath) - .Reverse() - .Take(limit) - .Reverse() - .ToArray(); + var log = str.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries) + .Reverse() + .Take(limit) + .ToArray(); - return log; + results = LogParser.Parse(log); + } + + return results; } catch (Exception exp) { - return new string[] { $"Failed to read log: {exp}" }; + return new LogItem[] + { + new LogItem + { + LogLevel = "ERR", EventDate = DateTime.Now, Message = $"Failed to read log: {exp}" + } + }; } } else { - return await Task.FromResult(new string[] { "" }); + return await Task.FromResult(Array.Empty()); } } @@ -641,36 +838,32 @@ private async Task StartHttpChallengeServer() /// Stop our temporary http challenge response service /// /// - private async Task StopHttpChallengeServer() + private async Task StopHttpChallengeServer() { - if (_httpChallengeServerClient != null) + if (_httpChallengeServerClient == null) { - try + return; + } + + try + { + var response = await _httpChallengeServerClient.GetAsync($"http://127.0.0.1:{_httpChallengePort}/.well-known/acme-challenge/{_httpChallengeControlKey}"); + if (response.IsSuccessStatusCode) { - var response = await _httpChallengeServerClient.GetAsync($"http://127.0.0.1:{_httpChallengePort}/.well-known/acme-challenge/{_httpChallengeControlKey}"); - if (response.IsSuccessStatusCode) - { - return true; - } - else - { - try - { - if (_httpChallengeProcess != null && !_httpChallengeProcess.HasExited) - { - _httpChallengeProcess.CloseMainWindow(); - } - } - catch { } - } + return; } - catch + else { - return true; + if (_httpChallengeProcess?.HasExited == false) + { + _httpChallengeProcess.CloseMainWindow(); + } } } - - return true; + catch + { + // ignored + } } } } diff --git a/src/Certify.Core/Management/CertifyManager/CertifyManager.ManagedChallenges.cs b/src/Certify.Core/Management/CertifyManager/CertifyManager.ManagedChallenges.cs new file mode 100644 index 000000000..e8be41a77 --- /dev/null +++ b/src/Certify.Core/Management/CertifyManager/CertifyManager.ManagedChallenges.cs @@ -0,0 +1,254 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading.Tasks; +using Certify.Core.Management.Challenges; +using Certify.Models; +using Certify.Models.Config; +using Certify.Models.Hub; + +namespace Certify.Management +{ + public partial class CertifyManager + { + public async Task> GetManagedChallenges() + { + return await _configStore.GetItems(nameof(ManagedChallenge)); + } + + public async Task UpdateManagedChallenge(ManagedChallenge update) + { + if (string.IsNullOrEmpty(update.Id)) + { + update.Id = Guid.NewGuid().ToString(); + } + + await _configStore.Update(nameof(ManagedChallenge), update); + return new ActionResult { IsSuccess = true }; + } + + public async Task DeleteManagedChallenge(string id) + { + var deleted = await _configStore.Delete(nameof(ManagedChallenge), id); + + return new ActionResult { IsSuccess = deleted }; + } + + private ManagedChallenge ManagedChallengeFindBestMatch(ManagedChallengeRequest request, ICollection managedChallenges) + { + // find most specific matching challenge for the request - based on ManagedCertificate.GetChallengeConfig + //TODO: filter based on access + var matchedConfig = managedChallenges.FirstOrDefault(c => string.IsNullOrEmpty(c.ChallengeConfig.DomainMatch)); + + if (request.Identifier != null && !string.IsNullOrEmpty(request.Identifier)) + { + // expand configs into per identifier list + var configsPerDomain = new Dictionary(); + foreach (var managedChallenge in managedChallenges.Where(c => !string.IsNullOrEmpty(c.ChallengeConfig.DomainMatch))) + { + var c = managedChallenge.ChallengeConfig; + if (c != null) + { + if (c.DomainMatch != null && !string.IsNullOrEmpty(c.DomainMatch)) + { + c.DomainMatch = c.DomainMatch.Replace(",", ";"); // if user has entered comma separators instead of semicolons, convert now. + + if (!c.DomainMatch.Contains(';')) + { + var domainMatchKey = c.DomainMatch.Trim(); + + // if identifier key is test.com for example we only support one matching config + if (!configsPerDomain.ContainsKey(domainMatchKey)) + { + configsPerDomain.Add(domainMatchKey, managedChallenge); + } + } + else + { + var domains = c.DomainMatch.Split(';'); + foreach (var d in domains) + { + if (!string.IsNullOrWhiteSpace(d)) + { + var domainMatchKey = d.Trim().ToLowerInvariant(); + if (!configsPerDomain.ContainsKey(domainMatchKey)) + { + configsPerDomain.Add(domainMatchKey, managedChallenge); + } + } + } + } + } + } + } + + // if exact match exists, use that + var identifierKey = request.Identifier.ToLowerInvariant() ?? ""; + if (configsPerDomain.TryGetValue(identifierKey, out var value)) + { + return value; + } + + // if explicit wildcard match exists, use that + if (configsPerDomain.TryGetValue("*." + identifierKey, out var wildValue)) + { + return wildValue; + } + + //if a more specific config matches the identifier, use that, in order of longest identifier name match first + var allMatchingConfigKeys = configsPerDomain.Keys.OrderByDescending(l => l.Length); + + foreach (var wildcard in allMatchingConfigKeys.Where(k => k.StartsWith("*.", StringComparison.CurrentCultureIgnoreCase))) + { + if (ManagedCertificate.IsDomainOrWildcardMatch(new List { wildcard }, request.Identifier)) + { + return configsPerDomain[wildcard]; + } + } + + foreach (var configDomain in allMatchingConfigKeys) + { + if (configDomain.EndsWith(request.Identifier.ToLowerInvariant(), StringComparison.CurrentCultureIgnoreCase)) + { + // use longest matching identifier (so subdomain.test.com takes priority + // over test.com, ) + return configsPerDomain[configDomain]; + } + } + } + + // no other matches, just use first + if (matchedConfig != null) + { + return matchedConfig; + } + else + { + // no match, return null + return default; + } + } + public async Task PerformManagedChallengeRequest(ManagedChallengeRequest request) + { + var log = _serviceLog; + + var managedChallenges = await GetManagedChallenges(); + + var matchingChallenge = ManagedChallengeFindBestMatch(request, managedChallenges); + + if (matchingChallenge == null) + { + return new ActionResult { IsSuccess = false, Message = "No matching challenge found" }; + } + else + { + // perform challenge + var _dnsHelper = new DnsChallengeHelper(_credentialsManager); + + DnsChallengeHelperResult dnsResult; + var managedCertificate = new ManagedCertificate + { + RequestConfig = new CertRequestConfig + { + Challenges = new ObservableCollection( + new List + { + matchingChallenge.ChallengeConfig + }) + } + }; + + var domain = new CertIdentifierItem { IdentifierType = CertIdentifierType.Dns, Value = request.Identifier }; + + dnsResult = await _dnsHelper.CompleteDNSChallenge(log, managedCertificate, domain, request.ResponseKey, request.ResponseValue, isTestMode: false); + + if (!dnsResult.Result.IsSuccess) + { + if (dnsResult.IsAwaitingUser) + { + log?.Error($"Action Required: {dnsResult.Result.Message}"); + } + else + { + log?.Error($"DNS update failed: {dnsResult.Result.Message}"); + } + + return dnsResult.Result; + } + else + { + log.Information($"DNS: {dnsResult.Result.Message}"); + } + + var cleanupQueue = new List { }; + + // configure cleanup actions for use after challenge completes + /* pendingAuth.Cleanup = async () => + { + _ = await _dnsHelper.DeleteDNSChallenge(log, managedCertificate, domain, dnsChallenge.Key, dnsChallenge.Value); + }; + */ + + return new ActionResult { IsSuccess = true, Message = $"Challenge response {request.ChallengeType} completed {request.ResponseKey} : {request.ResponseValue}" }; + + } + } + + public async Task CleanupManagedChallengeRequest(ManagedChallengeRequest request) + { + var log = _serviceLog; + + var managedChallenges = await GetManagedChallenges(); + + var matchingChallenge = ManagedChallengeFindBestMatch(request, managedChallenges); + + if (matchingChallenge == null) + { + return new ActionResult { IsSuccess = false, Message = "No matching challenge found" }; + } + else + { + // perform challenge + var _dnsHelper = new DnsChallengeHelper(_credentialsManager); + + var managedCertificate = new ManagedCertificate + { + RequestConfig = new CertRequestConfig + { + Challenges = new ObservableCollection( + new List + { + matchingChallenge.ChallengeConfig + }) + } + }; + + var domain = new CertIdentifierItem { IdentifierType = CertIdentifierType.Dns, Value = request.Identifier }; + + var dnsResult = await _dnsHelper.DeleteDNSChallenge(log, managedCertificate, domain, request.ResponseKey, request.ResponseValue); + + if (!dnsResult.Result.IsSuccess) + { + if (dnsResult.IsAwaitingUser) + { + log?.Error($"Action Required: {dnsResult.Result.Message}"); + } + else + { + log?.Error($"DNS cleanup failed: {dnsResult.Result.Message}"); + } + + return dnsResult.Result; + } + else + { + log.Information($"DNS: {dnsResult.Result.Message}"); + } + + return new ActionResult { IsSuccess = true, Message = $"Challenge cleanup {request.ChallengeType} completed {request.ResponseKey} : {request.ResponseValue}" }; + + } + } + } +} diff --git a/src/Certify.Core/Management/CertifyManager/CertifyManager.ManagementHub.cs b/src/Certify.Core/Management/CertifyManager/CertifyManager.ManagementHub.cs new file mode 100644 index 000000000..89b570beb --- /dev/null +++ b/src/Certify.Core/Management/CertifyManager/CertifyManager.ManagementHub.cs @@ -0,0 +1,376 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Certify.Client; +using Certify.Locales; +using Certify.Models; +using Certify.Models.Config; +using Certify.Models.Hub; +using Certify.Shared.Core.Utils; + +namespace Certify.Management +{ + public partial class CertifyManager + { + private IManagementServerClient _managementServerClient; + private string _managementServerConnectionId = string.Empty; + + public async Task UpdateManagementHub(string url, string joiningKey) + { + + _serverConfig = SharedUtils.ServiceConfigManager.GetAppServiceConfig(); + _serverConfig.ManagementServerHubUri = url; + SharedUtils.ServiceConfigManager.StoreUpdatedAppServiceConfig(_serverConfig); + + _managementServerClient = null; + + try + { + await EnsureMgmtHubConnection(); + } + catch + { + return new ActionStep("Update Management Hub Failed", "A problem occurred when connecting to the management hub. Check URL.", hasError: true); + } + + return new ActionStep("Updated Management Hub", "OK", false); + } + + public void SetDirectManagementClient(IManagementServerClient client) + { + _managementServerClient = client; + } + + private async Task EnsureMgmtHubConnection() + { + // connect/reconnect to management hub if enabled + if (_managementServerClient == null || !_managementServerClient.IsConnected()) + { + var mgmtHubUri = Environment.GetEnvironmentVariable("CERTIFY_MANAGEMENT_HUB") ?? _serverConfig.ManagementServerHubUri; + + if (!string.IsNullOrWhiteSpace(mgmtHubUri)) + { + await StartManagementHubConnection(mgmtHubUri); + } + } + else + { + + // send heartbeat message to management hub + SendHeartbeatToManagementHub(); + } + } + + private void SendHeartbeatToManagementHub() + { + _managementServerClient.SendInstanceInfo(Guid.NewGuid(), false); + } + + public ManagedInstanceInfo GetManagedInstanceInfo() + { + return new ManagedInstanceInfo + { + InstanceId = InstanceId, + Title = $"{Environment.MachineName}", + OS = EnvironmentUtil.GetFriendlyOSName(detailed: false), + OSVersion = EnvironmentUtil.GetFriendlyOSName(), + ClientVersion = Util.GetAppVersion().ToString(), + ClientName = ConfigResources.AppName + }; + } + private async Task StartManagementHubConnection(string hubUri) + { + + _serviceLog.Debug("Attempting connection to management hub {hubUri}", hubUri); + + var appVersion = Util.GetAppVersion().ToString(); + + var instanceInfo = GetManagedInstanceInfo(); + + if (_managementServerClient != null) + { + _managementServerClient.OnGetCommandResult -= PerformHubCommandWithResult; + _managementServerClient.OnConnectionReconnecting -= _managementServerClient_OnConnectionReconnecting; + } + + _managementServerClient = new ManagementServerClient(hubUri, instanceInfo); + + try + { + await _managementServerClient.ConnectAsync(); + + _managementServerClient.OnGetCommandResult += PerformHubCommandWithResult; + _managementServerClient.OnConnectionReconnecting += _managementServerClient_OnConnectionReconnecting; + } + catch (Exception ex) + { + _serviceLog.Error(ex, "Failed to create connection to management hub {hubUri}", hubUri); + + _managementServerClient = null; + } + } + + public async Task PerformHubCommandWithResult(InstanceCommandRequest arg) + { + object val = null; + + if (arg.CommandType == ManagementHubCommands.GetManagedItem) + { + // Get a single managed item by id + var args = JsonSerializer.Deserialize[]>(arg.Value); + var managedCertIdArg = args.FirstOrDefault(a => a.Key == "managedCertId"); + val = await GetManagedCertificate(managedCertIdArg.Value); + } + else if (arg.CommandType == ManagementHubCommands.GetManagedItems) + { + // Get all managed items + var items = await GetManagedCertificates(new ManagedCertificateFilter { }); + val = new ManagedInstanceItems { InstanceId = InstanceId, Items = items }; + } + else if (arg.CommandType == ManagementHubCommands.GetStatusSummary) + { + var s = await GetManagedCertificateSummary(new ManagedCertificateFilter { }); + s.InstanceId = InstanceId; + val = s; + } + else if (arg.CommandType == ManagementHubCommands.GetManagedItemLog) + { + var args = JsonSerializer.Deserialize[]>(arg.Value); + var managedCertIdArg = args.FirstOrDefault(a => a.Key == "managedCertId"); + var limit = args.FirstOrDefault(a => a.Key == "limit"); + + val = await GetItemLog(managedCertIdArg.Value, int.Parse(limit.Value)); + } + else if (arg.CommandType == ManagementHubCommands.GetManagedItemRenewalPreview) + { + var args = JsonSerializer.Deserialize[]>(arg.Value); + var managedCertArg = args.FirstOrDefault(a => a.Key == "managedCert"); + var managedCert = JsonSerializer.Deserialize(managedCertArg.Value); + + val = await GeneratePreview(managedCert); + } + else if (arg.CommandType == ManagementHubCommands.ExportCertificate) + { + var args = JsonSerializer.Deserialize[]>(arg.Value); + var managedCertIdArg = args.FirstOrDefault(a => a.Key == "managedCertId"); + var format = args.FirstOrDefault(a => a.Key == "format"); + val = await ExportCertificate(managedCertIdArg.Value, format.Value); + } + else if (arg.CommandType == ManagementHubCommands.UpdateManagedItem) + { + // update a single managed item + var args = JsonSerializer.Deserialize[]>(arg.Value); + var managedCertArg = args.FirstOrDefault(a => a.Key == "managedCert"); + var managedCert = JsonSerializer.Deserialize(managedCertArg.Value); + + var item = await UpdateManagedCertificate(managedCert); + + val = item; + + ReportManagedItemUpdateToMgmtHub(item); + } + else if (arg.CommandType == ManagementHubCommands.RemoveManagedItem) + { + // delete a single managed item + var args = JsonSerializer.Deserialize[]>(arg.Value); + var managedCertIdArg = args.FirstOrDefault(a => a.Key == "managedCertId"); + + var actionResult = await DeleteManagedCertificate(managedCertIdArg.Value); + + val = actionResult; + + if (actionResult.IsSuccess) + { + ReportManagedItemDeleteToMgmtHub(managedCertIdArg.Value); + } + } + else if (arg.CommandType == ManagementHubCommands.TestManagedItemConfiguration) + { + // test challenge response config for a single managed item + var args = JsonSerializer.Deserialize[]>(arg.Value); + var managedCertArg = args.FirstOrDefault(a => a.Key == "managedCert"); + var managedCert = JsonSerializer.Deserialize(managedCertArg.Value); + + var log = ManagedCertificateLog.GetLogger(managedCert.Id, _loggingLevelSwitch); + + val = await TestChallenge(log, managedCert, isPreviewMode: true); + + } + else if (arg.CommandType == ManagementHubCommands.PerformManagedItemRequest) + { + // attempt certificate order + var args = JsonSerializer.Deserialize[]>(arg.Value); + var managedCertIdArg = args.FirstOrDefault(a => a.Key == "managedCertId"); + var managedCert = await GetManagedCertificate(managedCertIdArg.Value); + + var progressState = new RequestProgressState(RequestState.Running, "Starting..", managedCert); + var progressIndicator = new Progress(progressState.ProgressReport); + + _ = await PerformCertificateRequest( + null, + managedCert, + progressIndicator, + resumePaused: true, + isInteractive: true + ); + + val = true; + } + else if (arg.CommandType == ManagementHubCommands.GetCertificateAuthorities) + { + val = await GetCertificateAuthorities(); + } + else if (arg.CommandType == ManagementHubCommands.UpdateCertificateAuthority) + { + var args = JsonSerializer.Deserialize[]>(arg.Value); + var itemArg = args.FirstOrDefault(a => a.Key == "certificateAuthority"); + var item = JsonSerializer.Deserialize(itemArg.Value); + + val = await UpdateCertificateAuthority(item); + } + else if (arg.CommandType == ManagementHubCommands.RemoveCertificateAuthority) + { + var args = JsonSerializer.Deserialize[]>(arg.Value); + var itemArg = args.FirstOrDefault(a => a.Key == "id"); + val = await RemoveCertificateAuthority(itemArg.Value); + } + else if (arg.CommandType == ManagementHubCommands.GetAcmeAccounts) + { + val = await GetAccountRegistrations(); + } + else if (arg.CommandType == ManagementHubCommands.AddAcmeAccount) + { + var args = JsonSerializer.Deserialize[]>(arg.Value); + var registrationArg = args.FirstOrDefault(a => a.Key == "registration"); + var registration = JsonSerializer.Deserialize(registrationArg.Value); + + val = await AddAccount(registration); + } + else if (arg.CommandType == ManagementHubCommands.RemoveAcmeAccount) + { + var args = JsonSerializer.Deserialize[]>(arg.Value); + var itemArg = args.FirstOrDefault(a => a.Key == "storageKey"); + var deactivateArg = args.FirstOrDefault(a => a.Key == "deactivate"); + val = await RemoveAccount(itemArg.Value, bool.Parse(deactivateArg.Value)); + } + else if (arg.CommandType == ManagementHubCommands.GetStoredCredentials) + { + val = await _credentialsManager.GetCredentials(); + } + else if (arg.CommandType == ManagementHubCommands.UpdateStoredCredential) + { + var args = JsonSerializer.Deserialize[]>(arg.Value); + var itemArg = args.FirstOrDefault(a => a.Key == "item"); + var storedCredential = JsonSerializer.Deserialize(itemArg.Value); + + val = await _credentialsManager.Update(storedCredential); + } + else if (arg.CommandType == ManagementHubCommands.RemoveStoredCredential) + { + var args = JsonSerializer.Deserialize[]>(arg.Value); + var itemArg = args.FirstOrDefault(a => a.Key == "storageKey"); + val = await _credentialsManager.Delete(_itemManager, itemArg.Value); + } + else if (arg.CommandType == ManagementHubCommands.GetChallengeProviders) + { + val = await Core.Management.Challenges.ChallengeProviders.GetChallengeAPIProviders(); + } + + else if (arg.CommandType == ManagementHubCommands.GetDnsZones) + { + var args = JsonSerializer.Deserialize[]>(arg.Value); + var providerTypeArg = args.FirstOrDefault(a => a.Key == "providerTypeId"); + var credentialIdArg = args.FirstOrDefault(a => a.Key == "credentialId"); + + val = await GetDnsProviderZones(providerTypeArg.Value, credentialIdArg.Value); + } + else if (arg.CommandType == ManagementHubCommands.GetDeploymentProviders) + { + val = await GetDeploymentProviders(); + } + else if (arg.CommandType == ManagementHubCommands.ExecuteDeploymentTask) + { + var args = JsonSerializer.Deserialize[]>(arg.Value); + + var managedCertificateIdArg = args.FirstOrDefault(a => a.Key == "managedCertificateId"); + var taskIdArg = args.FirstOrDefault(a => a.Key == "taskId"); + + val = await PerformDeploymentTask(null, managedCertificateIdArg.Value, taskIdArg.Value, isPreviewOnly: false, skipDeferredTasks: false, forceTaskExecution: false); + } + else if (arg.CommandType == ManagementHubCommands.GetTargetServiceTypes) + { + val = await GetTargetServiceTypes(); + } + else if (arg.CommandType == ManagementHubCommands.GetTargetServiceItems) + { + var args = JsonSerializer.Deserialize[]>(arg.Value); + var serviceTypeArg = args.FirstOrDefault(a => a.Key == "serviceType"); + + var serverType = MapStandardServerType(serviceTypeArg.Value); + + val = await GetPrimaryWebSites(serverType, ignoreStoppedSites: true); + } + else if (arg.CommandType == ManagementHubCommands.GetTargetServiceItemIdentifiers) + { + var args = JsonSerializer.Deserialize[]>(arg.Value); + var serviceTypeArg = args.FirstOrDefault(a => a.Key == "serviceType"); + var itemArg = args.FirstOrDefault(a => a.Key == "itemId"); + + var serverType = MapStandardServerType(serviceTypeArg.Value); + + val = await GetDomainOptionsFromSite(serverType, itemArg.Value); + } + else if (arg.CommandType == ManagementHubCommands.Reconnect) + { + await _managementServerClient.Disconnect(); + } + + return new InstanceCommandResult { CommandId = arg.CommandId, Value = JsonSerializer.Serialize(val), ObjectValue = val }; + } + + private StandardServerTypes MapStandardServerType(string type) + { + if (StandardServerTypes.TryParse(type, out StandardServerTypes standardServerType)) + { + return standardServerType; + } + else + { + return StandardServerTypes.Other; + } + } + + private void ReportManagedItemUpdateToMgmtHub(ManagedCertificate item) + { + if (item != null) + { + _managementServerClient?.SendNotificationToManagementHub(ManagementHubCommands.NotificationUpdatedManagedItem, item); + } + } + private void ReportManagedItemDeleteToMgmtHub(string id) + { + _managementServerClient?.SendNotificationToManagementHub(ManagementHubCommands.NotificationRemovedManagedItem, id); + } + + private void ReportRequestProgressToMgmtHub(RequestProgressState progress) + { + _managementServerClient?.SendNotificationToManagementHub(ManagementHubCommands.NotificationManagedItemRequestProgress, progress); + } + + private void _managementServerClient_OnConnectionReconnecting() + { + _serviceLog.Warning("Reconnecting to Management Hub."); + } + + private void GenerateDemoItems() + { + var items = DemoDataGenerator.GenerateDemoItems(); + foreach (var item in items) + { + _ = UpdateManagedCertificate(item); + } + } + } +} diff --git a/src/Certify.Core/Management/CertifyManager/CertifyManager.ServerType.cs b/src/Certify.Core/Management/CertifyManager/CertifyManager.ServerType.cs index 76d770d05..05dade748 100644 --- a/src/Certify.Core/Management/CertifyManager/CertifyManager.ServerType.cs +++ b/src/Certify.Core/Management/CertifyManager/CertifyManager.ServerType.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; @@ -9,6 +9,22 @@ namespace Certify.Management { public partial class CertifyManager { + private async Task> GetTargetServiceTypes() + { + var list = new List(); + + // TODO: make dynamic from service + if (await IsServerTypeAvailable(StandardServerTypes.IIS)) + { + list.Add(StandardServerTypes.IIS.ToString()); + } + + if (await IsServerTypeAvailable(StandardServerTypes.Nginx)) + { + list.Add(StandardServerTypes.Nginx.ToString()); + } + return list; + } private ITargetWebServer GetTargetServerProvider(StandardServerTypes serverType) { diff --git a/src/Certify.Core/Management/CertifyManager/CertifyManager.cs b/src/Certify.Core/Management/CertifyManager/CertifyManager.cs index 9812d4128..5e76ddbbd 100644 --- a/src/Certify.Core/Management/CertifyManager/CertifyManager.cs +++ b/src/Certify.Core/Management/CertifyManager/CertifyManager.cs @@ -1,25 +1,24 @@ -using System; +using System; using System.Collections.Concurrent; using System.Collections.Generic; -using System.Diagnostics; using System.IO; using System.Linq; using System.Runtime.InteropServices; using System.Threading.Tasks; -using Certify.Config.Migration; -using Certify.Core.Management; +using Certify.Core.Management.Access; using Certify.Core.Management.Challenges; using Certify.Datastore.SQLite; using Certify.Models; using Certify.Models.Providers; using Certify.Providers; -using Certify.Providers.ACME.Anvil; +using Microsoft.Extensions.Logging; using Serilog; namespace Certify.Management { public partial class CertifyManager : ICertifyManager, IDisposable { + private IConfigurationStore _configStore = null; /// /// Storage service for managed certificates /// @@ -58,7 +57,7 @@ public partial class CertifyManager : ICertifyManager, IDisposable /// /// Current service log level setting /// - private Serilog.Core.LoggingLevelSwitch _loggingLevelSwitch { get; set; } + private LogLevel _loggingLevelSwitch { get; set; } /// /// If true, http challenge service is started @@ -75,11 +74,6 @@ public partial class CertifyManager : ICertifyManager, IDisposable /// private ConcurrentDictionary _currentChallenges = new ConcurrentDictionary(); - /// - /// Set of current in-progress renewals - /// - private ConcurrentDictionary _progressResults { get; set; } - /// /// Service for reporting status/progress results back to client(s) /// @@ -100,19 +94,22 @@ public partial class CertifyManager : ICertifyManager, IDisposable /// private Shared.ServiceConfig _serverConfig; + private System.Timers.Timer _initTimer; private System.Timers.Timer _heartbeatTimer; private System.Timers.Timer _frequentTimer; private System.Timers.Timer _hourlyTimer; private System.Timers.Timer _dailyTimer; - public CertifyManager() : this(true) + private IServiceProvider _injectedServiceProvider; + public CertifyManager(IServiceProvider injectedServiceProvider) : this() { - + _injectedServiceProvider = injectedServiceProvider; } - public CertifyManager(bool useWindowsNativeFeatures = true) + public CertifyManager() { - _useWindowsNativeFeatures = useWindowsNativeFeatures; + // load setting here so that we know our instance ID etc early on. Other longer tasks are deferred until Init is called. + SettingsManager.LoadAppSettings(); } public async Task Init() @@ -121,13 +118,11 @@ public async Task Init() _serverConfig = SharedUtils.ServiceConfigManager.GetAppServiceConfig(); - SettingsManager.LoadAppSettings(); - InitLogging(_serverConfig); Util.SetSupportedTLSVersions(); - _pluginManager = new PluginManager + _pluginManager = new PluginManager(_injectedServiceProvider) { EnableExternalPlugins = CoreAppSettings.Current.IncludeExternalPlugins }; @@ -176,8 +171,6 @@ public async Task Init() throw (new Exception(msg)); } - _progressResults = new ConcurrentDictionary(); - LoadCertificateAuthorities(); // init remaining utilities and optionally enable telematics @@ -198,6 +191,13 @@ public async Task Init() await UpgradeSettings(); _serviceLog?.Information("Certify Manager Started"); + +#if DEBUG + if (Environment.GetEnvironmentVariable("CERTIFY_GENERATE_DEMO_ITEMS") == "true") + { + GenerateDemoItems(); + } +#endif } /// @@ -205,8 +205,21 @@ public async Task Init() /// private void SetupJobs() { - // 60 second job timer (reporting etc) - _heartbeatTimer = new System.Timers.Timer(60 * 1000); // every n seconds + // 1 shot init of async startup dependencyies (e.g. initial connection to mgmt hub instance) + _initTimer = new System.Timers.Timer(2 * 1000); // 2 seconds + _initTimer.Elapsed += async (s, e) => + { + _initTimer.Stop(); + await EnsureMgmtHubConnection(); + }; + _initTimer.Start(); + + _heartbeatTimer = new System.Timers.Timer(30 * 1000); // every n seconds + _heartbeatTimer.Elapsed += _heartbeatTimer_Elapsed; + _heartbeatTimer.Start(); + + // n second job timer (reporting etc) + _heartbeatTimer = new System.Timers.Timer(30 * 1000); // every n seconds _heartbeatTimer.Elapsed += _heartbeatTimer_Elapsed; _heartbeatTimer.Start(); @@ -247,7 +260,7 @@ private async void _hourlyTimer_Elapsed(object sender, System.Timers.ElapsedEven private async void _heartbeatTimer_Elapsed(object sender, System.Timers.ElapsedEventArgs e) { - + await EnsureMgmtHubConnection(); } private async void _frequentTimer_Elapsed(object sender, System.Timers.ElapsedEventArgs e) @@ -289,7 +302,11 @@ private async Task InitDataStore() { // default sqlite storage _itemManager = new SQLiteManagedItemStore("", _serviceLog); - _credentialsManager = new SQLiteCredentialStore(_useWindowsNativeFeatures, storageSubfolder: "credentials"); + _credentialsManager = new SQLiteCredentialStore("", _serviceLog); + + // config store is a generic store for settings etc + _configStore = new SQLiteConfigurationStore("", _serviceLog); + _accessControl = new AccessControl(_serviceLog, _configStore); } else { @@ -317,7 +334,10 @@ private async Task InitDataStore() else { _itemManager = new SQLiteManagedItemStore("", _serviceLog); - _credentialsManager = new SQLiteCredentialStore(_useWindowsNativeFeatures, storageSubfolder: "credentials"); + _credentialsManager = new SQLiteCredentialStore("", _serviceLog); + + _configStore = new SQLiteConfigurationStore("", _serviceLog); + _accessControl = new AccessControl(_serviceLog, _configStore); } // attempt to create and delete a test item @@ -354,19 +374,22 @@ private async Task InitDataStore() /// private void InitLogging(Shared.ServiceConfig serverConfig) { - _loggingLevelSwitch = new Serilog.Core.LoggingLevelSwitch(Serilog.Events.LogEventLevel.Information); + _loggingLevelSwitch = LogLevel.Information; SetLoggingLevel(serverConfig?.LogLevel); - _serviceLog = new Loggy( - new LoggerConfiguration() - .MinimumLevel.ControlledBy(_loggingLevelSwitch) - .WriteTo.Debug() + var serilogLog = new Serilog.LoggerConfiguration() + .Enrich.FromLogContext() + .MinimumLevel.ControlledBy(ManagedCertificateLog.LogLevelSwitchFromLogLevel(_loggingLevelSwitch)) + .WriteTo.Console() .WriteTo.File(Path.Combine(EnvironmentUtil.CreateAppDataPath("logs"), "session.log"), shared: true, flushToDiskInterval: new TimeSpan(0, 0, 10), rollOnFileSizeLimit: true, fileSizeLimitBytes: 5 * 1024 * 1024) - .CreateLogger() - ); + .CreateLogger(); + + var msLogger = new Serilog.Extensions.Logging.SerilogLoggerFactory(serilogLog).CreateLogger(); - _serviceLog?.Information($"-------------------- Logging started: {_loggingLevelSwitch.MinimumLevel} --------------------"); + _serviceLog = new Loggy(msLogger); + + _serviceLog?.Information($"-------------------- Logging started: {_loggingLevelSwitch} --------------------"); } /// @@ -378,15 +401,15 @@ public void SetLoggingLevel(string logLevel) switch (logLevel?.ToLower()) { case "debug": - _loggingLevelSwitch.MinimumLevel = Serilog.Events.LogEventLevel.Debug; + _loggingLevelSwitch = LogLevel.Trace; break; case "verbose": - _loggingLevelSwitch.MinimumLevel = Serilog.Events.LogEventLevel.Verbose; + _loggingLevelSwitch = LogLevel.Debug; break; default: - _loggingLevelSwitch.MinimumLevel = Serilog.Events.LogEventLevel.Information; + _loggingLevelSwitch = LogLevel.Information; break; } } @@ -399,25 +422,6 @@ public void SetStatusReporting(IStatusReporting statusReporting) { _statusReporting = statusReporting; } - /// - /// Begin/restart progress tracking for renewal requests of a given managed certificate - /// - /// - public void BeginTrackingProgress(RequestProgressState state) - { - try - { - if (state?.Id != null) - { - _progressResults.AddOrUpdate(state.Id, state, (id, s) => state); - } - } - catch (Exception) - { - // failed to add progress tracking, likely concurrency issue - _serviceLog?.Warning($"Failed to add tracking progress for {state.ManagedCertificate.Id}. Likely concurrency issue."); - } - } /// /// Update progress tracking and send status report to client(s). optionally logging to service log @@ -432,10 +436,12 @@ public void ReportProgress(IProgress progress, RequestProg progress.Report(state); } - // report request state to status hub clients + // report request state to status hub clients and optionally mgmt hub _statusReporting?.ReportRequestProgress(state); + ReportRequestProgressToMgmtHub(state); + if (state.ManagedCertificate != null && logThisEvent) { if (state.CurrentState == RequestState.Error) @@ -462,25 +468,17 @@ public void ReportProgress(IProgress progress, RequestProg Message = msg }, _loggingLevelSwitch); - /// - /// Get current progress result for the given managed certificate id - /// - /// - /// - public RequestProgressState GetRequestProgressState(string managedItemId) + public void Dispose() => Cleanup(); + + private void Cleanup() { - if (_progressResults.TryGetValue(managedItemId, out var progress)) + ManagedCertificateLog.DisposeLoggers(); + if (_tc != null) { - return progress; - } - else - { - return new RequestProgressState(RequestState.NotRunning, "No request in progress", null); + _tc.Dispose(); } } - public void Dispose() => ManagedCertificateLog.DisposeLoggers(); - /// /// Get the current service log (per line) /// @@ -538,5 +536,11 @@ public Task ApplyPreferences() return Task.FromResult(true); } + + private IAccessControl _accessControl; + public Task GetCurrentAccessControl() + { + return Task.FromResult(_accessControl); + } } } diff --git a/src/Certify.Core/Management/CertifyManager/ICertifyManager.cs b/src/Certify.Core/Management/CertifyManager/ICertifyManager.cs index ddbd48990..cb77732f9 100644 --- a/src/Certify.Core/Management/CertifyManager/ICertifyManager.cs +++ b/src/Certify.Core/Management/CertifyManager/ICertifyManager.cs @@ -1,11 +1,13 @@ -using System; +using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Threading.Tasks; +using Certify.Client; using Certify.Config; -using Certify.Config.Migration; using Certify.Models; using Certify.Models.Config; +using Certify.Models.Config.Migration; +using Certify.Models.Hub; using Certify.Models.Providers; using Certify.Providers; using Certify.Shared; @@ -15,91 +17,56 @@ namespace Certify.Management public interface ICertifyManager { Task Init(); - void SetStatusReporting(IStatusReporting statusReporting); - Task IsServerTypeAvailable(StandardServerTypes serverType); - Task GetServerTypeVersion(StandardServerTypes serverType); - Task> RunServerDiagnostics(StandardServerTypes serverType, string siteId); - Task GetManagedCertificate(string id); - Task> GetManagedCertificates(ManagedCertificateFilter filter = null); - + Task GetManagedCertificateResults(ManagedCertificateFilter filter = null); + Task GetManagedCertificateSummary(ManagedCertificateFilter filter = null); Task UpdateManagedCertificate(ManagedCertificate site); - - Task DeleteManagedCertificate(string id); - + Task DeleteManagedCertificate(string id); Task PerformExport(ExportRequest exportRequest); Task> PerformImport(ImportRequest importRequest); - Task> GetCurrentChallengeResponses(string challengeType, string key = null); Task> GetAccountRegistrations(); - Task AddAccount(ContactRegistration reg); - Task UpdateAccountContact(string storageKey, ContactRegistration contact); - Task RemoveAccount(string storageKey, bool includeAccountDeactivation = false); Task> ChangeAccountKey(string storageKey, string newKeyPEM = null); Task> TestChallenge(ILog log, ManagedCertificate managedCertificate, bool isPreviewMode, IProgress progress = null); Task> PerformChallengeCleanup(ILog log, ManagedCertificate managedCertificate, IProgress progress = null); Task> PerformServiceDiagnostics(); - Task> GetDnsProviderZones(string providerTypeId, string credentialsId); + Task> GetDnsProviderZones(string providerTypeId, string credentialId); Task UpdateCertificateAuthority(CertificateAuthority certificateAuthority); Task> GetCertificateAuthorities(); - Task RevokeCertificate(ILog log, ManagedCertificate managedCertificate); - Task PerformDummyCertificateRequest(ManagedCertificate managedCertificate, IProgress progress = null); Task RemoveCertificateAuthority(string id); Task> GetPrimaryWebSites(StandardServerTypes serverType, bool ignoreStoppedSites, string itemId = null); - - void BeginTrackingProgress(RequestProgressState state); - Task> RedeployManagedCertificates(ManagedCertificateFilter filter, IProgress progress = null, bool isPreviewOnly = false, bool includeDeploymentTasks = false); - Task DeployCertificate(ManagedCertificate managedCertificate, IProgress progress = null, bool isPreviewOnly = false, bool includeDeploymentTasks = false); - Task PerformCertificateRequest(ILog log, ManagedCertificate managedCertificate, IProgress progress = null, bool resumePaused = false, bool skipRequest = false, bool failOnSkip = false, bool skipTasks = false, bool isInteractive = false, string reason = null); - Task> GetDomainOptionsFromSite(StandardServerTypes serverType, string siteId); - Task> PerformRenewAll(RenewalSettings settings, ConcurrentDictionary> progressTrackers = null); - - RequestProgressState GetRequestProgressState(string managedItemId); - Task PerformRenewalTasks(); - Task PerformDailyMaintenanceTasks(); - Task PerformCertificateCleanup(); - Task> PerformCertificateMaintenanceTasks(string managedItemId = null); - Task> GeneratePreview(ManagedCertificate item); - void ReportProgress(IProgress progress, RequestProgressState state, bool logThisEvent = true); - Task> PerformDeploymentTask(ILog log, string managedCertificateId, string taskId, bool isPreviewOnly, bool skipDeferredTasks, bool forceTaskExecution); - Task> GetDeploymentProviders(); - Task> ValidateDeploymentTask(ManagedCertificate managedCertificate, DeploymentTaskConfig taskConfig); - Task GetDeploymentProviderDefinition(string id, DeploymentTaskConfig config); - - Task GetItemLog(string id, int limit = 1000); + Task GetItemLog(string id, int limit = 1000); Task GetServiceLog(string logType, int limit = 10000); - ICredentialsManager GetCredentialsManager(); IManagedItemStore GetManagedItemStore(); - Task ApplyPreferences(); Task> GetDataStoreProviders(); @@ -109,6 +76,20 @@ public interface ICertifyManager Task> UpdateDataStoreConnection(DataStoreConnection dataStore); Task> RemoveDataStoreConnection(string dataStoreId); Task> TestDataStoreConnection(DataStoreConnection connection); + Task TestCredentials(string storageKey); + Task GetCurrentAccessControl(); + + Task> GetManagedChallenges(); + Task UpdateManagedChallenge(ManagedChallenge update); + Task DeleteManagedChallenge(string id); + Task PerformManagedChallengeRequest(ManagedChallengeRequest request); + Task CleanupManagedChallengeRequest(ManagedChallengeRequest request); + + Task UpdateManagementHub(string url, string joiningKey); + Task PerformHubCommandWithResult(InstanceCommandRequest arg); + + void SetDirectManagementClient(IManagementServerClient client); + ManagedInstanceInfo GetManagedInstanceInfo(); } } diff --git a/src/Certify.Core/Management/Challenges/ChallengeProviders.cs b/src/Certify.Core/Management/Challenges/ChallengeProviders.cs index e363b3a9e..8b99c6262 100644 --- a/src/Certify.Core/Management/Challenges/ChallengeProviders.cs +++ b/src/Certify.Core/Management/Challenges/ChallengeProviders.cs @@ -223,6 +223,14 @@ public static async Task GetDnsProvider(string providerType, Dicti public static async Task> GetChallengeAPIProviders() { var result = PluginManager.CurrentInstance.DnsProviderProviders.SelectMany(pp => pp.GetProviders(pp.GetType())).ToList(); + +#if DEBUG + // output list of providers which require credentials plus list of potential stored credential parameters + foreach (var resultItem in result.Where(p => p.ProviderParameters.Any(p => p.IsCredential)).OrderBy(r => r.Title)) + { + System.Diagnostics.Debug.WriteLine($"[{resultItem.Title}] ID: {resultItem.Id} {{{string.Join(",", resultItem.ProviderParameters.Where(p => p.IsCredential).Select(p => $"'{p.Key}','<{p.Name}>'"))}}}"); + } +#endif return await Task.FromResult(result); } } diff --git a/src/Certify.Core/Management/Challenges/ChallengeResponseService.cs b/src/Certify.Core/Management/Challenges/ChallengeResponseService.cs index f7adadcab..19e6cc7a0 100644 --- a/src/Certify.Core/Management/Challenges/ChallengeResponseService.cs +++ b/src/Certify.Core/Management/Challenges/ChallengeResponseService.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -621,7 +621,7 @@ private Func PrepareChallengeResponse_TlsSni01(ILog log, ITargetWebServer private DnsChallengeHelper _dnsHelper = null; - private async Task PerformChallengeResponse_Dns01(ILog log, CertIdentifierItem domain, ManagedCertificate managedCertificate, PendingAuthorization pendingAuth, bool isTestMode, bool isCleanupOnly, ICredentialsManager credentialsManager) + internal async Task PerformChallengeResponse_Dns01(ILog log, CertIdentifierItem domain, ManagedCertificate managedCertificate, PendingAuthorization pendingAuth, bool isTestMode, bool isCleanupOnly, ICredentialsManager credentialsManager) { var dnsChallenge = pendingAuth.Challenges.FirstOrDefault(c => c.ChallengeType == SupportedChallengeTypes.CHALLENGE_TYPE_DNS); diff --git a/src/Certify.Core/Management/Challenges/DNS/DnsChallengeHelper.cs b/src/Certify.Core/Management/Challenges/DNS/DnsChallengeHelper.cs index a04d9c3ea..a5417c6c5 100644 --- a/src/Certify.Core/Management/Challenges/DNS/DnsChallengeHelper.cs +++ b/src/Certify.Core/Management/Challenges/DNS/DnsChallengeHelper.cs @@ -6,6 +6,7 @@ using Certify.Models; using Certify.Models.Config; using Certify.Models.Providers; +using Newtonsoft.Json; namespace Certify.Core.Management.Challenges { @@ -41,10 +42,10 @@ public DnsChallengeHelper(ICredentialsManager credentialsManager) { _credentialsManager = credentialsManager; } - public async Task GetDnsProvider(string providerTypeId, string credentialsId, Dictionary parameters, ICredentialsManager credentialsManager, ILog log = null) + public async Task GetDnsProvider(string providerTypeId, string credentialId, Dictionary parameters, ICredentialsManager credentialsManager, ILog log = null) { var credentials = new Dictionary(); - if (!string.IsNullOrEmpty(credentialsId)) + if (!string.IsNullOrEmpty(credentialId)) { var failureResult = new DnsChallengeHelperResult( failureMsg: "DNS Challenge API Credentials could not be decrypted or no longer exists. The original user must be used for decryption." @@ -53,7 +54,7 @@ public async Task GetDnsProvider(string providerTypeId // decode credentials string array try { - credentials = await credentialsManager.GetUnlockedCredentialsDictionary(credentialsId); + credentials = await credentialsManager.GetUnlockedCredentialsDictionary(credentialId); if (credentials == null) { return failureResult; @@ -61,7 +62,7 @@ public async Task GetDnsProvider(string providerTypeId } catch (Exception exp) { - log?.Error(exp, $"The required stored credential {credentialsId} could not be found or could not be decrypted."); + log?.Error(exp, $"The required stored credential {credentialId} could not be found or could not be decrypted."); return failureResult; } } @@ -94,6 +95,46 @@ public async Task GetDnsProvider(string providerTypeId }; } + private Dictionary _dnsProviderCache = new Dictionary(); + private bool _useDnsProviderCaching = false; + + /// + /// Gets optionally cached DNS provider instance, caching may be based credentials/parameters to allow for zone query caching. TODO: log context will be first caller instead of current + /// + /// + /// + /// + /// + /// + private async Task GetDnsProvider(ILog log, string challengeProvider, Dictionary credentials, Dictionary parameters) + { + + IDnsProvider dnsAPIProvider = null; + + if (_useDnsProviderCaching) + { + // construct basic cache key for dns provider and credentials combo + var providerCacheKey = challengeProvider + (challengeProvider + JsonConvert.SerializeObject(credentials ?? new Dictionary()) + JsonConvert.SerializeObject(parameters ?? new Dictionary())).GetHashCode().ToString(); + if (_dnsProviderCache.ContainsKey(providerCacheKey)) + { + log.Warning("Developer Note: DNS provider log context will be first caller instead of current"); + + dnsAPIProvider = _dnsProviderCache[providerCacheKey]; + } + else + { + dnsAPIProvider = await ChallengeProviders.GetDnsProvider(challengeProvider, credentials, parameters, log); + _dnsProviderCache.Add(providerCacheKey, dnsAPIProvider); + } + } + else + { + dnsAPIProvider = await ChallengeProviders.GetDnsProvider(challengeProvider, credentials, parameters, log); + } + + return dnsAPIProvider; + } + public async Task CompleteDNSChallenge(ILog log, ManagedCertificate managedcertificate, CertIdentifierItem domain, string txtRecordName, string txtRecordValue, bool isTestMode) { // for a given managed site configuration, attempt to complete the required challenge by @@ -129,7 +170,7 @@ public async Task CompleteDNSChallenge(ILog log, Manag try { - dnsAPIProvider = await ChallengeProviders.GetDnsProvider(challengeConfig.ChallengeProvider, credentials, parameters, log); + dnsAPIProvider = await GetDnsProvider(log, challengeConfig.ChallengeProvider, credentials, parameters); } catch (ChallengeProviders.CredentialsRequiredException) { @@ -167,54 +208,52 @@ public async Task CompleteDNSChallenge(ILog log, Manag #pragma warning restore CS0618 // Type or member is obsolete } - if (dnsAPIProvider != null) - { - //most DNS providers require domains to by ASCII - txtRecordName = _idnMapping.GetAscii(txtRecordName).ToLower().Trim(); + //most DNS providers require domains to by ASCII + txtRecordName = _idnMapping.GetAscii(txtRecordName).ToLower().Trim(); - if (!string.IsNullOrEmpty(challengeConfig.ChallengeDelegationRule)) - { - var delegatedTXTRecordName = ApplyChallengeDelegationRule(domain.Value, txtRecordName, challengeConfig.ChallengeDelegationRule); - log.Information($"DNS: Challenge Delegation Domain enabled, using {delegatedTXTRecordName} in place of {txtRecordName}."); + if (!string.IsNullOrEmpty(challengeConfig.ChallengeDelegationRule)) + { + var delegatedTxtRecordName = ApplyChallengeDelegationRule(domain.Value, txtRecordName, challengeConfig.ChallengeDelegationRule); + log.Information($"DNS: Challenge Delegation Domain enabled, using {delegatedTxtRecordName} in place of {txtRecordName}."); - txtRecordName = delegatedTXTRecordName; - } + txtRecordName = delegatedTxtRecordName; + } - log.Information($"DNS: Creating TXT Record '{txtRecordName}' with value '{txtRecordValue}', [{domain.Value}] {(zoneId != null ? $"in ZoneId '{zoneId}'" : "")} using API provider '{dnsAPIProvider.ProviderTitle}'"); - try + log.Information($"DNS: Creating TXT Record '{txtRecordName}' with value '{txtRecordValue}', [{domain.Value}] {(zoneId != null ? $"in ZoneId '{zoneId}'" : "")} using API provider '{dnsAPIProvider.ProviderTitle}'"); + try + { + var result = await dnsAPIProvider.CreateRecord(new DnsRecord { - var result = await dnsAPIProvider.CreateRecord(new DnsRecord - { - RecordType = "TXT", - TargetDomainName = domain.Value.Trim(), - RecordName = txtRecordName, - RecordValue = txtRecordValue, - ZoneId = zoneId - }); + RecordType = "TXT", + TargetDomainName = domain.Value.Trim(), + RecordName = txtRecordName, + RecordValue = txtRecordValue, + ZoneId = zoneId + }); - result.Message = $"{dnsAPIProvider.ProviderTitle} :: {result.Message}"; + result.Message = $"{dnsAPIProvider.ProviderTitle} :: {result.Message}"; - var isAwaitingUser = false; + var isAwaitingUser = false; - if (challengeConfig.ChallengeProvider.Contains(".Manual") || result.Message.Contains("[Action Required]")) - { - isAwaitingUser = true; - } - - return new DnsChallengeHelperResult - { - Result = result, - PropagationSeconds = dnsAPIProvider.PropagationDelaySeconds, - IsAwaitingUser = isAwaitingUser - }; - } - catch (Exception exp) + if (challengeConfig.ChallengeProvider.Contains(".Manual") || result.Message.Contains("[Action Required]")) { - return new DnsChallengeHelperResult(failureMsg: $"Failed [{dnsAPIProvider.ProviderTitle}]: {exp}"); + isAwaitingUser = true; } - //TODO: DNS query to check for new record - /* + return new DnsChallengeHelperResult + { + Result = result, + PropagationSeconds = dnsAPIProvider.PropagationDelaySeconds, + IsAwaitingUser = isAwaitingUser + }; + } + catch (Exception exp) + { + return new DnsChallengeHelperResult(failureMsg: $"Failed [{dnsAPIProvider.ProviderTitle}]: {exp}"); + } + + //TODO: DNS query to check for new record + /* if (result.IsSuccess) { // do our own txt record query before proceeding with challenge completion @@ -245,11 +284,6 @@ public async Task CompleteDNSChallenge(ILog log, Manag return result; } */ - } - else - { - return new DnsChallengeHelperResult(failureMsg: "Error: Could not determine DNS API Provider."); - } } /// @@ -365,15 +399,15 @@ public async Task DeleteDNSChallenge(ILog log, Managed try { - dnsAPIProvider = await ChallengeProviders.GetDnsProvider(challengeConfig.ChallengeProvider, credentials, parameters); + dnsAPIProvider = await GetDnsProvider(log, challengeConfig.ChallengeProvider, credentials, parameters); } catch (ChallengeProviders.CredentialsRequiredException) { - return new DnsChallengeHelperResult(failureMsg: "This DNS Challenge API requires one or more credentials to be specified."); + return new DnsChallengeHelperResult("This DNS Challenge API requires one or more credentials to be specified."); } catch (Exception exp) { - return new DnsChallengeHelperResult(failureMsg: $"DNS Challenge API Provider could not be created. Check all required credentials are set. {exp.ToString()}"); + return new DnsChallengeHelperResult($"DNS Challenge API Provider could not be created. Check all required credentials are set. {exp.ToString()}"); } if (dnsAPIProvider == null) diff --git a/src/Certify.Core/Management/DeploymentTasks/DeploymentTask.cs b/src/Certify.Core/Management/DeploymentTasks/DeploymentTask.cs index 474dc6d6f..fad888bf3 100644 --- a/src/Certify.Core/Management/DeploymentTasks/DeploymentTask.cs +++ b/src/Certify.Core/Management/DeploymentTasks/DeploymentTask.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Certify.Config; @@ -30,23 +31,31 @@ public async Task> Execute( ILog log, ICredentialsManager credentialsManager, object subject, - CancellationToken cancellationToken, DeploymentContext deploymentContext, - bool isPreviewOnly = true + bool isPreviewOnly, + CancellationToken cancellationToken ) { if (TaskProvider != null && TaskConfig != null) { try { - var execParams = new DeploymentTaskExecutionParams(log, credentialsManager, subject, TaskConfig, _credentials, isPreviewOnly, null, cancellationToken, deploymentContext); + var execParams = new DeploymentTaskExecutionParams(log, credentialsManager, subject, TaskConfig, _credentials, isPreviewOnly, null, deploymentContext, cancellationToken); if (!isPreviewOnly) { return await TaskProvider.Execute(execParams); } else { - return new List { new ActionResult { IsSuccess = true, Message = "Task is review mode only. Not action performed." } }; + var validation = await TaskProvider.Validate(execParams); + if (validation == null || !validation.Any(r => r.IsSuccess == false)) + { + return new List { new ActionResult { IsSuccess = true, Message = "Task is valid and ready to execute." } }; + } + else + { + return validation; + } } } catch (Exception exp) diff --git a/src/Certify.Core/Management/DeploymentTasks/DeploymentTaskProviderFactory.cs b/src/Certify.Core/Management/DeploymentTasks/DeploymentTaskProviderFactory.cs index f715a941e..a5a3deccf 100644 --- a/src/Certify.Core/Management/DeploymentTasks/DeploymentTaskProviderFactory.cs +++ b/src/Certify.Core/Management/DeploymentTasks/DeploymentTaskProviderFactory.cs @@ -52,6 +52,13 @@ public async static Task> GetDeploymentTaskPr } } +#if DEBUG + // output list of providers which require credentials plus list of potential stored credential parameters + foreach (var resultItem in list.Where(p => p.ProviderParameters.Any(p => p.IsCredential)).OrderBy(r => r.Title)) + { + System.Diagnostics.Debug.WriteLine($"[{resultItem.Title}] ID: {resultItem.Id} {{{string.Join(",", resultItem.ProviderParameters.Where(p => p.IsCredential).Select(p => $"'{p.Key}','<{p.Name}>'"))}}}"); + } +#endif return await Task.FromResult(list); } diff --git a/src/Certify.Core/Management/MigrationManager.cs b/src/Certify.Core/Management/MigrationManager.cs index 215d31160..ed2aad3fb 100644 --- a/src/Certify.Core/Management/MigrationManager.cs +++ b/src/Certify.Core/Management/MigrationManager.cs @@ -8,10 +8,10 @@ using System.Text; using System.Threading.Tasks; using Certify.Config; -using Certify.Config.Migration; using Certify.Management; using Certify.Models; using Certify.Models.Config; +using Certify.Models.Config.Migration; using Certify.Models.Providers; using Certify.Providers; diff --git a/src/Certify.Core/Management/RenewalManager.cs b/src/Certify.Core/Management/RenewalManager.cs index 30f7a75c4..1da39dcc0 100644 --- a/src/Certify.Core/Management/RenewalManager.cs +++ b/src/Certify.Core/Management/RenewalManager.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; -using Certify.Locales; using Certify.Models; using Certify.Models.Providers; using Certify.Providers; @@ -19,75 +18,42 @@ public static class RenewalManager private const int DEFAULT_CERTIFICATE_REQUEST_TASKS = 50; - public static async Task> PerformRenewAll( - ILog _serviceLog, - IManagedItemStore _itemManager, - RenewalSettings settings, - RenewalPrefs prefs, - Action BeginTrackingProgress, - Action, RequestProgressState, bool> ReportProgress, - Func> IsManagedCertificateRunning, - Func, bool, string, Task> PerformCertificateRequest, - ConcurrentDictionary> progressTrackers = null - ) + private static Progress SetupProgressTracker( + ManagedCertificate item, string renewalReason, + ConcurrentDictionary> progressTrackers, + Action, RequestProgressState, bool> reportProgress + ) { - // we can perform request in parallel but if processing many requests this can cause issues committing IIS bindings etc - var testModeOnly = false; + // track progress + var progressState = new RequestProgressState(RequestState.Running, "Starting..", item); + var progressTracker = new Progress(progressState.ProgressReport); - IEnumerable managedCertificates; + progressTrackers.TryAdd(item.Id, progressTracker); - if (settings.TargetManagedCertificates?.Any() == true) - { - var targetCerts = new List(); - foreach (var id in settings.TargetManagedCertificates) - { - targetCerts.Add(await _itemManager.GetById(id)); - } + reportProgress(progressTracker, new RequestProgressState(RequestState.Queued, $"Queued for renewal: {renewalReason}", item), false); - managedCertificates = targetCerts; - } - else - { - managedCertificates = await _itemManager.Find( - new ManagedCertificateFilter - { - IncludeOnlyNextAutoRenew = (settings.Mode == RenewalMode.Auto) - } - ); - } + return progressTracker; + } - if (settings.Mode == RenewalMode.Auto || settings.Mode == RenewalMode.RenewalsDue) - { - // auto renew enabled sites in order of oldest date renewed (or earliest attempted), items not yet attempted are first. - // if mode is just RenewalDue then we also include items that are not marked auto renew (the user may be controlling when to perform renewal). + public static async Task> PerformRenewAll( + ILog serviceLog, + IManagedItemStore itemManager, + RenewalSettings settings, + RenewalPrefs prefs, + Action, RequestProgressState, bool> reportProgress, + Func> isManagedCertificateRunning, + Func, bool, string, Task> performCertificateRequest, + ConcurrentDictionary> progressTrackers = null + ) + { - managedCertificates = managedCertificates.Where(s => s.IncludeInAutoRenew == true || settings.Mode == RenewalMode.RenewalsDue) - .OrderBy(s => s.DateRenewed ?? s.DateLastRenewalAttempt ?? DateTimeOffset.MinValue); - } - else if (settings.Mode == RenewalMode.NewItems) - { - // new items not yet completed in order of oldest renewal attempt first - managedCertificates = managedCertificates.Where(s => s.DateRenewed == null) - .OrderBy(s => s.DateLastRenewalAttempt ?? DateTimeOffset.UtcNow.AddHours(-48)); - } - else if (settings.Mode == RenewalMode.RenewalsWithErrors) + var maxRenewalTasks = prefs.MaxRenewalRequests; + if (maxRenewalTasks <= 0) { - // items with current errors in order of oldest renewal attempt first - managedCertificates = managedCertificates.Where(s => s.LastRenewalStatus == RequestState.Error) - .OrderBy(s => s.DateLastRenewalAttempt ?? DateTimeOffset.UtcNow.AddHours(-1)); + maxRenewalTasks = DEFAULT_CERTIFICATE_REQUEST_TASKS; } - // check site list and examine current certificates. If certificate is less than n days - // old, don't attempt to renew it - var sitesToRenew = new List(); - var renewalIntervalDays = prefs.RenewalIntervalDays; - var renewalIntervalMode = prefs.RenewalIntervalMode ?? RenewalIntervalModes.DaysAfterLastRenewal; - - var numRenewalTasks = 0; - - var maxRenewalTasks = prefs.MaxRenewalRequests; - var renewalTasks = new List>(); if (progressTrackers == null) @@ -95,129 +61,151 @@ public static async Task> PerformRenewAll( progressTrackers = new ConcurrentDictionary>(); } - if (managedCertificates.Count(c => c.LastRenewalStatus == RequestState.Error) > MAX_CERTIFICATE_REQUEST_TASKS) - { - _serviceLog?.Warning("Too many failed certificates outstanding. Fix failures or delete. Failures: " + managedCertificates.Count(c => c.LastRenewalStatus == RequestState.Error)); - } + List managedCertificateBatch; - foreach (var managedCertificate in managedCertificates) + if (settings.TargetManagedCertificates?.Any() == true) { - // if cert is not awaiting manual user input (manual DNS etc), proceed with renewal checks - if (managedCertificate.LastRenewalStatus != RequestState.Paused) - { - var progressState = new RequestProgressState(RequestState.Running, "Starting..", managedCertificate); - var progressIndicator = new Progress(progressState.ProgressReport); + // prepare renewal batch using just the selected set of target items + var targetCerts = new List(); - try - { - progressTrackers.TryAdd(managedCertificate.Id, progressIndicator); - } - catch + foreach (var id in settings.TargetManagedCertificates) + { + var item = await itemManager.GetById(id); + if (item != null) { - _serviceLog?.Error($"Failed to add progress tracker for {managedCertificate.Id}. Likely concurrency issue, skipping this managed cert during this run."); - continue; + targetCerts.Add(item); } + } - BeginTrackingProgress(progressState); + if (!targetCerts.Any()) + { + serviceLog?.Error("No matching target managed certificates found for renewal."); + return new List(); + } + + managedCertificateBatch = targetCerts; + + foreach (var item in managedCertificateBatch) + { + var progressTracker = SetupProgressTracker(item, "", progressTrackers, reportProgress); - // determine if this site currently requires renewal for auto mode (or renewals due mode) - // In auto mode we skip if recent failures, in Renewals Due mode we ignore recent failures + renewalTasks.Add( + new Task( + () => performCertificateRequest(item, progressTracker, settings.IsPreviewMode, "Renewal requested").Result, + TaskCreationOptions.LongRunning + ) + ); + } + } + else + { - var renewalDueCheck = ManagedCertificate.CalculateNextRenewalAttempt(managedCertificate, renewalIntervalDays, renewalIntervalMode, checkFailureStatus: false); - var isRenewalRequired = (settings.Mode != RenewalMode.Auto && settings.Mode != RenewalMode.RenewalsDue) || renewalDueCheck.IsRenewalDue; + // prepare batch of renewals until we have reached the limit of tasks we will perform in one pass, or run out of items to attempt - var renewalReason = renewalDueCheck.Reason; + // auto renew enabled sites in order of oldest date renewed (or earliest attempted), items not yet attempted are first. + var filter = new ManagedCertificateFilter + { + IncludeOnlyNextAutoRenew = (settings.Mode == RenewalMode.Auto), + OrderBy = ManagedCertificateFilter.SortMode.RENEWAL_ASC + }; - if (settings.Mode == RenewalMode.All) + /* if (settings.Mode == RenewalMode.Auto || settings.Mode == RenewalMode.RenewalsDue) { - // on all mode, everything gets an attempted renewal - isRenewalRequired = true; - renewalReason = "Renewal Mode is set to All"; - } - //if we care about stopped sites being stopped, check for that if a specific site is selected - var isSiteRunning = true; - if (prefs.IncludeStoppedSites && !string.IsNullOrEmpty(managedCertificate.ServerSiteId)) + // if mode is just RenewalDue then we also include items that are not marked auto renew (the user may be controlling when to perform renewal). + + managedCertificateBatch = managedCertificateBatch.Where(s => s.IncludeInAutoRenew == true || settings.Mode == RenewalMode.RenewalsDue) + .OrderBy(s => s.DateRenewed ?? s.DateLastRenewalAttempt ?? DateTimeOffset.MinValue); + } + else if (settings.Mode == RenewalMode.NewItems) { - isSiteRunning = await IsManagedCertificateRunning(managedCertificate.Id); + // new items not yet completed in order of oldest renewal attempt first + managedCertificateBatch = managedCertificateBatch.Where(s => s.DateRenewed == null) + .OrderBy(s => s.DateLastRenewalAttempt ?? DateTimeOffset.UtcNow.AddHours(-48)); } - - if (!renewalDueCheck.IsRenewalOnHold && isRenewalRequired && isSiteRunning && !testModeOnly) + else if (settings.Mode == RenewalMode.RenewalsWithErrors) { - //get matching progress tracker for this site - IProgress tracker = null; - if (progressTrackers != null) - { - tracker = progressTrackers[managedCertificate.Id]; - } + // items with current errors in order of oldest renewal attempt first + managedCertificateBatch = managedCertificateBatch.Where(s => s.LastRenewalStatus == RequestState.Error) + .OrderBy(s => s.DateLastRenewalAttempt ?? DateTimeOffset.UtcNow.AddHours(-1)); + }*/ - // limit the number of renewal tasks to attempt in this pass either to custom setting or max allowed - if ( - (maxRenewalTasks == 0 && numRenewalTasks < DEFAULT_CERTIFICATE_REQUEST_TASKS) - || - (maxRenewalTasks > 0 && numRenewalTasks < maxRenewalTasks && numRenewalTasks < MAX_CERTIFICATE_REQUEST_TASKS) - ) - { + var totalRenewalCandidates = await itemManager.CountAll(filter); - renewalTasks.Add( - new Task( - () => PerformCertificateRequest(managedCertificate, tracker, settings.IsPreviewMode, renewalReason).Result, - TaskCreationOptions.LongRunning - )); + var renewalIntervalDays = prefs.RenewalIntervalDays; + var renewalIntervalMode = prefs.RenewalIntervalMode ?? RenewalIntervalModes.DaysAfterLastRenewal; - ReportProgress((IProgress)progressTrackers[managedCertificate.Id], new RequestProgressState(RequestState.Queued, $"Queued for renewal: {renewalDueCheck.Reason}", managedCertificate), false); + filter.PageSize = MAX_CERTIFICATE_REQUEST_TASKS; + filter.PageIndex = 0; - } - else - { - if (!prefs.SuppressSkippedItems) - { - //send progress back to report skip - var progress = (IProgress)progressTrackers[managedCertificate.Id]; - ReportProgress(progress, new RequestProgressState(RequestState.NotRunning, "Skipped renewal because the max requests per batch has been reached. This request will be attempted again later.", managedCertificate, isSkipped: true), true); - } - else - { - _serviceLog.Debug($"Skipping item {managedCertificate.Id}:{managedCertificate.Name}, max batch size exceeded."); - } - } + var batch = new List(); + var resultsRemaining = totalRenewalCandidates; - // track number of tasks being attempted - numRenewalTasks++; + // identify items we will attempt and begin tracking progress + while (batch.Count < maxRenewalTasks && resultsRemaining > 0) + { + var results = await itemManager.Find(filter); + resultsRemaining = results.Count; - } - else + foreach (var item in results) { - var msg = renewalDueCheck.Reason; - var requestState = RequestState.Success; - - var logThisEvent = false; - - if (isRenewalRequired && !isSiteRunning) - { - msg = CoreSR.CertifyManager_SiteStopped; - } - - if (renewalDueCheck.IsRenewalOnHold) - { - logThisEvent = true; - } - - if (progressTrackers != null) + if (batch.Count < maxRenewalTasks) { - if (!renewalDueCheck.IsRenewalDue || renewalDueCheck.IsRenewalOnHold && prefs.SuppressSkippedItems) - { - _serviceLog.Debug($"Skipping item {managedCertificate.Id}:{managedCertificate.Name}, UI reporting suppressed: {msg}"); - } - else + // if cert is not awaiting manual user input (manual DNS etc), proceed with renewal checks + if (item.LastRenewalStatus != RequestState.Paused) { - //send progress back to report skip - /* var progress = (IProgress)progressTrackers[managedCertificate.Id]; - ReportProgress(progress, new RequestProgressState(requestState, msg, managedCertificate, isSkipped: true), logThisEvent);*/ + // check if item is due for renewal based on current settings + + var renewalDueCheck = ManagedCertificate.CalculateNextRenewalAttempt(item, renewalIntervalDays, renewalIntervalMode, checkFailureStatus: false); + var isRenewalRequired = (settings.Mode != RenewalMode.Auto && settings.Mode != RenewalMode.RenewalsDue) || renewalDueCheck.IsRenewalDue; + + var renewalReason = renewalDueCheck.Reason; + + if (settings.Mode == RenewalMode.All) + { + // on all mode, everything gets an attempted renewal + isRenewalRequired = true; + renewalReason = "Renewal Mode is set to All"; + } + + // if we care about stopped sites being stopped, check if a specific site is selected and if it's running + if (!prefs.IncludeStoppedSites && !string.IsNullOrEmpty(item.ServerSiteId) && item.RequestConfig.DeploymentSiteOption == DeploymentOption.SingleSite) + { + var isSiteRunning = await isManagedCertificateRunning(item.Id); + + if (!isSiteRunning) + { + isRenewalRequired = false; + renewalReason = "Target site is not running and 'Include Stopped Sites' preference is False. Renewal will not be attempted."; + } + } + + if (isRenewalRequired && !renewalDueCheck.IsRenewalOnHold) + { + batch.Add(item); + + var progressTracker = SetupProgressTracker(item, "", progressTrackers, reportProgress); + + renewalTasks.Add( + new Task( + () => performCertificateRequest(item, progressTracker, settings.IsPreviewMode, renewalReason).Result, + TaskCreationOptions.LongRunning + ) + ); + } } } } + + filter.PageIndex++; } + + managedCertificateBatch = batch; + } + + if (managedCertificateBatch.Count(c => c.LastRenewalStatus == RequestState.Error) > MAX_CERTIFICATE_REQUEST_TASKS) + { + serviceLog?.Warning("Too many failed certificates outstanding. Fix failures or delete. Failures: " + managedCertificateBatch.Count(c => c.LastRenewalStatus == RequestState.Error)); } if (!renewalTasks.Any()) @@ -228,16 +216,13 @@ public static async Task> PerformRenewAll( } else { - _serviceLog.Information($"Attempting {renewalTasks.Count} renewal tasks. Max renewal tasks is set to {maxRenewalTasks}, max supported tasks is {MAX_CERTIFICATE_REQUEST_TASKS}"); + serviceLog?.Information($"Attempting {renewalTasks.Count} renewal tasks. Max renewal tasks is set to {maxRenewalTasks}, max supported tasks is {MAX_CERTIFICATE_REQUEST_TASKS}"); } if (prefs.PerformParallelRenewals) { renewalTasks.ForEach(t => t.Start()); - - var allTaskResults = await Task.WhenAll(renewalTasks); - - return allTaskResults.ToList(); + return (await Task.WhenAll(renewalTasks)).ToList(); } else { @@ -264,42 +249,42 @@ public static async Task> PerformRenewAll( /// private static List GetAccountsWithRequiredCAFeatures(ManagedCertificate item, string defaultCA, ICollection certificateAuthorities, List accounts) { - var requiredCAFeatures = new List(); + var requiredCaFeatures = new List(); var identifiers = item.GetCertificateIdentifiers(); if (identifiers.Any(i => i.IdentifierType == CertIdentifierType.Dns && i.Value.StartsWith("*"))) { - requiredCAFeatures.Add(CertAuthoritySupportedRequests.DOMAIN_WILDCARD); + requiredCaFeatures.Add(CertAuthoritySupportedRequests.DOMAIN_WILDCARD); } if (identifiers.Count(i => i.IdentifierType == CertIdentifierType.Dns) == 1) { - requiredCAFeatures.Add(CertAuthoritySupportedRequests.DOMAIN_SINGLE); + requiredCaFeatures.Add(CertAuthoritySupportedRequests.DOMAIN_SINGLE); } if (identifiers.Count(i => i.IdentifierType == CertIdentifierType.Dns) > 2) { - requiredCAFeatures.Add(CertAuthoritySupportedRequests.DOMAIN_MULTIPLE_SAN); + requiredCaFeatures.Add(CertAuthoritySupportedRequests.DOMAIN_MULTIPLE_SAN); } - if (identifiers.Any(i => i.IdentifierType == CertIdentifierType.Ip)) + if (identifiers.Count(i => i.IdentifierType == CertIdentifierType.Ip) == 1) { - requiredCAFeatures.Add(CertAuthoritySupportedRequests.IP_SINGLE); + requiredCaFeatures.Add(CertAuthoritySupportedRequests.IP_SINGLE); } if (identifiers.Count(i => i.IdentifierType == CertIdentifierType.Ip) > 1) { - requiredCAFeatures.Add(CertAuthoritySupportedRequests.IP_MULTIPLE); + requiredCaFeatures.Add(CertAuthoritySupportedRequests.IP_MULTIPLE); } if (identifiers.Any(i => i.IdentifierType == CertIdentifierType.TnAuthList)) { - requiredCAFeatures.Add(CertAuthoritySupportedRequests.TNAUTHLIST); + requiredCaFeatures.Add(CertAuthoritySupportedRequests.TNAUTHLIST); } if (item.RequestConfig.PreferredExpiryDays > 0) { - requiredCAFeatures.Add(CertAuthoritySupportedRequests.OPTIONAL_LIFETIME_DAYS); + requiredCaFeatures.Add(CertAuthoritySupportedRequests.OPTIONAL_LIFETIME_DAYS); } var fallbackCandidateAccounts = accounts.Where(a => a.CertificateAuthorityId != defaultCA && a.IsStagingAccount == item.UseStagingMode); @@ -310,7 +295,7 @@ private static List GetAccountsWithRequiredCAFeatures(ManagedCer // select a candidate based on features required by the certificate. If a CA has no known features we assume it supports all the ones we might be interested in foreach (var ca in certificateAuthorities) { - if (!ca.SupportedFeatures.Any() || requiredCAFeatures.All(r => ca.SupportedFeatures.Contains(r.ToString()))) + if (!ca.SupportedFeatures.Any() || requiredCaFeatures.All(r => ca.SupportedFeatures.Contains(r.ToString()))) { fallbackAccounts.AddRange(fallbackCandidateAccounts.Where(f => f.CertificateAuthorityId == ca.Id)); } @@ -345,7 +330,7 @@ public static AccountDetails SelectCAWithFailover(ICollection f.CertificateAuthorityId != item.LastAttemptedCA && f.CertificateAuthorityId != defaultMatchingAccount?.CertificateAuthorityId); + var nextFallback = fallbackAccounts.FirstOrDefault(f => f.CertificateAuthorityId != item.LastAttemptedCA); if (nextFallback != null) { diff --git a/src/Certify.Core/Management/Servers/ServerProviderIIS.cs b/src/Certify.Core/Management/Servers/ServerProviderIIS.cs index b7c45a993..bf30e946f 100644 --- a/src/Certify.Core/Management/Servers/ServerProviderIIS.cs +++ b/src/Certify.Core/Management/Servers/ServerProviderIIS.cs @@ -23,7 +23,7 @@ public class ServerProviderIIS : ITargetWebServer /// /// We use a lock on any method that uses CommitChanges, to avoid writing changes at the same time /// - private static readonly object _iisAPILock = new object(); + private static readonly Lock _iisAPILock = LockFactory.Create(); private ILog _log; @@ -54,6 +54,11 @@ public IBindingDeploymentTarget GetDeploymentTarget() public Task IsAvailable() { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return Task.FromResult(false); + } + if (!_isIISAvailable) { try diff --git a/src/Certify.Core/Management/SettingsManager.cs b/src/Certify.Core/Management/SettingsManager.cs index 3425fadb2..326550521 100644 --- a/src/Certify.Core/Management/SettingsManager.cs +++ b/src/Certify.Core/Management/SettingsManager.cs @@ -8,7 +8,7 @@ namespace Certify.Management public sealed class CoreAppSettings { private static volatile CoreAppSettings instance; - private static object syncRoot = new object(); + private static Lock syncRoot = LockFactory.Create(); private CoreAppSettings() { @@ -19,7 +19,6 @@ private CoreAppSettings() IgnoreStoppedSites = true; EnableValidationProxyAPI = true; EnableAppTelematics = true; - EnableEFS = false; EnableDNSValidationChecks = false; RenewalIntervalDays = 75; RenewalIntervalMode = RenewalIntervalModes.PercentageLifetime; @@ -76,8 +75,6 @@ public static CoreAppSettings Current public bool EnableValidationProxyAPI { get; set; } - public bool EnableEFS { get; set; } - public bool EnableDNSValidationChecks { get; set; } /// @@ -183,12 +180,17 @@ public static CoreAppSettings Current /// public bool PerformChallengeCleanupsLast { get; set; } public string CurrentServiceVersion { get; set; } + + /// + /// if true, additional management hub features and data stores may be enabled + /// + public bool IsManagementHubService { get; set; } } public class SettingsManager { private const string COREAPPSETTINGSFILE = "appsettings.json"; - private static Object settingsLocker = new Object(); + private static Lock settingsLocker = LockFactory.Create(); public static bool FromPreferences(Models.Preferences prefs) { @@ -199,7 +201,6 @@ public static bool FromPreferences(Models.Preferences prefs) CoreAppSettings.Current.MaxRenewalRequests = prefs.MaxRenewalRequests; CoreAppSettings.Current.RenewalIntervalMode = prefs.RenewalIntervalMode; CoreAppSettings.Current.RenewalIntervalDays = prefs.RenewalIntervalDays; - CoreAppSettings.Current.EnableEFS = prefs.EnableEFS; CoreAppSettings.Current.IsInstanceRegistered = prefs.IsInstanceRegistered; CoreAppSettings.Current.Language = prefs.Language; CoreAppSettings.Current.EnableHttpChallengeServer = prefs.EnableHttpChallengeServer; @@ -256,7 +257,6 @@ public static Models.Preferences ToPreferences() MaxRenewalRequests = CoreAppSettings.Current.MaxRenewalRequests, RenewalIntervalMode = CoreAppSettings.Current.RenewalIntervalMode, RenewalIntervalDays = CoreAppSettings.Current.RenewalIntervalDays, - EnableEFS = CoreAppSettings.Current.EnableEFS, InstanceId = CoreAppSettings.Current.InstanceId, IsInstanceRegistered = CoreAppSettings.Current.IsInstanceRegistered, Language = CoreAppSettings.Current.Language, diff --git a/src/Certify.Locales/Certify.Locales.csproj b/src/Certify.Locales/Certify.Locales.csproj index c0c1ff290..9acc6809c 100644 --- a/src/Certify.Locales/Certify.Locales.csproj +++ b/src/Certify.Locales/Certify.Locales.csproj @@ -1,8 +1,8 @@  - netstandard2.0 + netstandard2.0;net9.0 AnyCPU - true + False Certify Certificate Manager UI Resources diff --git a/src/Certify.Locales/SR.Designer.cs b/src/Certify.Locales/SR.Designer.cs index cc8630df7..33e33a1bf 100644 --- a/src/Certify.Locales/SR.Designer.cs +++ b/src/Certify.Locales/SR.Designer.cs @@ -141,6 +141,51 @@ public static string AboutControl_TrialDetailLabel { } } + /// + /// Looks up a localized string similar to To proceed, confirm that you agree to the current terms and conditions for this Certificate Authority.. + /// + public static string Account_Edit_AgreeConditions { + get { + return ResourceManager.GetString("Account_Edit_AgreeConditions", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Yes, I Agree. + /// + public static string Account_Edit_AgreeConfirm { + get { + return ResourceManager.GetString("Account_Edit_AgreeConfirm", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to To request certificates you need to register with each of the Certificate Authorities that you want to use.. + /// + public static string Account_Edit_Intro { + get { + return ResourceManager.GetString("Account_Edit_Intro", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The email address provided may be used to notify you of upcoming certificate renewals if required. Invalid email addresses will be rejected by the Certificate Authority.. + /// + public static string Account_Edit_Intro2 { + get { + return ResourceManager.GetString("Account_Edit_Intro2", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Edit ACME Account. + /// + public static string Account_Edit_SectionTitle { + get { + return ResourceManager.GetString("Account_Edit_SectionTitle", resourceCulture); + } + } + /// /// Looks up a localized string similar to Advanced. /// @@ -294,6 +339,24 @@ public static string DiscardChanges { } } + /// + /// Looks up a localized string similar to (Url for the production directory endpoint). + /// + public static string EditCertificateAuthority_ProductionDirectoryHelp { + get { + return ResourceManager.GetString("EditCertificateAuthority_ProductionDirectoryHelp", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to (Display Name for the Certificate Authority). + /// + public static string EditCertificateAuthority_TitleHelp { + get { + return ResourceManager.GetString("EditCertificateAuthority_TitleHelp", resourceCulture); + } + } + /// /// Looks up a localized string similar to Email Address. /// @@ -529,7 +592,7 @@ public static string MainWindow_KeyExpired { } /// - /// Looks up a localized string similar to This will renew certificates for all auto-renewed items. Proceed?. + /// Looks up a localized string similar to This will renew certificates for all auto-renewed items, if applicable. Proceed?. /// public static string MainWindow_RenewAllConfirm { get { @@ -582,6 +645,15 @@ public static string Managed_Sites { } } + /// + /// Looks up a localized string similar to Use Staging Mode (Test Certificates). + /// + public static string ManagedCertificate_CertificateAuthority_UseTestCertificates { + get { + return ResourceManager.GetString("ManagedCertificate_CertificateAuthority_UseTestCertificates", resourceCulture); + } + } + /// /// Looks up a localized string similar to Deployment of your certificate can be automatic or you can perform your own deployment tasks (see the Tasks tab).. /// @@ -998,7 +1070,7 @@ public static string ManagedCertificateSettings_NameRequired { } /// - /// Looks up a localized string similar to A Primary Domain must be included. + /// Looks up a localized string similar to A Primary Domain/identifier must be included. /// public static string ManagedCertificateSettings_NeedPrimaryDomain { get { @@ -1268,33 +1340,6 @@ public static string New_Contact_EmailError { } } - /// - /// Looks up a localized string similar to To proceed, confirm that you agree to the current terms and conditions for this Certificate Authority.. - /// - public static string New_Contact_NeedAgree { - get { - return ResourceManager.GetString("New_Contact_NeedAgree", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to To request certificates you need to register with each of the Certificate Authorities that you want to use.. - /// - public static string New_Contact_Tip1 { - get { - return ResourceManager.GetString("New_Contact_Tip1", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to The email address provided may be used to notify you of upcoming certificate renewals if required. Invalid email addresses will be rejected by the Certificate Authority.. - /// - public static string New_Contact_Tip2 { - get { - return ResourceManager.GetString("New_Contact_Tip2", resourceCulture); - } - } - /// /// Looks up a localized string similar to OK. /// @@ -1619,6 +1664,24 @@ public static string Settings_AutoRenewalRequestLimit { } } + /// + /// Looks up a localized string similar to If you register with multiple authorities this may enable you to use automatic Certificate Authority Failover, so if your preferred Certificate Authority can't issue a new certificate an alternative compatible provider can be used automatically.. + /// + public static string Settings_CA_Fallback { + get { + return ResourceManager.GetString("Settings_CA_Fallback", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Certificate Authorities are the organisations who can issue trusted certificates. You need to register an account for each (ACME) Certificate Authority you wish to use. Accounts can either be Production (live, trusted certificates) or Staging (test, non-trusted).. + /// + public static string Settings_CA_Intro { + get { + return ResourceManager.GetString("Settings_CA_Intro", resourceCulture); + } + } + /// /// Looks up a localized string similar to Check for updates automatically. /// @@ -1646,15 +1709,6 @@ public static string Settings_EnableDnsValidation { } } - /// - /// Looks up a localized string similar to Enable Encrypted File System (EFS) for sensitive files. This option does not work on all versions of Windows.. - /// - public static string Settings_EnableEFS { - get { - return ResourceManager.GetString("Settings_EnableEFS", resourceCulture); - } - } - /// /// Looks up a localized string similar to Enable Http Challenge Server. /// @@ -1691,6 +1745,15 @@ public static string Settings_EnableTelemetry { } } + /// + /// Looks up a localized string similar to You can create an export file to bundle all of the related settings and file for this instance together. Note: sensitive content is encrypted but you should not share this file with untrusted sources or use unsecured storage.. + /// + public static string Settings_Export_Intro { + get { + return ResourceManager.GetString("Settings_Export_Intro", resourceCulture); + } + } + /// /// Looks up a localized string similar to Ignore stopped IIS sites for new certificates and renewals. /// @@ -1789,14 +1852,5 @@ public static string ValidateKey { return ResourceManager.GetString("ValidateKey", resourceCulture); } } - - /// - /// Looks up a localized string similar to Yes, I Agree. - /// - public static string Yes_I_Agree { - get { - return ResourceManager.GetString("Yes_I_Agree", resourceCulture); - } - } } } diff --git a/src/Certify.Locales/SR.es-ES.resx b/src/Certify.Locales/SR.es-ES.resx index 4a11aa2f5..3d50cced8 100644 --- a/src/Certify.Locales/SR.es-ES.resx +++ b/src/Certify.Locales/SR.es-ES.resx @@ -1,4 +1,4 @@ - + + + + + + + + + + + + all + + + all + + + + diff --git a/src/Certify.Server/Certify.Server.HubService/Certify.Server.HubService.http b/src/Certify.Server/Certify.Server.HubService/Certify.Server.HubService.http new file mode 100644 index 000000000..47e347dc4 --- /dev/null +++ b/src/Certify.Server/Certify.Server.HubService/Certify.Server.HubService.http @@ -0,0 +1,6 @@ +@Certify.Server.HubService_HostAddress = https://localhost:7187 + +GET {{Certify.Server.HubService_HostAddress}}/api/v1/health/ +Accept: application/json + +### diff --git a/src/Certify.Server/Certify.Server.HubService/Program.cs b/src/Certify.Server/Certify.Server.HubService/Program.cs new file mode 100644 index 000000000..03ba26e22 --- /dev/null +++ b/src/Certify.Server/Certify.Server.HubService/Program.cs @@ -0,0 +1,131 @@ +using Certify.Client; +using Certify.Management; +using Certify.Server.Hub.Api.Middleware; +using Certify.Server.Hub.Api.Services; +using Certify.Server.Hub.Api.SignalR; +using Certify.Server.Hub.Api.SignalR.ManagementHub; +using Certify.Server.HubService.Services; +using Microsoft.AspNetCore.Mvc.ApplicationParts; +using Microsoft.AspNetCore.SignalR; +using Microsoft.AspNetCore.StaticFiles; +using Serilog; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); + +// Add services to the container + +var assembly = typeof(Certify.Server.Hub.Api.Startup).Assembly; +var part = new AssemblyPart(assembly); + +builder.Services + .AddMemoryCache() + .AddTokenAuthentication(builder.Configuration) + .AddAuthorization() + .AddControllers() + .ConfigureApplicationPartManager(apm => apm.ApplicationParts.Add(part)); + +builder.Services + .AddRouting(r => r.LowercaseUrls = true) + .AddSignalR(opt => opt.MaximumReceiveMessageSize = null) + .AddMessagePackProtocol(); + +builder.Services.AddDataProtection(a => +{ + a.ApplicationDiscriminator = "certify"; +}); + +builder.Services.AddResponseCompression(); + +// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi +builder.Services.AddOpenApi(); + +builder.Services.AddLogging(loggingBuilder => + loggingBuilder.AddSerilog(dispose: true)); + +// setup public/hub api +builder.Services.AddSingleton(); + +builder.Services.AddTransient(typeof(ICertifyInternalApiClient), typeof(CertifyHubService)); + +// setup server core +builder.Services.AddSingleton(); + +builder.Services.AddTransient(); + +// used to directly talk back to the management server process instead of connecting back via SignalR +builder.Services.AddTransient(); +builder.Services.AddTransient(); + +builder.Services.AddHostedService(); + +// build app + +var app = builder.Build(); + +app.MapDefaultEndpoints(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.MapOpenApi(); +} + +app.UseHttpsRedirection(); + +// serve static files from wwwroot +app.UseDefaultFiles(); +// Set up custom content types - associating file extension to MIME type +var provider = new FileExtensionContentTypeProvider(); +// Add new mappings +provider.Mappings[".dat"] = "application/octet-stream"; +provider.Mappings[".dll"] = "application/octet-stream"; +provider.Mappings[".image"] = "image/png"; + +app.UseStaticFiles(new StaticFileOptions +{ + ContentTypeProvider = provider +}); + +// configure CROS +app.UseCors((p) => +{ + p.AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader(); +}); + +app.UseAuthentication(); +app.UseAuthorization(); + +app.MapControllers(); + +app.MapHub("/api/internal/status"); +app.MapHub("/api/internal/managementhub"); + +app.MapDefaultControllerRoute().WithStaticAssets(); +app.UseResponseCompression(); + +var statusHubContext = app.Services.GetRequiredService>(); + +if (statusHubContext == null) +{ + throw new Exception("Status Hub not registered"); +} + +// setup signalr message forwarding, message received from internal service will be resent to our connected clients via our own SignalR hub +var statusReporting = new UserInterfaceStatusHubReporting(statusHubContext); + +// wire up internal service to our hub + +var certifyManager = app.Services.GetRequiredService(); +await certifyManager.Init(); + +var directServerClient = app.Services.GetRequiredService(); +certifyManager.SetDirectManagementClient(directServerClient); + +app.Start(); + +System.Diagnostics.Debug.WriteLine($"Server started {string.Join(";", app.Urls)}"); +app.WaitForShutdown(); diff --git a/src/Certify.Server/Certify.Server.HubService/Properties/launchSettings.json b/src/Certify.Server/Certify.Server.HubService/Properties/launchSettings.json new file mode 100644 index 000000000..67a19738d --- /dev/null +++ b/src/Certify.Server/Certify.Server.HubService/Properties/launchSettings.json @@ -0,0 +1,35 @@ +{ + "profiles": { + "http": { + "commandName": "Project", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "dotnetRunMessages": true, + "applicationUrl": "http://localhost:5028", + "CERTIFY_MANAGEMENT_HUB": "http://localhost:5028/api/internal/managementhub", + "CERTIFY_ENABLE_MANAGEMENT_HUB": "true" + }, + "https": { + "commandName": "Project", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "dotnetRunMessages": true, + "applicationUrl": "https://localhost:7187;http://localhost:5028", + "CERTIFY_MANAGEMENT_HUB": "https://localhost:7187/api/internal/managementhub", + "CERTIFY_ENABLE_MANAGEMENT_HUB": "true" + }, + "Container (.NET SDK)": { + "commandName": "SdkContainer", + "launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}", + "environmentVariables": { + "ASPNETCORE_HTTPS_PORTS": "8081", + "ASPNETCORE_HTTP_PORTS": "8080" + }, + "publishAllPorts": true, + "useSSL": true + } + }, + "$schema": "https://json.schemastore.org/launchsettings.json" +} diff --git a/src/Certify.Server/Certify.Server.HubService/Services/CertifyHubService.cs b/src/Certify.Server/Certify.Server.HubService/Services/CertifyHubService.cs new file mode 100644 index 000000000..969e61188 --- /dev/null +++ b/src/Certify.Server/Certify.Server.HubService/Services/CertifyHubService.cs @@ -0,0 +1,130 @@ +using Certify.Client; +using Certify.Config; +using Certify.Management; +using Certify.Models; +using Certify.Models.Config; +using Certify.Models.Config.Migration; +using Certify.Models.Hub; +using Certify.Models.Providers; +using Certify.Models.Reporting; +using Certify.Models.Utils; +using Certify.Shared; +using Microsoft.AspNetCore.DataProtection; +using ServiceControllers = Certify.Service.Controllers; + +namespace Certify.Server.HubService.Services +{ + /// + /// The HubService is a surrogate for the Certify Server Core Service, Service API and Client. The Hub hosts a Certify Server Core instead of talking to a Service instance over http, skipping a layer of abstraction and a communication layer. + /// A further layer of abstraction can be skipped by implementing all controller logic in Certify.Core and using that directly + /// + public class CertifyHubService : ICertifyInternalApiClient + { + private ICertifyManager _certifyManager; + private IDataProtectionProvider _dataProtectionProvider; + public CertifyHubService(ICertifyManager certifyManager, IDataProtectionProvider dataProtectionProvider) + { + _certifyManager = certifyManager; + _dataProtectionProvider = dataProtectionProvider; + } + + private ServiceControllers.AccessController _accessController(AuthContext authContext) + { + var controller = new ServiceControllers.AccessController(_certifyManager, _dataProtectionProvider); + controller.SetCurrentAuthContext(authContext); + return controller; + } + + private ServiceControllers.ManagedChallengeController _managedChallengeController(AuthContext authContext) + { + var controller = new ServiceControllers.ManagedChallengeController(_certifyManager); + controller.SetCurrentAuthContext(authContext); + return controller; + } + + public Task GetPreferences(AuthContext authContext = null) => Task.FromResult(new ServiceControllers.PreferencesController(_certifyManager).GetPreferences()); + public Task AddSecurityPrinciple(SecurityPrinciple principle, AuthContext authContext) => _accessController(authContext).AddSecurityPrinciple(principle); + public Task CheckSecurityPrincipleHasAccess(AccessCheck check, AuthContext authContext) => _accessController(authContext).CheckSecurityPrincipleHasAccess(check); + public Task> GetSecurityPrincipleAssignedRoles(string id, AuthContext authContext) => _accessController(authContext).GetSecurityPrincipleAssignedRoles(id); + public Task GetSecurityPrincipleRoleStatus(string id, AuthContext authContext) => _accessController(authContext).GetSecurityPrincipleRoleStatus(id); + public Task> GetSecurityPrinciples(AuthContext authContext) => _accessController(authContext).GetSecurityPrinciples(); + public Task AddAssignedAccessToken(AssignedAccessToken token, AuthContext authContext) => _accessController(authContext).AddAssignedccessToken(token); + public Task CheckApiTokenHasAccess(AccessToken token, AccessCheck check, AuthContext authContext = null) => _accessController(authContext).CheckApiTokenHasAccess(new AccessTokenCheck { Check = check, Token = token }); + public Task> GetAssignedAccessTokens(AuthContext authContext) => _accessController(authContext).GetAssignedAccessTokens(); + public Task RemoveSecurityPrinciple(string id, AuthContext authContext) => _accessController(authContext).DeleteSecurityPrinciple(id); + public Task UpdateSecurityPrinciple(SecurityPrinciple principle, AuthContext authContext) => _accessController(authContext).UpdateSecurityPrinciple(principle); + public Task UpdateSecurityPrincipleAssignedRoles(SecurityPrincipleAssignedRoleUpdate update, AuthContext authContext) => _accessController(authContext).UpdateSecurityPrincipleAssignedRoles(update); + public Task UpdateSecurityPrinciplePassword(SecurityPrinciplePasswordUpdate passwordUpdate, AuthContext authContext) => _accessController(authContext).UpdatePassword(passwordUpdate); + public Task ValidateSecurityPrinciplePassword(SecurityPrinciplePasswordCheck passwordCheck, AuthContext authContext) => _accessController(authContext).Validate(passwordCheck); + public Task> GetAccessRoles(AuthContext authContext) => _accessController(authContext).GetRoles(); + public Task> GetManagedChallenges(AuthContext authContext) => _managedChallengeController(authContext).Get(); + public Task UpdateManagedChallenge(ManagedChallenge update, AuthContext authContext) => _managedChallengeController(authContext).Update(update); + public Task CleanupManagedChallenge(ManagedChallengeRequest request, AuthContext authContext) => _managedChallengeController(authContext).CleanupChallengeResponse(request); + + public Task AddAccount(ContactRegistration contact, AuthContext authContext = null) => throw new NotImplementedException(); + public Task> BeginAutoRenewal(RenewalSettings settings, AuthContext authContext = null) => throw new NotImplementedException(); + public Task BeginCertificateRequest(string managedItemId, bool resumePaused, bool isInteractive, AuthContext authContext = null) => throw new NotImplementedException(); + public Task ChangeAccountKey(string storageKey, string newKeyPEM = null, AuthContext authContext = null) => throw new NotImplementedException(); + + public Task CheckForUpdates(AuthContext authContext = null) => throw new NotImplementedException(); + + public Task> CopyDataStore(string sourceId, string targetId, AuthContext authContext = null) => throw new NotImplementedException(); + public Task DeleteCertificateAuthority(string id, AuthContext authContext = null) => throw new NotImplementedException(); + public Task DeleteCredential(string credentialKey, AuthContext authContext = null) => throw new NotImplementedException(); + public Task DeleteManagedCertificate(string managedItemId, AuthContext authContext = null) => throw new NotImplementedException(); + + public Task> GetAccounts(AuthContext authContext = null) => throw new NotImplementedException(); + public Task GetAppVersion(AuthContext authContext = null) => Task.FromResult(new ServiceControllers.SystemController(_certifyManager).GetAppVersion()); + + public Task> GetCertificateAuthorities(AuthContext authContext = null) => throw new NotImplementedException(); + public Task> GetChallengeAPIList(AuthContext authContext = null) => throw new NotImplementedException(); + public Task> GetCredentials(AuthContext authContext = null) => throw new NotImplementedException(); + public Task> GetCurrentChallenges(string type, string key, AuthContext authContext = null) => throw new NotImplementedException(); + public Task> GetDataStoreConnections(AuthContext authContext = null) => throw new NotImplementedException(); + public Task> GetDataStoreProviders(AuthContext authContext = null) => throw new NotImplementedException(); + public Task GetDeploymentProviderDefinition(string id, DeploymentTaskConfig config, AuthContext authContext = null) => throw new NotImplementedException(); + public Task> GetDeploymentProviderList(AuthContext authContext = null) => throw new NotImplementedException(); + public Task> GetDnsProviderZones(string providerTypeId, string credentialId, AuthContext authContext = null) => throw new NotImplementedException(); + public Task GetItemLog(string id, int limit, AuthContext authContext = null) => throw new NotImplementedException(); + public Task GetManagedCertificate(string managedItemId, AuthContext authContext = null) => throw new NotImplementedException(); + public Task> GetManagedCertificates(ManagedCertificateFilter filter, AuthContext authContext = null) => throw new NotImplementedException(); + public Task GetManagedCertificateSearchResult(ManagedCertificateFilter filter, AuthContext authContext = null) => throw new NotImplementedException(); + public Task GetManagedCertificateSummary(ManagedCertificateFilter filter, AuthContext authContext = null) => throw new NotImplementedException(); + + + public Task> GetServerSiteDomains(StandardServerTypes serverType, string serverSiteId, AuthContext authContext = null) => throw new NotImplementedException(); + public Task> GetServerSiteList(StandardServerTypes serverType, string itemId = null, AuthContext authContext = null) => throw new NotImplementedException(); + public Task GetServerVersion(StandardServerTypes serverType, AuthContext authContext = null) => throw new NotImplementedException(); + public Task IsServerAvailable(StandardServerTypes serverType, AuthContext authContext = null) => throw new NotImplementedException(); + public Task> PerformChallengeCleanup(ManagedCertificate site, AuthContext authContext = null) => throw new NotImplementedException(); + public Task> PerformDeployment(string managedCertificateId, string taskId, bool isPreviewOnly, bool forceTaskExecute, AuthContext authContext = null) => throw new NotImplementedException(); + public Task PerformExport(ExportRequest exportRequest, AuthContext authContext) => throw new NotImplementedException(); + public Task> PerformImport(ImportRequest importRequest, AuthContext authContext) => throw new NotImplementedException(); + public Task> PerformManagedCertMaintenance(string id = null, AuthContext authContext = null) => throw new NotImplementedException(); + public Task PerformManagedChallenge(ManagedChallengeRequest request, AuthContext authContext) => throw new NotImplementedException(); + public Task> PerformServiceDiagnostics(AuthContext authContext = null) => throw new NotImplementedException(); + public Task> PreviewActions(ManagedCertificate site, AuthContext authContext = null) => throw new NotImplementedException(); + public Task ReapplyCertificateBindings(string managedItemId, bool isPreviewOnly, bool includeDeploymentTasks, AuthContext authContext = null) => throw new NotImplementedException(); + public Task> RedeployManagedCertificates(bool isPreviewOnly, bool includeDeploymentTasks, AuthContext authContext = null) => throw new NotImplementedException(); + public Task RefetchCertificate(string managedItemId, AuthContext authContext = null) => throw new NotImplementedException(); + public Task RemoveAccount(string storageKey, bool deactivate, AuthContext authContext = null) => throw new NotImplementedException(); + public Task RemoveManagedChallenge(string id, AuthContext authContext) => throw new NotImplementedException(); + + public Task RevokeManageSiteCertificate(string managedItemId, AuthContext authContext = null) => throw new NotImplementedException(); + public Task> RunConfigurationDiagnostics(StandardServerTypes serverType, string serverSiteId, AuthContext authContext = null) => throw new NotImplementedException(); + public Task> SetDefaultDataStore(string dataStoreId, AuthContext authContext = null) => throw new NotImplementedException(); + public Task SetPreferences(Preferences preferences, AuthContext authContext = null) => throw new NotImplementedException(); + public Task> TestChallengeConfiguration(ManagedCertificate site, AuthContext authContext = null) => throw new NotImplementedException(); + public Task TestCredentials(string credentialKey, AuthContext authContext = null) => throw new NotImplementedException(); + public Task> TestDataStoreConnection(DataStoreConnection dataStoreConnection, AuthContext authContext = null) => throw new NotImplementedException(); + public Task UpdateAccountContact(ContactRegistration contact, AuthContext authContext = null) => throw new NotImplementedException(); + public Task UpdateCertificateAuthority(CertificateAuthority ca, AuthContext authContext = null) => throw new NotImplementedException(); + public Task UpdateCredentials(StoredCredential credential, AuthContext authContext = null) => throw new NotImplementedException(); + public Task> UpdateDataStoreConnection(DataStoreConnection dataStoreConnection, AuthContext authContext = null) => throw new NotImplementedException(); + public Task UpdateManagedCertificate(ManagedCertificate site, AuthContext authContext = null) => throw new NotImplementedException(); + + public Task UpdateManagementHub(string url, string joiningKey, AuthContext authContext = null) => throw new NotImplementedException(); + public Task> ValidateDeploymentTask(DeploymentTaskValidationInfo info, AuthContext authContext = null) => throw new NotImplementedException(); + + } +} diff --git a/src/Certify.Server/Certify.Server.HubService/Services/DirectInstanceManagementHub.cs b/src/Certify.Server/Certify.Server.HubService/Services/DirectInstanceManagementHub.cs new file mode 100644 index 000000000..bb5d1d323 --- /dev/null +++ b/src/Certify.Server/Certify.Server.HubService/Services/DirectInstanceManagementHub.cs @@ -0,0 +1,181 @@ +using Certify.Management; +using Certify.Models.Hub; +using Certify.Models.Reporting; +using Certify.Server.Hub.Api.SignalR.ManagementHub; + +namespace Certify.Server.HubService.Services +{ + public class DirectInstanceManagementHub : IInstanceManagementHub + { + private IInstanceManagementStateProvider _stateProvider; + private ILogger _logger; + private ICertifyManager _certifyManager; + public DirectInstanceManagementHub(ILogger logger, IInstanceManagementStateProvider stateProvider, ICertifyManager certifyManager) + { + _stateProvider = stateProvider; + _logger = logger; + _certifyManager = certifyManager; + } + + /// + /// Receive results from a previously issued command + /// + /// + /// + public Task ReceiveCommandResult(InstanceCommandResult result) + { + + result.Received = DateTimeOffset.Now; + + // check we are awaiting this result + var cmd = _stateProvider.GetAwaitedCommandRequest(result.CommandId); + + if (cmd == null && !result.IsCommandResponse) + { + // message was not requested and has been sent by the instance (e.g. heartbeat) + cmd = new InstanceCommandRequest { CommandId = result.CommandId, CommandType = result.CommandType }; + } + + if (cmd != null) + { + _stateProvider.RemoveAwaitedCommandRequest(cmd.CommandId); + + if (cmd.CommandType == ManagementHubCommands.GetInstanceInfo) + { + var instanceInfo = System.Text.Json.JsonSerializer.Deserialize(result.Value); + + if (instanceInfo != null) + { + + instanceInfo.LastReported = DateTimeOffset.Now; + _stateProvider.UpdateInstanceConnectionInfo("internal", instanceInfo); + + _logger?.LogInformation("Received instance {instanceId} {instanceTitle} for mgmt hub connection.", instanceInfo.InstanceId, instanceInfo.Title); + + // if we don't yet have any managed items for this instance, ask for them + if (!_stateProvider.HasItemsForManagedInstance(instanceInfo.InstanceId)) + { + var request = new InstanceCommandRequest + { + CommandId = Guid.NewGuid(), + CommandType = ManagementHubCommands.GetManagedItems + }; + + IssueCommand(request); + } + + // if we dont have a status summary, ask for that + if (!_stateProvider.HasStatusSummaryForManagedInstance(instanceInfo.InstanceId)) + { + var request = new InstanceCommandRequest + { + CommandId = Guid.NewGuid(), + CommandType = ManagementHubCommands.GetStatusSummary + }; + + IssueCommand(request); + } + } + } + else + { + // for all other command results we need to resolve which instance id we are communicating with + var instanceId = _stateProvider.GetInstanceIdForConnection("internal"); + result.InstanceId = instanceId; + + if (!string.IsNullOrWhiteSpace(instanceId)) + { + // action this message from this instance + _logger?.LogInformation("Received instance command result {result}", result.CommandType); + + if (cmd.CommandType == ManagementHubCommands.GetManagedItems) + { + // got items from an instance + var val = System.Text.Json.JsonSerializer.Deserialize(result.Value); + + _stateProvider.UpdateInstanceItemInfo(instanceId, val.Items); + } + else if (cmd.CommandType == ManagementHubCommands.GetStatusSummary && result?.Value != null) + { + // got status summary + var val = System.Text.Json.JsonSerializer.Deserialize(result.Value); + + _stateProvider.UpdateInstanceStatusSummary(instanceId, val); + } + else + { + // store for something else to consume + if (result.IsCommandResponse) + { + _stateProvider.AddAwaitedCommandResult(result); + } + else + { + // item was not requested, queue for processing + if (result.CommandType == ManagementHubCommands.NotificationUpdatedManagedItem) + { + //_uiStatusHub.Clients.All.SendAsync(Providers.StatusHubMessages.SendManagedCertificateUpdateMsg, System.Text.Json.JsonSerializer.Deserialize(result.Value)); + } + else if (result.CommandType == ManagementHubCommands.NotificationManagedItemRequestProgress) + { + //_uiStatusHub.Clients.All.SendAsync(Providers.StatusHubMessages.SendProgressStateMsg, System.Text.Json.JsonSerializer.Deserialize(result.Value)); + } + else if (result.CommandType == ManagementHubCommands.NotificationRemovedManagedItem) + { + // deleted :TODO + //_uiStatusHub.Clients.All.SendAsync(Providers.StatusHubMessages.SendMsg, $"Deleted item {result.Value}"); + } + } + } + } + else + { + _logger?.LogError("Received instance command result for an unknown instance {result}", result.CommandType); + } + } + } + + return Task.CompletedTask; + } + + public Task ReceiveInstanceMessage(InstanceMessage message) + { + + var instanceId = _stateProvider.GetInstanceIdForConnection("internal"); + if (instanceId != null) + { + // action this message from this instance + _logger?.LogInformation("Received instance message {msg}", message); + } + else + { + _logger?.LogError("Received instance command result for an unknown instance {msg}", message); + } + + return Task.CompletedTask; + } + + public Task SendCommandRequest(InstanceCommandRequest cmd) + { + IssueCommand(cmd); + + return Task.CompletedTask; + } + + private async void IssueCommand(InstanceCommandRequest cmd) + { + _stateProvider.AddAwaitedCommandRequest(cmd); + + // + var result = await _certifyManager.PerformHubCommandWithResult(cmd); + if (result.IsCommandResponse) + { + result.CommandType = cmd.CommandType; + result.CommandId = cmd.CommandId; + result.InstanceId = _stateProvider.GetManagementHubInstanceId(); + + await ReceiveCommandResult(result); + } + } + } +} diff --git a/src/Certify.Server/Certify.Server.HubService/Services/DirectManagementServerClient.cs b/src/Certify.Server/Certify.Server.HubService/Services/DirectManagementServerClient.cs new file mode 100644 index 000000000..fbc3cfb7d --- /dev/null +++ b/src/Certify.Server/Certify.Server.HubService/Services/DirectManagementServerClient.cs @@ -0,0 +1,54 @@ +using Certify.Client; +using Certify.Management; +using Certify.Models.Hub; +using Certify.Server.Hub.Api.SignalR.ManagementHub; + +namespace Certify.Server.HubService.Services +{ + public class DirectManagementServerClient : Client.IManagementServerClient + { + public event Action OnConnectionClosed; + public event Action OnConnectionReconnected; + public event Action OnConnectionReconnecting; + public event Func> OnGetCommandResult; + public event Func OnGetInstanceItems; + + private ICertifyManager _certifyManager; + private IInstanceManagementHub _managementHub; + + private ManagedInstanceInfo _instanceInfo; + public DirectManagementServerClient(ICertifyManager certifyManager, IServiceProvider serviceProvider, IInstanceManagementHub instanceManagementHub) + { + _certifyManager = certifyManager; + _managementHub = instanceManagementHub; + _instanceInfo = certifyManager.GetManagedInstanceInfo(); + } + + Task IManagementServerClient.ConnectAsync() => Task.CompletedTask; + Task IManagementServerClient.Disconnect() => throw new NotImplementedException(); + bool IManagementServerClient.IsConnected() => true; + void IManagementServerClient.SendInstanceInfo(Guid commandId, bool isCommandResponse) + { + System.Diagnostics.Debug.WriteLine("SendInstanceInfo"); + + // send this clients instance ID back to the hub to identify it in the connection: should send a shared secret before this to confirm this client knows and is not impersonating another instance + var result = new InstanceCommandResult + { + CommandId = commandId, + InstanceId = _instanceInfo.InstanceId, + CommandType = ManagementHubCommands.GetInstanceInfo, + Value = System.Text.Json.JsonSerializer.Serialize(_instanceInfo), + IsCommandResponse = isCommandResponse + }; + + result.ObjectValue = _instanceInfo; + + _managementHub.ReceiveCommandResult(result); + } + void IManagementServerClient.SendNotificationToManagementHub(string msgCommandType, object updateMsg) + { + System.Diagnostics.Debug.WriteLine("SendInstanceInfo"); + + } + } +} diff --git a/src/Certify.Server/Certify.Server.HubService/appsettings.Development.json b/src/Certify.Server/Certify.Server.HubService/appsettings.Development.json new file mode 100644 index 000000000..0c208ae91 --- /dev/null +++ b/src/Certify.Server/Certify.Server.HubService/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/Certify.Server/Certify.Server.Api.Public/appsettings.json b/src/Certify.Server/Certify.Server.HubService/appsettings.json similarity index 89% rename from src/Certify.Server/Certify.Server.Api.Public/appsettings.json rename to src/Certify.Server/Certify.Server.HubService/appsettings.json index c1f97dfd3..7675acb21 100644 --- a/src/Certify.Server/Certify.Server.Api.Public/appsettings.json +++ b/src/Certify.Server/Certify.Server.HubService/appsettings.json @@ -11,6 +11,6 @@ "secret": "8FdYdFZKb2gQz7c4hpX7BMKpEnrpGhI7APd7GHMdvGg", "refreshTokenExpirationInDays": 1, "authTokenExpirationInMinutes": 60, - "issuer": "Certify.Api.Public" + "issuer": "Certify.Server.Hub.Api" } } diff --git a/src/Certify.Server/Certify.Service.Worker/Certify.Service.Worker.sln b/src/Certify.Server/Certify.Service.Worker/Certify.Service.Worker.sln deleted file mode 100644 index 76482fa41..000000000 --- a/src/Certify.Server/Certify.Service.Worker/Certify.Service.Worker.sln +++ /dev/null @@ -1,25 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 16 -VisualStudioVersion = 16.0.30114.128 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Certify.Service.Worker", "Certify.Service.Worker\Certify.Service.Worker.csproj", "{7EA8644D-580D-49CD-BD5B-CFDA23A20EC2}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {7EA8644D-580D-49CD-BD5B-CFDA23A20EC2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {7EA8644D-580D-49CD-BD5B-CFDA23A20EC2}.Debug|Any CPU.Build.0 = Debug|Any CPU - {7EA8644D-580D-49CD-BD5B-CFDA23A20EC2}.Release|Any CPU.ActiveCfg = Release|Any CPU - {7EA8644D-580D-49CD-BD5B-CFDA23A20EC2}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {CFDF0060-BB3D-49C8-8A8D-9F0813953142} - EndGlobalSection -EndGlobal diff --git a/src/Certify.Server/Certify.Service.Worker/Certify.Service.Worker/Certify.Service.Worker.csproj b/src/Certify.Server/Certify.Service.Worker/Certify.Service.Worker/Certify.Service.Worker.csproj deleted file mode 100644 index dd7ffcbe6..000000000 --- a/src/Certify.Server/Certify.Service.Worker/Certify.Service.Worker/Certify.Service.Worker.csproj +++ /dev/null @@ -1,25 +0,0 @@ - - - net7.0 - dotnet-Certify.Service.Worker-347A036F-C1EA-4D32-A163-DCB38C3CA53E - Linux - -v certifydata:/usr/share/Certify - - - portable - true - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/Certify.Server/Certify.Service.Worker/Certify.Service.Worker/Dockerfile b/src/Certify.Server/Certify.Service.Worker/Certify.Service.Worker/Dockerfile deleted file mode 100644 index c4714dbf8..000000000 --- a/src/Certify.Server/Certify.Service.Worker/Certify.Service.Worker/Dockerfile +++ /dev/null @@ -1,21 +0,0 @@ -#See https://aka.ms/containerfastmode to understand how Visual Studio uses this Dockerfile to build your images for faster debugging. -FROM mcr.microsoft.com/dotnet/aspnet:7.0 AS base -WORKDIR /app -EXPOSE 80 -EXPOSE 443 - -FROM mcr.microsoft.com/dotnet/sdk:7.0 AS build -WORKDIR /src -COPY ["Certify.Service.Worker/Certify.Service.Worker.csproj", "Certify.Service.Worker/"] -RUN dotnet restore "Certify.Service.Worker/Certify.Service.Worker.csproj" -COPY . . -WORKDIR "/src/Certify.Service.Worker" -RUN dotnet build "Certify.Service.Worker.csproj" -c Release -o /app/build - -FROM build AS publish -RUN dotnet publish "Certify.Service.Worker.csproj" -c Release -o /app/publish - -FROM base AS final -WORKDIR /app -COPY --from=publish /app/publish . -ENTRYPOINT ["dotnet", "Certify.Service.Worker.dll"] diff --git a/src/Certify.Server/Certify.Service.Worker/Certify.Service.Worker/Program.cs b/src/Certify.Server/Certify.Service.Worker/Certify.Service.Worker/Program.cs deleted file mode 100644 index cb5ec853b..000000000 --- a/src/Certify.Server/Certify.Service.Worker/Certify.Service.Worker/Program.cs +++ /dev/null @@ -1,153 +0,0 @@ -using System; -using System.IO; -using System.Net; -using System.Reflection; -using System.Runtime.InteropServices; -using System.Security.Cryptography.X509Certificates; -using Certify.Server.Core; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Server.Kestrel.Https; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; - -namespace Certify.Service.Worker -{ - public class Program - { - public static void Main(string[] args) - { - CreateHostBuilder(args).Build().Run(); - } - - public static IHostBuilder CreateHostBuilder(string[] args) - { - - var builder = Host.CreateDefaultBuilder(args) - .ConfigureAppConfiguration((context, builder) => - { - // when running within an integration test optionally load test config - if (File.Exists(Path.Join(AppContext.BaseDirectory, "appsettings.worker.test.json"))) - { - builder.AddJsonFile("appsettings.worker.test.json"); - builder.AddUserSecrets(typeof(Program).GetTypeInfo().Assembly); // for worker pfx details) - } - }) - .ConfigureLogging(logging => - { - logging.ClearProviders(); - logging.AddConsole(); - - }) - .UseSystemd() - .ConfigureServices((hostContext, services) => - { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - services.AddWindowsService(options => - options.ServiceName = "Certify Certificate Manager Background Service" - - ); - } - - services.AddHostedService(); - }) - .ConfigureWebHostDefaults(webBuilder => - { - webBuilder.ConfigureKestrel(serverOptions => - { - serverOptions.UseSystemd(); - // configure https listener, cert path and pwd can come either from an environment variable, usersecrets or appsettings. - // configuration precedence is secrets first, https://docs.microsoft.com/en-us/aspnet/core/fundamentals/configuration/?view=aspnetcore-5.0#default - var configuration = (IConfiguration)serverOptions.ApplicationServices.GetService(typeof(IConfiguration)); - - var useHttps = bool.Parse(configuration["API:Service:UseHttps"]); - - // default IP to localhost then specify from configuration - var ipSelection = configuration["API:Service:BindingIP"]; - var ipBinding = IPAddress.Loopback; - - if (ipSelection != null) - { - if (ipSelection.ToLower() == "loopback") - { - ipBinding = IPAddress.Loopback; - } - else if (ipSelection.ToLower() == "any") - { - ipBinding = IPAddress.Any; - } - else - { - ipBinding = IPAddress.Parse(ipSelection); - } - } - - if (useHttps) - { - - var certPassword = Environment.GetEnvironmentVariable("ASPNETCORE_Kestrel__Certificates__Development__Password"); - var certPath = Environment.GetEnvironmentVariable("ASPNETCORE_Kestrel__Certificates__Development__Path"); - - // if not yet defined load config from usersecrets (development env only) or appsettings - if (certPassword == null) - { - certPassword = configuration["Kestrel:Certificates:Default:Password"]; - } - - if (certPath == null) - { - certPath = configuration["Kestrel:Certificates:Default:Path"]; - } - - try - { - var certificate = new X509Certificate2(certPath, certPassword); - - // if password is wrong at this stage the attempts to use the cert will results in SSL Protocol Error - - var httpsConnectionAdapterOptions = new HttpsConnectionAdapterOptions() - { - ClientCertificateMode = ClientCertificateMode.NoCertificate, - SslProtocols = System.Security.Authentication.SslProtocols.Tls12 | System.Security.Authentication.SslProtocols.Tls13, - ServerCertificate = certificate, - }; - - var httpsPort = Convert.ToInt32(configuration["API:Service:HttpsPort"]); - - serverOptions.Listen(new System.Net.IPEndPoint(ipBinding, httpsPort), listenOptions => - { - listenOptions.UseHttps(httpsConnectionAdapterOptions); - }); - - } - catch (Exception exp) - { - // TODO: there is no logger yet, need to report this failure to main log once the log exists - System.Diagnostics.Debug.WriteLine("Failed to load PFX certificate for application. Check service certificate config." + exp.ToString()); - } - } - else - { - var httpPort = Convert.ToInt32(configuration["API:Service:HttpPort"]); - - serverOptions.Listen(new System.Net.IPEndPoint(ipBinding, httpPort), listenOptions => - { - }); - } - }); - - webBuilder.ConfigureLogging(logging => - { - logging.AddFilter("Microsoft.AspNetCore.SignalR", LogLevel.Debug); - logging.AddFilter("Microsoft.AspNetCore.Http.Connections", LogLevel.Debug); - }); - - webBuilder.UseStartup(); - }); - - return builder; - } - } -} diff --git a/src/Certify.Server/Certify.Service.Worker/Certify.Service.Worker/Properties/launchSettings.json b/src/Certify.Server/Certify.Service.Worker/Certify.Service.Worker/Properties/launchSettings.json deleted file mode 100644 index e9093ef61..000000000 --- a/src/Certify.Server/Certify.Service.Worker/Certify.Service.Worker/Properties/launchSettings.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "$schema": "http://json.schemastore.org/launchsettings.json", - "iisSettings": { - "windowsAuthentication": false, - "anonymousAuthentication": true, - "iisExpress": { - "applicationUrl": "http://localhost:54688", - "sslPort": 44346 - } - }, - "profiles": { - "IIS Express": { - "commandName": "IISExpress", - "launchBrowser": true, - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "Certify.Service.Worker": { - "commandName": "Project", - "launchBrowser": true, - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - }, - "applicationUrl": "https://localhost:5001;http://localhost:5000" - }, - "Docker": { - "commandName": "Docker", - "launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}/api/system/appversion", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - }, - "sslPort": 44360, - "applicationUrl": "http://localhost:32768;https://localhost:44360", - "httpPort": 32768, - "publishAllPorts": true, - "useSSL": true - }, - "WSL": { - "commandName": "WSL2", - "launchBrowser": true, - "launchUrl": "https://localhost:5001/api/system/appversion", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development", - "ASPNETCORE_URLS": "https://localhost:5001;http://localhost:5000" - }, - "distributionName": "" - } - } -} diff --git a/src/Certify.Server/Certify.Service.Worker/Certify.Service.Worker/Startup.cs b/src/Certify.Server/Certify.Service.Worker/Certify.Service.Worker/Startup.cs deleted file mode 100644 index ef965344c..000000000 --- a/src/Certify.Server/Certify.Service.Worker/Certify.Service.Worker/Startup.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Microsoft.Extensions.Configuration; - -namespace Certify.Server.Worker -{ - public class Startup : Certify.Server.Core.Startup - { - public Startup(IConfiguration configuration) : base(configuration) - { - // base startup performs most of the configuration in this instance - } - } -} diff --git a/src/Certify.Server/Certify.Service.Worker/Certify.Service.Worker/Worker.cs b/src/Certify.Server/Certify.Service.Worker/Certify.Service.Worker/Worker.cs deleted file mode 100644 index 6fd55ebef..000000000 --- a/src/Certify.Server/Certify.Service.Worker/Certify.Service.Worker/Worker.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; - -namespace Certify.Service.Worker -{ - public class Worker : BackgroundService - { - private readonly ILogger _logger; - - public Worker(ILogger logger) - { - _logger = logger; - } - - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - while (!stoppingToken.IsCancellationRequested) - { - _logger.LogInformation("Certify Service Worker heartbeat: {time}", DateTimeOffset.Now); - await Task.Delay(1000 * 60 * 60, stoppingToken); - } - } - } -} diff --git a/src/Certify.Server/Certify.Service.Worker/Certify.Service.Worker/readme.md b/src/Certify.Server/Certify.Service.Worker/Certify.Service.Worker/readme.md deleted file mode 100644 index 9f863b4a7..000000000 --- a/src/Certify.Server/Certify.Service.Worker/Certify.Service.Worker/readme.md +++ /dev/null @@ -1,44 +0,0 @@ -Certify Service Worker (Cross Platform) ------------------ -Development workflow - - - -Running Manually: - -dotnet ./Certify.Service.Worker.dll - -API will listen on http://localhost:32768 and https://localhost:44360 -HTTPS certificate setup is configured in Program.cs -Initial setup should use invalid pfx for https, with valid PFX to be acquired from own API. API status should flag https cert status for UI to report. - -WSL debug ---------- -- set DebugType to portable in build.props to enable debugging -- in debug service will run from host pc with a mnt -- database and log are in /usr/share/certify from Environment.SpecialFolder.CommonApplicationData - - -Linux Install ------------- - -apt-get certifytheweb - -sudo mkdir /opt/certifytheweb - -Systemd ------------ - -[Unit] -Description=Certify The Web - -[Service] -ExecStart=dotnet /opt/certifytheweb/certify.service -WorkingDirectory=/opt/certifytheweb/ -User=certifytheweb -Restart=on-failure -SyslogIdentifier=certifytheweb -PrivateTmp=true - -[Install] -WantedBy=multi-user.target diff --git a/src/Certify.Service/App.config b/src/Certify.Service/App.config index 05fa38a59..244bbe26f 100644 --- a/src/Certify.Service/App.config +++ b/src/Certify.Service/App.config @@ -1,4 +1,4 @@ - + @@ -8,32 +8,33 @@ - + - + - + - + - + diff --git a/src/Certify.Service/Certify.Service.csproj b/src/Certify.Service/Certify.Service.csproj index a89735035..79b00b4ed 100644 --- a/src/Certify.Service/Certify.Service.csproj +++ b/src/Certify.Service/Certify.Service.csproj @@ -1,39 +1,22 @@ net462 - win-x64 Debug;Release; Certify.Service Exe app.manifest icon.ico - x64;AnyCPU + AnyCPU true - - x64 - DEBUG;TRACE - - - x64 - DEBUG;TRACE - - - - x64 - - x64 - - - - x64 - bin\Release\ + AnyCPU + DEBUG;TRACE - x64 + AnyCPU bin\Release\ @@ -44,7 +27,7 @@ - + @@ -54,8 +37,8 @@ - - + + @@ -65,15 +48,14 @@ - - - - + + + - + diff --git a/src/Certify.Service/Controllers/AuthController.cs b/src/Certify.Service/Controllers/AuthController.cs deleted file mode 100644 index a7bb733c9..000000000 --- a/src/Certify.Service/Controllers/AuthController.cs +++ /dev/null @@ -1,75 +0,0 @@ -using System; -using System.Threading.Tasks; -using System.Web.Http; -using Certify.Management; - -namespace Certify.Service.Controllers -{ - [RoutePrefix("api/auth")] - public class AuthController : ControllerBase - { - public class AuthModel - { - public string Key { get; set; } - public string Username { get; set; } - public string Password { get; set; } - } - - private ICertifyManager _certifyManager = null; - - public AuthController(ICertifyManager manager) - { - _certifyManager = manager; - } -#if !RELEASE //feature not production ready - [HttpGet, Route("windows")] - public async Task GetWindowsAuthKey() - { - - // user is using windows authentication, return an initial secret auth token. TODO: user must be able to invalidate existing auth key - var encryptedBytes = System.Security.Cryptography.ProtectedData.Protect( - System.Text.Encoding.UTF8.GetBytes(ActionContext.RequestContext.Principal.Identity.Name), - System.Text.Encoding.UTF8.GetBytes("authtoken"), System.Security.Cryptography.DataProtectionScope.LocalMachine - ); - - var secret = Convert.ToBase64String(encryptedBytes); - - var userIdPlusSecret = ActionContext.RequestContext.Principal.Identity.Name + ":" + secret; - - // return auth secret as Base64 string suitable for Basic Authorization https://en.wikipedia.org/wiki/Basic_access_authentication - return await Task.FromResult(Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(userIdPlusSecret))); - - } - - [HttpPost, Route("token")] - [AllowAnonymous] - public async Task AcquireToken(AuthModel model) - { - DebugLog(); - - // TODO: validate authkey and return new JWT - - if (model.Key == "windows123") - { - var jwt = GenerateJwt("certifyuser", GetAuthSecretKey()); - return await Task.FromResult(Ok(jwt)); - } - else - { - return await Task.FromResult(Unauthorized()); - } - } - - [HttpPost, Route("refresh")] - public async Task Refresh() - { - DebugLog(); - - // TODO: validate refresh token and return new JWT - - var jwt = GenerateJwt("certifyuser", GetAuthSecretKey()); - return await Task.FromResult(jwt); - } -#endif - } -} diff --git a/src/Certify.Service/Controllers/CredentialsController.cs b/src/Certify.Service/Controllers/CredentialsController.cs index eb4fe32bc..98e481aed 100644 --- a/src/Certify.Service/Controllers/CredentialsController.cs +++ b/src/Certify.Service/Controllers/CredentialsController.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Threading.Tasks; using System.Web.Http; using Certify.Management; @@ -34,7 +34,7 @@ public async Task UpdateCredentials(StoredCredential credentia } [HttpDelete, Route("{storageKey}")] - public async Task DeleteCredential(string storageKey) + public async Task DeleteCredential(string storageKey) { DebugLog(); diff --git a/src/Certify.Service/Controllers/ManagedCertificateController.cs b/src/Certify.Service/Controllers/ManagedCertificateController.cs index 04b9170b0..669553175 100644 --- a/src/Certify.Service/Controllers/ManagedCertificateController.cs +++ b/src/Certify.Service/Controllers/ManagedCertificateController.cs @@ -8,7 +8,10 @@ using Certify.Management; using Certify.Models; using Certify.Models.Config; +using Certify.Models.Hub; +using Certify.Models.Reporting; using Certify.Models.Utils; +using Microsoft.Extensions.Logging; using Serilog; namespace Certify.Service.Controllers @@ -33,6 +36,21 @@ public async Task> Search(ManagedCertificateFilter filt return await _certifyManager.GetManagedCertificates(filter); } + // Get List of Top N Managed Certificates, filtered by title, as a Search Result with total count + [HttpPost, Route("results")] + public async Task GetResults(ManagedCertificateFilter filter) + { + DebugLog(); + return await _certifyManager.GetManagedCertificateResults(filter); + } + + [HttpPost, Route("summary")] + public async Task GetSummary(ManagedCertificateFilter filter) + { + DebugLog(); + return await _certifyManager.GetManagedCertificateSummary(filter); + } + [HttpGet, Route("{id}")] public async Task GetById(string id) { @@ -69,9 +87,6 @@ public async Task> TestChallengeResponse(ManagedCertificate var progressIndicator = new Progress(progressState.ProgressReport); - //begin monitoring progress - _certifyManager.BeginTrackingProgress(progressState); - // perform challenge response test, log to string list and return in result var logList = new List(); using (var log = new LoggerConfiguration() @@ -79,36 +94,13 @@ public async Task> TestChallengeResponse(ManagedCertificate .WriteTo.Sink(new ProgressLogSink(progressIndicator, managedCertificate, _certifyManager)) .CreateLogger()) { - var theLog = new Loggy(log); + var theLog = new Loggy(new Serilog.Extensions.Logging.SerilogLoggerFactory(log).CreateLogger()); var results = await _certifyManager.TestChallenge(theLog, managedCertificate, isPreviewMode: true, progress: progressIndicator); return results; } } - [HttpPost, Route("challengecleanup")] - public async Task> PerformChallengeCleanup(ManagedCertificate managedCertificate) - { - DebugLog(); - - var progressState = new RequestProgressState(RequestState.Running, "Performing Challenge Cleanup..", managedCertificate); - - var progressIndicator = new Progress(progressState.ProgressReport); - - // perform challenge response test, log to string list and return in result - var logList = new List(); - using (var log = new LoggerConfiguration() - - .WriteTo.Sink(new ProgressLogSink(progressIndicator, managedCertificate, _certifyManager)) - .CreateLogger()) - { - var theLog = new Loggy(log); - var results = await _certifyManager.PerformChallengeCleanup(theLog, managedCertificate, progress: progressIndicator); - - return results; - } - } - [HttpPost, Route("preview")] public async Task> PreviewActions(ManagedCertificate site) { @@ -184,9 +176,6 @@ public async Task BeginCertificateRequest(string manag var progressIndicator = new Progress(progressState.ProgressReport); - //begin monitoring progress - _certifyManager.BeginTrackingProgress(progressState); - //begin request var result = await _certifyManager.PerformCertificateRequest( null, @@ -198,17 +187,8 @@ public async Task BeginCertificateRequest(string manag return result; } - [HttpGet, Route("requeststatus/{managedItemId}")] - public RequestProgressState CheckCertificateRequest(string managedItemId) - { - DebugLog(); - - //TODO: check current status of request in progress - return _certifyManager.GetRequestProgressState(managedItemId); - } - [HttpGet, Route("log/{managedItemId}/{limit}")] - public async Task GetLog(string managedItemId, int limit) + public async Task GetLog(string managedItemId, int limit) { DebugLog(); return await _certifyManager.GetItemLog(managedItemId, limit); diff --git a/src/Certify.Service/Controllers/SystemController.cs b/src/Certify.Service/Controllers/SystemController.cs index e408d0900..35de239b3 100644 --- a/src/Certify.Service/Controllers/SystemController.cs +++ b/src/Certify.Service/Controllers/SystemController.cs @@ -2,10 +2,10 @@ using System.Threading.Tasks; using System.Web.Http; using System.Web.Http.Cors; -using Certify.Config.Migration; using Certify.Management; using Certify.Models; using Certify.Models.Config; +using Certify.Models.Config.Migration; using Certify.Shared; namespace Certify.Service.Controllers diff --git a/src/Certify.Service/OwinService.cs b/src/Certify.Service/OwinService.cs index ba48bee83..60d2cb0dd 100644 --- a/src/Certify.Service/OwinService.cs +++ b/src/Certify.Service/OwinService.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Diagnostics; using System.ServiceProcess; using Certify.Management; diff --git a/src/Certify.Shared.Extensions/Certify.Shared.Extensions.csproj b/src/Certify.Shared.Extensions/Certify.Shared.Extensions.csproj index 7e6e80ef6..d16bc24fe 100644 --- a/src/Certify.Shared.Extensions/Certify.Shared.Extensions.csproj +++ b/src/Certify.Shared.Extensions/Certify.Shared.Extensions.csproj @@ -1,9 +1,9 @@ - + - net462;netstandard2.0;net7.0 + netstandard2.0;net9.0 AnyCPU - + latest @@ -23,51 +23,8 @@ - - - - - - + + diff --git a/src/Certify.Shared.Extensions/Scripts/Common/RDPListenerService.ps1 b/src/Certify.Shared.Extensions/Scripts/Common/RDPListenerService.ps1 index 129e1e5e8..df6e5eb36 100644 --- a/src/Certify.Shared.Extensions/Scripts/Common/RDPListenerService.ps1 +++ b/src/Certify.Shared.Extensions/Scripts/Common/RDPListenerService.ps1 @@ -7,4 +7,14 @@ param($result) # Apply certificate -wmic /namespace:\\root\cimv2\TerminalServices PATH Win32_TSGeneralSetting Set SSLCertificateSHA1Hash="$($result.ManagedItem.CertificateThumbprintHash)" +if (Get-Command wmic -errorAction SilentlyContinue) +{ + # Beginning with Windows Server 2025, WMIC is available as a feature on demand. + wmic /namespace:\\root\cimv2\TerminalServices PATH Win32_TSGeneralSetting Set SSLCertificateSHA1Hash="$($result.ManagedItem.CertificateThumbprintHash)" +} +else +{ + # For new development, use the CIM cmdlets instead. + $instance = Get-CimInstance -ClassName Win32_TSGeneralSetting -Namespace root/cimv2/TerminalServices + Set-CimInstance -InputObject $instance -Property @{SSLCertificateSHA1Hash=$result.ManagedItem.CertificateThumbprintHash} +} diff --git a/src/Certify.Shared.Extensions/Utils/PowerShellManager.cs b/src/Certify.Shared.Extensions/Utils/PowerShellManager.cs index 0502ed92d..62d2a9018 100644 --- a/src/Certify.Shared.Extensions/Utils/PowerShellManager.cs +++ b/src/Certify.Shared.Extensions/Utils/PowerShellManager.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Management.Automation; using System.Management.Automation.Runspaces; +using System.Runtime.InteropServices; using System.Security; using System.Security.AccessControl; using System.Security.Principal; @@ -16,18 +17,26 @@ namespace Certify.Management { + /// + /// PowerShell script execution manager. + /// Manage the execution of PowerShell scripts, either in-process or by launching a new process. + /// public class PowerShellManager { /// - /// + /// Run a PowerShell script, either in-process or by launching a new process. /// - /// Unrestricted etc, - /// - /// - /// - /// - /// - /// + /// Unrestricted etc, see https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_execution_policies?view=powershell-7.3 + /// Result object to pass to the script + /// Path to the script file + /// Parameters to pass to the script + /// Content of the script + /// Credentials to use for running the script + /// Logon type to use for running the script + /// Commands to ignore exceptions for + /// Timeout in minutes + /// Launch a new process + /// ActionResult public static async Task RunScript( string powershellExecutionPolicy, CertificateRequestResult result = null, @@ -60,7 +69,7 @@ public static async Task RunScript( if (launchNewProcess) { // spawn new process as the given user - return ExecutePowershellAsProcess(result, powershellExecutionPolicy, scriptFile, parameters, credentials, scriptContent, null, ignoredCommandExceptions: ignoredCommandExceptions, timeoutMinutes: timeoutMinutes); + return await ExecutePowershellAsProcess(result, powershellExecutionPolicy, scriptFile, parameters, credentials, logonType, scriptContent, null, ignoredCommandExceptions: ignoredCommandExceptions, timeoutMinutes: timeoutMinutes); } else { @@ -82,49 +91,33 @@ public static async Task RunScript( { shell.Runspace = runspace; - if (credentials != null && credentials.Any()) + // running PowerShell under credentials currently only supported for windows + var credentialsProvidedButNotSupported = false; + + if (credentials?.Any() == true && !RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + // TODO: warn credentials not supported on this platform + credentialsProvidedButNotSupported = true; + } + + if (credentials?.Any() == true && credentialsProvidedButNotSupported == false && RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { // run as windows user UserCredentials windowsCredentials = null; - if (credentials != null && credentials.Count > 0) - { - try - { - windowsCredentials = GetWindowsCredentials(credentials); - } - catch - { - var err = "Command with Windows Credentials requires username and password."; - - return new ActionResult(err, false); - } - } - - // logon type affects the range of abilities the impersonated user has - var _defaultLogonType = LogonType.NewCredentials; - - if (logonType == "network") - { - _defaultLogonType = LogonType.Network; - } - else if (logonType == "batch") + try { - _defaultLogonType = LogonType.Batch; + windowsCredentials = GetWindowsCredentials(credentials); } - else if (logonType == "service") + catch { - _defaultLogonType = LogonType.Service; - } - else if (logonType == "interactive") - { - _defaultLogonType = LogonType.Interactive; - } - else if (logonType == "newcredentials") - { - _defaultLogonType = LogonType.NewCredentials; + var err = "Command with Windows Credentials requires username and password."; + + return new ActionResult(err, false); } + var _defaultLogonType = GetLogonType(logonType); + ActionResult powerShellResult = null; using (var userHandle = windowsCredentials.LogonUser(_defaultLogonType)) { @@ -152,13 +145,37 @@ public static async Task RunScript( } } - private static string GetPowershellExePath() + private static LogonType GetLogonType(string logonType) + { + return logonType?.ToLower() switch + { + "network" => LogonType.Network, + "batch" => LogonType.Batch, + "service" => LogonType.Service, + "interactive" => LogonType.Interactive, + "newcredentials" => LogonType.NewCredentials, + _ => LogonType.NewCredentials, + }; + } + + /// + /// Get the path to the pwoershell exe, optionally using a preferred path first + /// + /// + /// + private static string GetPowershellExePath(string powershellPathPreference) { var searchPaths = new List() { "%WINDIR%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", - "%PROGRAMFILES%\\PowerShell\\7\\pwsh.exe" + "%PROGRAMFILES%\\PowerShell\\7\\pwsh.exe", + "/usr/bin/pwsh" }; + if (!string.IsNullOrWhiteSpace(powershellPathPreference)) + { + searchPaths.Insert(0, powershellPathPreference); + } + // if powershell exe path supplied, use that (with expansion) and check exe exists // otherwise detect powershell exe location foreach (var exePath in searchPaths) @@ -173,15 +190,15 @@ private static string GetPowershellExePath() return null; } - private static ActionResult ExecutePowershellAsProcess(CertificateRequestResult result, string executionPolicy, string scriptFile, Dictionary parameters, Dictionary credentials, string scriptContent, PowerShell shell, bool autoConvertBoolean = true, string[] ignoredCommandExceptions = null, int timeoutMinutes = 5) + private static async Task ExecutePowershellAsProcess(CertificateRequestResult result, string executionPolicy, string scriptFile, Dictionary parameters, Dictionary credentials, string logonType, string scriptContent, PowerShell shell, bool autoConvertBoolean = true, string[] ignoredCommandExceptions = null, int timeoutMinutes = 5, string powershellPathPreference = null) { - var _log = new StringBuilder(); - var commandExe = GetPowershellExePath(); + var commandExe = GetPowershellExePath(powershellPathPreference); + if (commandExe == null) { - return new ActionResult("Failed to locate powershell exe. Cannot launch as new process.", false); + return new ActionResult("Failed to locate powershell executable. Cannot launch as new process.", false); } if (!string.IsNullOrEmpty(scriptContent)) @@ -190,11 +207,47 @@ private static ActionResult ExecutePowershellAsProcess(CertificateRequestResult return new ActionResult("Script content is not yet supported when used with launch as new process.", false); } + var resultObj = parameters?.Where(p => p.Key == "result" && p.Value != null).FirstOrDefault().Value; + var resultJson = resultObj != null ? Newtonsoft.Json.JsonConvert.SerializeObject(resultObj) : null; + + var resultsJsonTempPath = string.Empty; + var resultsJsonExported = false; + var appBasePath = AppContext.BaseDirectory; + var wrapperScriptPath = Path.Combine(new string[] { appBasePath, "Scripts", "Internal", "Script-Wrapper.ps1" }); + var wrapperScriptSourceText = File.ReadAllText(wrapperScriptPath); + + var isUsingCredentials = (credentials != null && credentials.ContainsKey("username") && credentials.ContainsKey("password")); + + if (isUsingCredentials && (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))) + { + // The impersonating user must be able to read the script wrapper so that the process starting under their credentials can call it. They will also need to be able to read the users supplied target script (not addressed here). + // If the Results object is also being used we write that to a temp file and set the ACL to allow read by the impersonating user. + + try + { + var username = GetWindowsCredentialsUsername(credentials); - // note that the impersonating user must be able to read the script wrapper so that the process starting under their credentials can call it and the target script - // if the Results object is also being used we write that to a temp file and set the ACL to allow read by the impersonating user. + var wrapperTempPath = Path.GetTempPath(); + var wrapperTempFilePath = Path.GetTempFileName(); + wrapperScriptPath = Path.ChangeExtension(wrapperTempFilePath, ".ps1"); + File.WriteAllText(wrapperScriptPath, wrapperScriptSourceText); + ApplyFileACL(wrapperScriptPath, username); + + resultsJsonTempPath = Path.GetTempFileName(); + File.WriteAllText(resultsJsonTempPath, resultJson); + ApplyFileACL(resultsJsonTempPath, username); + + resultsJsonExported = true; + } + catch + { + var err = "A command with Windows Credentials requires a correct username and password. Check credentials."; + + return new ActionResult(err, false); + } + } var arguments = $" -File \"{wrapperScriptPath}\""; @@ -205,24 +258,27 @@ private static ActionResult ExecutePowershellAsProcess(CertificateRequestResult if (!string.IsNullOrEmpty(executionPolicy)) { - arguments = $"-ExecutionPolicy {executionPolicy} " + arguments; + arguments = $"-ExecutionPolicy {executionPolicy} {arguments}"; } arguments += $" -scriptFile \"{scriptFile}\""; - string resultsJsonTempPath = null; - if (parameters?.Any() == true) { foreach (var p in parameters) { if (p.Key == "result" && p.Value != null) { - // reserved parameter name for the ManagedCertificate object - var json = Newtonsoft.Json.JsonConvert.SerializeObject(p.Value); + if (!resultsJsonExported) + { // if results file not already exported for the impersonated user export now + + // "result" is reserved parameter name for the ManagedCertificate object + var json = Newtonsoft.Json.JsonConvert.SerializeObject(p.Value); - resultsJsonTempPath = Path.GetTempFileName(); - File.WriteAllText(resultsJsonTempPath, json); + resultsJsonTempPath = Path.GetTempFileName(); + File.WriteAllText(resultsJsonTempPath, json); + resultsJsonExported = true; + } arguments += $" -resultJsonFile \"{resultsJsonTempPath}\""; } @@ -249,48 +305,39 @@ private static ActionResult ExecutePowershellAsProcess(CertificateRequestResult // launch process with user credentials set if (credentials != null && credentials.ContainsKey("username") && credentials.ContainsKey("password")) { - var username = credentials["username"]; - var pwd = credentials["password"]; - - credentials.TryGetValue("domain", out var domain); - - if (domain == null && !username.Contains(".\\") && !username.Contains("@")) + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - domain = "."; - } - - scriptProcessInfo.UserName = username; - scriptProcessInfo.Domain = domain; - - var sPwd = new SecureString(); - foreach (var c in pwd) - { - sPwd.AppendChar(c); - } - - sPwd.MakeReadOnly(); - scriptProcessInfo.Password = sPwd; + var username = credentials["username"]; + var pwd = credentials["password"]; - if (resultsJsonTempPath != null) - { - //allow this user to read the results file - var fileInfo = new FileInfo(resultsJsonTempPath); - var accessControl = fileInfo.GetAccessControl(); - var fullUser = domain == "." ? username : $"{domain}\\{username}"; - accessControl.AddAccessRule(new FileSystemAccessRule(fullUser, FileSystemRights.Read, AccessControlType.Allow)); + credentials.TryGetValue("domain", out var domain); - try + if (domain == null && !username.Contains(".\\") && !username.Contains("@")) { - fileInfo.SetAccessControl(accessControl); + domain = "."; } - catch + + // Note: process running as local system cannot start a process as different user due to lack of security token context + scriptProcessInfo.UserName = username; + scriptProcessInfo.Domain = domain; + + var sPwd = new SecureString(); + foreach (var c in pwd) { - _log.AppendLine("Running Powershell As New Process: Could not apply access control to allow this user to read the temp results file"); + sPwd.AppendChar(c); } - } - _log.AppendLine($"Launching Process {commandExe} as User: {domain}\\{username}"); + sPwd.MakeReadOnly(); + + scriptProcessInfo.Password = sPwd; + + _log.AppendLine($"Launching Process {commandExe} as User: {domain}\\{username}"); + } + else + { + _log.AppendLine($"Running PowerShell As New Process: Running as specific user credentials are not supported on this platform."); + } } try @@ -353,7 +400,11 @@ private static ActionResult ExecutePowershellAsProcess(CertificateRequestResult catch (Exception exp) { _log.AppendLine("Error: " + exp.ToString()); - return new ActionResult { IsSuccess = false, Message = _log.ToString() }; + return new ActionResult + { + IsSuccess = false, + Message = _log.ToString() + }; } finally { @@ -373,6 +424,24 @@ private static ActionResult ExecutePowershellAsProcess(CertificateRequestResult } } + private static bool ApplyFileACL(string filePath, string fullUsername) + { + var fileInfo = new FileInfo(filePath); + var accessControl = fileInfo.GetAccessControl(); + + accessControl.AddAccessRule(new FileSystemAccessRule(fullUsername, FileSystemRights.ReadAndExecute, AccessControlType.Allow)); + + try + { + fileInfo.SetAccessControl(accessControl); + return true; + } + catch + { + return false; + } + } + private static ActionResult InvokePowershell(CertificateRequestResult result, string executionPolicy, string scriptFile, Dictionary parameters, string scriptContent, PowerShell shell, bool autoConvertBoolean = true, string[] ignoredCommandExceptions = null, int timeoutMinutes = 5) { // ensure execution policy will allow the script to run, default to system default, default policy is set in service config object @@ -383,13 +452,17 @@ private static ActionResult InvokePowershell(CertificateRequestResult result, st executionPolicy = parameters.FirstOrDefault(p => p.Key.ToLower() == "executionpolicy").Value?.ToString(); } - if (!string.IsNullOrEmpty(executionPolicy)) + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - shell.AddCommand("Set-ExecutionPolicy") - .AddParameter("ExecutionPolicy", executionPolicy) - .AddParameter("Scope", "Process") - .AddParameter("Force") - .Invoke(); + // on windows we may need to set execution policy depending on user preferences + if (!string.IsNullOrEmpty(executionPolicy)) + { + shell.AddCommand("Set-ExecutionPolicy") + .AddParameter("ExecutionPolicy", executionPolicy) + .AddParameter("Scope", "Process") + .AddParameter("Force") + .Invoke(); + } } // add script command to invoke @@ -563,5 +636,29 @@ public static UserCredentials GetWindowsCredentials(Dictionary c return windowsCredentials; } + + public static string GetWindowsCredentialsUsername(Dictionary credentials, bool includeAutoLocalDomain = false) + { + var username = credentials["username"]; + + credentials.TryGetValue("domain", out var domain); + + if (includeAutoLocalDomain) + { + if (domain == null && !username.Contains(".\\") && !username.Contains("@")) + { + domain = "."; + } + } + + if (domain != null) + { + return $"{domain}\\{username}"; + } + else + { + return username; + } + } } } diff --git a/src/Certify.Shared/Certify.Shared.Core.csproj b/src/Certify.Shared/Certify.Shared.Core.csproj index 254af5b42..26a2917af 100644 --- a/src/Certify.Shared/Certify.Shared.Core.csproj +++ b/src/Certify.Shared/Certify.Shared.Core.csproj @@ -1,27 +1,28 @@  - netstandard2.0;net6.0 - AnyCPU;x64 + netstandard2.0;net9.0; + AnyCPU - + + + + + - - - - - - + + + @@ -38,4 +39,14 @@ + + + all + analyzers + + + + + + diff --git a/src/Certify.Shared/Management/CertificateManager.cs b/src/Certify.Shared/Management/CertificateManager.cs index a9e5d7435..b4d7802bb 100644 --- a/src/Certify.Shared/Management/CertificateManager.cs +++ b/src/Certify.Shared/Management/CertificateManager.cs @@ -32,6 +32,9 @@ public static class CertificateManager public const string DEFAULT_STORE_NAME = "My"; public const string WEBHOSTING_STORE_NAME = "WebHosting"; public const string DISALLOWED_STORE_NAME = "Disallowed"; + private static readonly bool IsWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + private static readonly bool IsLinux = RuntimeInformation.IsOSPlatform(OSPlatform.Linux); + private static readonly bool IsMac = RuntimeInformation.IsOSPlatform(OSPlatform.OSX); public static X509Certificate2 GenerateSelfSignedCertificate(string domain, DateTimeOffset? dateFrom = null, DateTimeOffset? dateTo = null, string suffix = "[Certify]", string subject = null, string keyType = StandardKeyTypes.RSA256) { @@ -269,6 +272,72 @@ public static Org.BouncyCastle.X509.X509Certificate ReadCertificateFromPem(strin return cert; } + private static X509Store GetStore(string storeName, bool useMachineStore = true) + { + if (IsWindows) + { + if (useMachineStore) + { + return GetMachineStore(storeName); + } + + return GetUserStore(storeName); + } + else if (IsLinux) + { + // See https://github.com/dotnet/runtime/blob/main/src/libraries/System.Security.Cryptography/src/System/Security/Cryptography/X509Certificates/StorePal.OpenSsl.cs#L142 + return GetUserStore(storeName); + } + else if (IsMac) + { + // See https://github.com/dotnet/runtime/blob/main/src/libraries/System.Security.Cryptography/src/System/Security/Cryptography/X509Certificates/StorePal.macOS.cs#L108 + if (!useMachineStore || (storeName == CA_STORE_NAME || storeName == WEBHOSTING_STORE_NAME)) + { + return GetUserStore(storeName); + } + else if (storeName == DEFAULT_STORE_NAME || storeName == ROOT_STORE_NAME || storeName == DISALLOWED_STORE_NAME) + { + return GetMachineStore(storeName); + } + + throw new CryptographicException($"Could not open X509Store {storeName} in LocalMachine on OSX"); + } + + throw new PlatformNotSupportedException($"Could not open X509Store for unsupported OS {RuntimeInformation.OSDescription}"); + } + + private static void OpenStoreForReadWrite(X509Store store, string storeName) + { + if (IsWindows) + { + store.Open(OpenFlags.OpenExistingOnly | OpenFlags.ReadWrite); + } + else if (IsLinux) + { + // See https://github.com/dotnet/runtime/blob/main/src/libraries/System.Security.Cryptography/src/System/Security/Cryptography/X509Certificates/StorePal.OpenSsl.cs#L142 + if (storeName == DEFAULT_STORE_NAME || storeName == WEBHOSTING_STORE_NAME) + { + store.Open(OpenFlags.ReadWrite); + } + else + { + store.Open(OpenFlags.OpenExistingOnly | OpenFlags.ReadWrite); + } + } + else if (IsMac) + { + // See https://github.com/dotnet/runtime/blob/main/src/libraries/System.Security.Cryptography/src/System/Security/Cryptography/X509Certificates/StorePal.macOS.cs#L108 + if (storeName == CA_STORE_NAME || storeName == WEBHOSTING_STORE_NAME) + { + store.Open(OpenFlags.ReadWrite); + } + else + { + store.Open(OpenFlags.OpenExistingOnly | OpenFlags.ReadWrite); + } + } + } + public static bool StoreCertificateFromPem(string pem, string storeName, bool useMachineStore = true) { try @@ -277,9 +346,9 @@ public static bool StoreCertificateFromPem(string pem, string storeName, bool us var cert = x509CertificateParser.ReadCertificate(System.Text.UTF8Encoding.UTF8.GetBytes(pem)); var certToStore = new X509Certificate2(DotNetUtilities.ToX509Certificate(cert)); - using (var store = useMachineStore ? GetMachineStore(storeName) : GetUserStore(storeName)) + using (var store = GetStore(storeName, useMachineStore)) { - store.Open(OpenFlags.OpenExistingOnly | OpenFlags.ReadWrite); + OpenStoreForReadWrite(store, storeName); store.Add(certToStore); store.Close(); return true; @@ -303,6 +372,23 @@ public static async Task StoreCertificate( { // https://support.microsoft.com/en-gb/help/950090/installing-a-pfx-file-using-x509certificate-from-a-standard--net-appli X509Certificate2 certificate; + +#if NET9_0_OR_GREATER + try + { + var pfxBytes = File.ReadAllBytes(pfxFile); + certificate = X509CertificateLoader.LoadPkcs12(pfxBytes, pwd, X509KeyStorageFlags.MachineKeySet | X509KeyStorageFlags.PersistKeySet | X509KeyStorageFlags.Exportable); + + } + catch (CryptographicException) + { + var pfxBytes = File.ReadAllBytes(pfxFile); + certificate = X509CertificateLoader.LoadPkcs12(pfxBytes, "", X509KeyStorageFlags.MachineKeySet | X509KeyStorageFlags.PersistKeySet | X509KeyStorageFlags.Exportable); + + // success using blank pwd, continue with blank pwd + pwd = ""; + } +#else try { certificate = new X509Certificate2(pfxFile, pwd, X509KeyStorageFlags.MachineKeySet | X509KeyStorageFlags.PersistKeySet | X509KeyStorageFlags.Exportable); @@ -315,6 +401,7 @@ public static async Task StoreCertificate( // success using blank pwd, continue with blank pwd pwd = ""; } +#endif if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { @@ -367,11 +454,11 @@ public static async Task StoreCertificate( } } - public static List GetCertificatesFromStore(string issuerName = null, string storeName = DEFAULT_STORE_NAME) + public static List GetCertificatesFromStore(string issuerName = null, string storeName = DEFAULT_STORE_NAME, bool useMachineStore = true) { var list = new List(); - using (var store = GetMachineStore(storeName)) + using (var store = GetStore(storeName, useMachineStore)) { store.Open(OpenFlags.OpenExistingOnly | OpenFlags.ReadOnly); @@ -394,7 +481,7 @@ public static X509Certificate2 GetCertificateFromStore(string subjectName, strin { X509Certificate2 cert = null; - using (var store = GetMachineStore(storeName)) + using (var store = GetStore(storeName)) { store.Open(OpenFlags.OpenExistingOnly | OpenFlags.ReadOnly); @@ -420,7 +507,7 @@ public static X509Certificate2 GetCertificateByThumbprint(string thumbprint, str X509Certificate2 cert = null; - using (var store = useMachineStore ? GetMachineStore(storeName) : GetUserStore(storeName)) + using (var store = GetStore(storeName, useMachineStore)) { store.Open(OpenFlags.OpenExistingOnly | OpenFlags.ReadOnly); @@ -439,9 +526,9 @@ public static X509Certificate2 GetCertificateByThumbprint(string thumbprint, str public static X509Certificate2 StoreCertificate(X509Certificate2 certificate, string storeName = DEFAULT_STORE_NAME) { - using (var store = GetMachineStore(storeName)) + using (var store = GetStore(storeName)) { - store.Open(OpenFlags.OpenExistingOnly | OpenFlags.ReadWrite); + OpenStoreForReadWrite(store, storeName); store.Add(certificate); @@ -453,9 +540,9 @@ public static X509Certificate2 StoreCertificate(X509Certificate2 certificate, st public static void RemoveCertificate(X509Certificate2 certificate, string storeName = DEFAULT_STORE_NAME) { - using (var store = GetMachineStore(storeName)) + using (var store = GetStore(storeName)) { - store.Open(OpenFlags.OpenExistingOnly | OpenFlags.ReadWrite); + OpenStoreForReadWrite(store, storeName); store.Remove(certificate); store.Close(); } @@ -721,13 +808,20 @@ private static string GetWindowsPrivateKeyLocation(string keyFileName) } public static X509Store GetMachineStore(string storeName = DEFAULT_STORE_NAME) => new X509Store(storeName, StoreLocation.LocalMachine); - public static X509Store GetUserStore(string storeName = DEFAULT_STORE_NAME) => new X509Store(storeName, StoreLocation.CurrentUser); + public static X509Store GetUserStore(string storeName = DEFAULT_STORE_NAME) + { +#if NET9_0_OR_GREATER + return new X509Store(storeName, StoreLocation.CurrentUser, OpenFlags.ReadWrite); +#else + return new X509Store(storeName, StoreLocation.CurrentUser); +#endif + } public static bool IsCertificateInStore(X509Certificate2 cert, string storeName = DEFAULT_STORE_NAME) { var certExists = false; - using (var store = GetMachineStore(storeName)) + using (var store = GetStore(storeName)) { store.Open(OpenFlags.OpenExistingOnly | OpenFlags.ReadOnly); @@ -772,9 +866,9 @@ public static List PerformCertificateStoreCleanup( } // get all certificates - using (var store = GetMachineStore(storeName)) + using (var store = GetStore(storeName)) { - store.Open(OpenFlags.OpenExistingOnly | OpenFlags.ReadWrite); + OpenStoreForReadWrite(store, storeName); var certsToRemove = new List(); foreach (var c in store.Certificates) @@ -866,9 +960,9 @@ public static bool DisableCertificateUsage(string thumbprint, string sourceStore { var disabled = false; - using (var store = useMachineStore ? GetMachineStore(sourceStore) : GetUserStore(sourceStore)) + using (var store = GetStore(sourceStore, useMachineStore)) { - store.Open(OpenFlags.OpenExistingOnly | OpenFlags.ReadWrite); + OpenStoreForReadWrite(store, sourceStore); foreach (var c in store.Certificates) { @@ -887,9 +981,9 @@ public static bool DisableCertificateUsage(string thumbprint, string sourceStore public static bool MoveCertificate(string thumbprint, string sourceStore, string destStore, bool useMachineStore = true) { var certsToMove = new List(); - using (var store = useMachineStore ? GetMachineStore(sourceStore) : GetUserStore(sourceStore)) + using (var store = GetStore(sourceStore, useMachineStore)) { - store.Open(OpenFlags.OpenExistingOnly | OpenFlags.ReadWrite); + OpenStoreForReadWrite(store, sourceStore); foreach (var c in store.Certificates) { if (c.Thumbprint == thumbprint) @@ -908,9 +1002,9 @@ public static bool MoveCertificate(string thumbprint, string sourceStore, string if (certsToMove.Any()) { - using (var store = useMachineStore ? GetMachineStore(destStore) : GetUserStore(destStore)) + using (var store = GetStore(destStore, useMachineStore)) { - store.Open(OpenFlags.OpenExistingOnly | OpenFlags.ReadWrite); + OpenStoreForReadWrite(store, destStore); foreach (var c in certsToMove) { var foundCerts = store.Certificates.Find(X509FindType.FindByThumbprint, thumbprint, false); diff --git a/src/Certify.Shared/Management/CredentialsManagerBase.cs b/src/Certify.Shared/Management/CredentialsUtil.cs similarity index 59% rename from src/Certify.Shared/Management/CredentialsManagerBase.cs rename to src/Certify.Shared/Management/CredentialsUtil.cs index b2626fe1d..395cbe27e 100644 --- a/src/Certify.Shared/Management/CredentialsManagerBase.cs +++ b/src/Certify.Shared/Management/CredentialsUtil.cs @@ -1,25 +1,19 @@ using System; -using System.Collections.Generic; +using System.IO; using System.Linq; +using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Text; using System.Threading.Tasks; -using Certify.Models.Config; +using Certify.Models; using Certify.Providers; +using Microsoft.AspNetCore.DataProtection; namespace Certify.Management { - public class CredentialsManagerBase + public static class CredentialsUtil { - - protected bool _useWindowsNativeFeatures = true; - - public CredentialsManagerBase(bool useWindowsNativeFeatures = true) - { - _useWindowsNativeFeatures = useWindowsNativeFeatures; - } - - public async Task IsCredentialInUse(IManagedItemStore itemStore, string storageKey) + public static async Task IsCredentialInUse(IManagedItemStore itemStore, string storageKey) { if (itemStore == null) { @@ -39,18 +33,6 @@ public async Task IsCredentialInUse(IManagedItemStore itemStore, string st } } -#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously - public virtual async Task GetCredential(string storageKey) - { - throw new NotImplementedException(); - } - - public virtual async Task> GetUnlockedCredentialsDictionary(string storageKey) - { - throw new NotImplementedException(); - } -#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously - /// /// Get protected version of a secret /// @@ -58,10 +40,10 @@ public virtual async Task> GetUnlockedCredentialsDict /// /// /// - public string Protect( + public static string Protect( string clearText, string optionalEntropy = null, - DataProtectionScope scope = DataProtectionScope.CurrentUser) + DataProtectionScope? scope = null) { // https://www.thomaslevesque.com/2013/05/21/an-easy-and-secure-way-to-store-a-password-using-data-protection-api/ @@ -70,20 +52,29 @@ public string Protect( return null; } - if (_useWindowsNativeFeatures) + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { + // protect using DAPI + if (scope == null) + { + scope = DataProtectionScope.CurrentUser; + } var clearBytes = Encoding.UTF8.GetBytes(clearText); var entropyBytes = string.IsNullOrEmpty(optionalEntropy) ? null : Encoding.UTF8.GetBytes(optionalEntropy); - var encryptedBytes = ProtectedData.Protect(clearBytes, entropyBytes, scope); + var encryptedBytes = ProtectedData.Protect(clearBytes, entropyBytes, (DataProtectionScope)scope); return Convert.ToBase64String(encryptedBytes); } else { - // TODO: dummy implementation, require alternative implementation for non-windows - return Convert.ToBase64String(Encoding.UTF8.GetBytes(clearText).Reverse().ToArray()); + // protect using platform data protection provider + + var protector = GetDataProtector(); + var clearBytes = Encoding.UTF8.GetBytes(clearText); + var protectedBytes = protector.Protect(clearBytes); + return Convert.ToBase64String(protectedBytes); } } @@ -94,10 +85,10 @@ public string Protect( /// /// /// - public string Unprotect( + public static string Unprotect( string encryptedText, string optionalEntropy = null, - DataProtectionScope scope = DataProtectionScope.CurrentUser) + DataProtectionScope? scope = null) { // https://www.thomaslevesque.com/2013/05/21/an-easy-and-secure-way-to-store-a-password-using-data-protection-api/ @@ -106,22 +97,35 @@ public string Unprotect( throw new ArgumentNullException("encryptedText"); } - if (_useWindowsNativeFeatures) + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { + if (scope == null) + { + scope = DataProtectionScope.CurrentUser; + } + var encryptedBytes = Convert.FromBase64String(encryptedText); var entropyBytes = string.IsNullOrEmpty(optionalEntropy) ? null : Encoding.UTF8.GetBytes(optionalEntropy); - var clearBytes = ProtectedData.Unprotect(encryptedBytes, entropyBytes, scope); + var clearBytes = ProtectedData.Unprotect(encryptedBytes, entropyBytes, (DataProtectionScope)scope); return Encoding.UTF8.GetString(clearBytes); } else { - - // TODO: dummy implementation, implement alternative implementation for non-windows - var bytes = Convert.FromBase64String(encryptedText); - return Encoding.UTF8.GetString(bytes.Reverse().ToArray()); + // protect using platform data protection provider + var protector = GetDataProtector(); + var encryptedBytes = Convert.FromBase64String(encryptedText); + var clearBytes = protector.Unprotect(encryptedBytes); + return Encoding.UTF8.GetString(clearBytes); } } + + private static IDataProtector GetDataProtector() + { + var keyDirectory = EnvironmentUtil.CreateAppDataPath("credentials"); + var dataProtectionProvider = DataProtectionProvider.Create(new DirectoryInfo(keyDirectory)); + return dataProtectionProvider.CreateProtector("StoredCredentials"); + } } } diff --git a/src/Certify.Shared/Management/PluginManager.cs b/src/Certify.Shared/Management/PluginManager.cs index 478934884..227e56c74 100644 --- a/src/Certify.Shared/Management/PluginManager.cs +++ b/src/Certify.Shared/Management/PluginManager.cs @@ -7,7 +7,10 @@ using Certify.Models; using Certify.Models.Config; using Certify.Models.Plugins; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Serilog; +using Serilog.Extensions.Logging; namespace Certify.Management { @@ -50,14 +53,27 @@ public class PluginManager private Models.Providers.ILog _log = null; + private IServiceProvider _services; + + /// + /// + /// + /// + public PluginManager(IServiceProvider serviceProvider) : this() + { + _services = serviceProvider; + } public PluginManager() { - _log = new Models.Loggy( - new LoggerConfiguration() + var serilogLogger = new LoggerConfiguration() + .Enrich.FromLogContext() .MinimumLevel.Information() .WriteTo.File(Path.Combine(EnvironmentUtil.CreateAppDataPath("logs"), "plugins.log"), shared: true, flushToDiskInterval: new TimeSpan(0, 0, 10)) - .CreateLogger() - ); + .CreateLogger(); + + var msLogger = new SerilogLoggerFactory(serilogLogger).CreateLogger(); + + _log = new Models.Loggy(msLogger); if (CurrentInstance == null) { @@ -119,9 +135,14 @@ private T LoadPlugin(string dllFileName, string pluginFolder = null) if (pluginType != null) { - var obj = (T)Activator.CreateInstance(pluginType); - - return obj; + if (_services == null) + { + return (T)Activator.CreateInstance(pluginType); + } + else + { + return (T)ActivatorUtilities.CreateInstance(_services, pluginType); + } } else { diff --git a/src/Certify.Shared/Management/ServerConnectionManager.cs b/src/Certify.Shared/Management/ServerConnectionManager.cs index 9d7f95936..801564166 100644 --- a/src/Certify.Shared/Management/ServerConnectionManager.cs +++ b/src/Certify.Shared/Management/ServerConnectionManager.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; diff --git a/src/Certify.Shared/Utils/DemoDataGenerator.cs b/src/Certify.Shared/Utils/DemoDataGenerator.cs new file mode 100644 index 000000000..10903c4a0 --- /dev/null +++ b/src/Certify.Shared/Utils/DemoDataGenerator.cs @@ -0,0 +1,121 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Certify.Models; +using Certify.Models.Shared.Validation; + +namespace Certify.Shared.Core.Utils +{ + public class DemoDataGenerator + { + public static List GenerateDemoItems() + { + var rnd = new Random(); + + var items = new List(); + var numItems = new Random().Next(10, 500); + for (var i = 0; i < numItems; i++) + { + + var item = new ManagedCertificate + { + Id = Guid.NewGuid().ToString(), + Name = GenerateName(rnd), + RequestConfig = new CertRequestConfig + { + Challenges = new System.Collections.ObjectModel.ObservableCollection { new CertRequestChallengeConfig { ChallengeType = SupportedChallengeTypes.CHALLENGE_TYPE_HTTP } } + } + }; + + item.DomainOptions.Add(new DomainOption { Domain = $"{item.Name}.dev.projectbids.co.uk", IsManualEntry = true, IsPrimaryDomain = true, IsSelected = true, Type = CertIdentifierType.Dns }); + item.RequestConfig.PrimaryDomain = item.DomainOptions[0].Domain; + item.RequestConfig.SubjectAlternativeNames = new string[] { item.DomainOptions[0].Domain }; + + var validation = CertificateEditorService.Validate(item, null, null, applyAutoConfiguration: true); + if (validation.IsValid) + { + var demoState = new Random().Next(1, 3); + var certLifetime = new Random().Next(7, 30); + var certElapsed = new Random().Next(1, certLifetime); + var certStart = DateTime.UtcNow.AddDays(-certElapsed); + + if (demoState == 1) + { + // not yet requested + item.Comments = "This is an example item note yet attempted."; + } + else if (demoState == 2) + { + // failed + item.CertificateCurrentCA = "demo-ca.org"; + item.DateStart = certStart; + item.DateLastRenewalAttempt = certStart; + item.DateExpiry = certStart.AddDays(certLifetime); + item.CertificateFriendlyName = $"{item.GetCertificateIdentifiers().First().Value} [CertifyDemo] - {item.DateStart} to {item.DateExpiry}"; + item.Comments = "This is an example item showing failure."; + item.LastAttemptedCA = item.CertificateCurrentCA; + item.LastRenewalStatus = RequestState.Error; + item.RenewalFailureCount = new Random().Next(1, 3); + item.RenewalFailureMessage = "Item failed because it is a demo item that was designed to show what failure looks like."; + + } + else if (demoState == 3) + { + //success + item.CertificateCurrentCA = "demo-ca.org"; + item.DateStart = certStart; + item.DateLastRenewalAttempt = certStart; + item.DateExpiry = certStart.AddDays(certLifetime); + item.CertificateFriendlyName = $"{item.GetCertificateIdentifiers().First().Value} [CertifyDemo] - {item.DateStart} to {item.DateExpiry}"; + item.Comments = "This is an example item showing success"; + item.LastAttemptedCA = item.CertificateCurrentCA; + item.LastRenewalStatus = RequestState.Success; + } + + items.Add(item); + } + else + { + // generated invalid test item + System.Diagnostics.Debug.WriteLine(validation.Message); + } + } + + return items; + } + + public static string GenerateName(Random rnd) + { + // generate test item names using verb,animal + var subjects = new string[] { + "Lion", + "Tiger", + "Leopard", + "Cheetah", + "Elephant", + "Giraffe", + "Rhinoceros", + "Gorilla" + }; + var adjectives = new string[] { + "active", + "adaptable", + "alert", + "clever" , + "comfortable" , + "conscientious", + "considerate", + "courageous" , + "decisive", + "determined" , + "diligent" , + "energetic", + "entertaining", + "enthusiastic" , + "fabulous" + }; + + return $"{adjectives[rnd.Next(0, adjectives.Length - 1)]}-{subjects[rnd.Next(0, subjects.Length - 1)]}".ToLower(); + } + } +} diff --git a/src/Certify.Shared/Utils/HttpChallengeServer.cs b/src/Certify.Shared/Utils/HttpChallengeServer.cs index e0c45a2c2..f9799c8a7 100644 --- a/src/Certify.Shared/Utils/HttpChallengeServer.cs +++ b/src/Certify.Shared/Utils/HttpChallengeServer.cs @@ -51,8 +51,8 @@ public class HttpChallengeServer private int _autoCloseSeconds = 60; private string _baseUri = ""; private Timer _autoCloseTimer; - private readonly object _challengeServerStartLock = new object(); - private readonly object _challengeServerStopLock = new object(); + private readonly Lock _challengeServerStartLock = LockFactory.Create(); + private readonly Lock _challengeServerStopLock = LockFactory.Create(); /// /// If true, challenge server has been started or a start has been attempted diff --git a/src/Certify.Shared/Utils/Loggy.cs b/src/Certify.Shared/Utils/Loggy.cs index c946e7b0a..01981517f 100644 --- a/src/Certify.Shared/Utils/Loggy.cs +++ b/src/Certify.Shared/Utils/Loggy.cs @@ -1,5 +1,5 @@ using System; -using Serilog; +using Microsoft.Extensions.Logging; namespace Certify.Models { @@ -12,16 +12,16 @@ public Loggy(ILogger log) _log = log; } - public void Error(string template, params object[] propertyValues) => _log.Error(template, propertyValues); + public void Error(string template, params object[] propertyValues) => _log?.LogError(template, propertyValues); - public void Error(Exception exp, string template, params object[] propertyValues) => _log.Error(exp, template, propertyValues); + public void Error(Exception exp, string template, params object[] propertyValues) => _log?.LogError(exp, template, propertyValues); - public void Information(string template, params object[] propertyValues) => _log.Information(template, propertyValues); + public void Information(string template, params object[] propertyValues) => _log?.LogInformation(template, propertyValues); - public void Debug(string template, params object[] propertyValues) => _log.Debug(template, propertyValues); + public void Debug(string template, params object[] propertyValues) => _log?.LogDebug(template, propertyValues); - public void Verbose(string template, params object[] propertyValues) => _log.Verbose(template, propertyValues); + public void Verbose(string template, params object[] propertyValues) => _log?.LogTrace(template, propertyValues); - public void Warning(string template, params object[] propertyValues) => _log.Warning(template, propertyValues); + public void Warning(string template, params object[] propertyValues) => _log?.LogWarning(template, propertyValues); } } diff --git a/src/Certify.Shared/Utils/ManagedCertificateLog.cs b/src/Certify.Shared/Utils/ManagedCertificateLog.cs index e6052b9cd..52ffb2c2f 100644 --- a/src/Certify.Shared/Utils/ManagedCertificateLog.cs +++ b/src/Certify.Shared/Utils/ManagedCertificateLog.cs @@ -2,7 +2,9 @@ using System.Collections.Concurrent; using System.IO; using Certify.Models.Providers; +using Microsoft.Extensions.Logging; using Serilog; +using Serilog.Core; namespace Certify.Models { @@ -26,11 +28,11 @@ public class ManagedCertificateLogItem public static class ManagedCertificateLog { - private static ConcurrentDictionary _managedItemLoggers { get; set; } + private static ConcurrentDictionary _managedItemLoggers { get; set; } public static string GetLogPath(string managedItemId) => Path.Combine(EnvironmentUtil.CreateAppDataPath("logs"), "log_" + managedItemId.Replace(':', '_') + ".txt"); - public static ILog GetLogger(string managedItemId, Serilog.Core.LoggingLevelSwitch logLevelSwitch) + public static ILog GetLogger(string managedItemId, LogLevel logLevelSwitch) { if (string.IsNullOrEmpty(managedItemId)) { @@ -39,10 +41,10 @@ public static ILog GetLogger(string managedItemId, Serilog.Core.LoggingLevelSwit if (_managedItemLoggers == null) { - _managedItemLoggers = new ConcurrentDictionary(); + _managedItemLoggers = new ConcurrentDictionary(); } - Serilog.Core.Logger log = _managedItemLoggers.GetOrAdd(managedItemId, (key) => + var log = _managedItemLoggers.GetOrAdd(managedItemId, (key) => { var logPath = GetLogPath(key); @@ -55,26 +57,40 @@ public static ILog GetLogger(string managedItemId, Serilog.Core.LoggingLevelSwit } catch { } - Serilog.Debugging.SelfLog.Enable(Console.Error); - - log = new LoggerConfiguration() - .MinimumLevel.ControlledBy(logLevelSwitch) -#if DEBUG - .WriteTo.Debug() -#endif + var serilogLog = new Serilog.LoggerConfiguration() + .Enrich.FromLogContext() + .MinimumLevel.ControlledBy(LogLevelSwitchFromLogLevel(logLevelSwitch)) .WriteTo.File( logPath, shared: true, flushToDiskInterval: new TimeSpan(0, 0, 10) ) .CreateLogger(); - return log; + return new Serilog.Extensions.Logging.SerilogLoggerFactory(serilogLog).CreateLogger(); + }); return new Loggy(log); } - public static void AppendLog(string managedItemId, ManagedCertificateLogItem logItem, Serilog.Core.LoggingLevelSwitch logLevelSwitch) + public static LoggingLevelSwitch LogLevelSwitchFromLogLevel(LogLevel level) + { + switch (level) + { + case LogLevel.Trace: + return new LoggingLevelSwitch(Serilog.Events.LogEventLevel.Debug); + case LogLevel.Debug: + return new LoggingLevelSwitch(Serilog.Events.LogEventLevel.Verbose); + case LogLevel.Warning: + return new LoggingLevelSwitch(Serilog.Events.LogEventLevel.Warning); + case LogLevel.Error: + return new LoggingLevelSwitch(Serilog.Events.LogEventLevel.Error); + default: + return new LoggingLevelSwitch(Serilog.Events.LogEventLevel.Information); + } + } + + public static void AppendLog(string managedItemId, ManagedCertificateLogItem logItem, LogLevel logLevelSwitch) { var log = GetLogger(managedItemId, logLevelSwitch); @@ -106,7 +122,10 @@ public static void DisposeLoggers() { foreach (var l in _managedItemLoggers.Values) { - l?.Dispose(); + if (l is IDisposable tmp) + { + tmp?.Dispose(); + } } } } diff --git a/src/Certify.Shared/Utils/NetworkUtils.cs b/src/Certify.Shared/Utils/NetworkUtils.cs index 271446f8a..24e56c838 100644 --- a/src/Certify.Shared/Utils/NetworkUtils.cs +++ b/src/Certify.Shared/Utils/NetworkUtils.cs @@ -7,6 +7,8 @@ using System.Net.Sockets; using System.Threading.Tasks; using Certify.Management; +using Certify.Models.API; + #if NET6_0_OR_GREATER using ARSoft.Tools.Net; using ARSoft.Tools.Net.Dns; @@ -157,7 +159,7 @@ public async Task CheckURL(ILog log, string url, bool? useProxyAPI = null) { var jsonText = await response.Content.ReadAsStringAsync(); - var result = Newtonsoft.Json.JsonConvert.DeserializeObject(jsonText); + var result = Newtonsoft.Json.JsonConvert.DeserializeObject(jsonText); if (result.IsAccessible == true) { diff --git a/src/Certify.Shared/Utils/PKI/CertUtils.cs b/src/Certify.Shared/Utils/PKI/CertUtils.cs index 9963876f3..8ba0009d7 100644 --- a/src/Certify.Shared/Utils/PKI/CertUtils.cs +++ b/src/Certify.Shared/Utils/PKI/CertUtils.cs @@ -1,6 +1,7 @@ using System; using System.IO; using System.Linq; +using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using Certify.Management; using Org.BouncyCastle.Asn1.X509; @@ -41,7 +42,21 @@ public static string GetCertComponentsAsPEMString(byte[] pfxData, string pwd, Ex { // See also https://www.digicert.com/ssl-support/pem-ssl-creation.htm - var cert = new X509Certificate2(pfxData, pwd); + X509Certificate2 cert = null; +#if NET9_0_OR_GREATER + try + { + cert = X509CertificateLoader.LoadPkcs12(pfxData, pwd, X509KeyStorageFlags.MachineKeySet | X509KeyStorageFlags.PersistKeySet | X509KeyStorageFlags.Exportable); + } + catch (CryptographicException) + { + // try again using blank pwd + cert = X509CertificateLoader.LoadPkcs12(pfxData, "", X509KeyStorageFlags.MachineKeySet | X509KeyStorageFlags.PersistKeySet | X509KeyStorageFlags.Exportable); + } +#else + cert = new X509Certificate2(pfxData, pwd); +#endif + var chain = new X509Chain(); chain.Build(cert); diff --git a/src/Certify.Shared/Utils/PKI/OcspUtils.cs b/src/Certify.Shared/Utils/PKI/OcspUtils.cs index d47bba76f..afaa561c5 100644 --- a/src/Certify.Shared/Utils/PKI/OcspUtils.cs +++ b/src/Certify.Shared/Utils/PKI/OcspUtils.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Net.Http; diff --git a/src/Certify.Shared/Utils/Util.cs b/src/Certify.Shared/Utils/Util.cs index c388e40d7..44498ee31 100644 --- a/src/Certify.Shared/Utils/Util.cs +++ b/src/Certify.Shared/Utils/Util.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; @@ -176,7 +176,7 @@ public static void SetSupportedTLSVersions() public static string GetUserAgent() { var versionName = "Certify/" + GetAppVersion().ToString(); - return $"{versionName} (Windows; {Environment.OSVersion}) "; + return $"{versionName} ({RuntimeInformation.OSDescription}; {Environment.OSVersion}) "; } public static Version GetAppVersion() diff --git a/src/Certify.SourceGenerators/ApiMethods.cs b/src/Certify.SourceGenerators/ApiMethods.cs new file mode 100644 index 000000000..b504a6566 --- /dev/null +++ b/src/Certify.SourceGenerators/ApiMethods.cs @@ -0,0 +1,449 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using SourceGenerator; + +namespace Certify.SourceGenerators +{ + public class ApiMethods + { + public static string HttpGet = "HttpGet"; + public static string HttpPost = "HttpPost"; + public static string HttpDelete = "HttpDelete"; + + public static string GetFormattedTypeName(Type type) + { + if (type.IsGenericType) + { + var genericArguments = type.GetGenericArguments() + .Select(x => x.FullName) + .Aggregate((x1, x2) => $"{x1}, {x2}"); + return $"{type.FullName.Substring(0, type.FullName.IndexOf("`"))}" + + $"<{genericArguments}>"; + } + + return type.FullName; + } + public static List GetApiDefinitions() + { + // declaring an API definition here is then used by the source generators to: + // - create the public API endpoint + // - map the call from the public API to the background service API in the service API Client (interface and implementation) + // - to then generate the public API clients, run nswag when the public API is running. + + return new List { + + new() { + OperationName = "CheckSecurityPrincipleHasAccess", + OperationMethod = HttpPost, + Comment = "Check a given security principle has permissions to perform a specific action for a specific resource action", + PublicAPIController = "Access", + PublicAPIRoute = "securityprinciple/allowedaction", + ServiceAPIRoute = "access/securityprinciple/allowedaction", + ReturnType = "bool", + Params =new Dictionary{{"check","Certify.Models.Hub.AccessCheck"} } + }, + new() { + OperationName = "GetSecurityPrincipleAssignedRoles", + OperationMethod = HttpGet, + Comment = "Get list of Assigned Roles for a given security principle", + PublicAPIController = "Access", + PublicAPIRoute = "securityprinciple/{id}/assignedroles", + ServiceAPIRoute = "access/securityprinciple/{id}/assignedroles", + ReturnType = "ICollection", + Params =new Dictionary{{"id","string"}} + }, + new() { + OperationName = "GetSecurityPrincipleRoleStatus", + OperationMethod = HttpGet, + Comment = "Get list of Assigned Roles etc for a given security principle", + PublicAPIController = "Access", + PublicAPIRoute = "securityprinciple/{id}/rolestatus", + ServiceAPIRoute = "access/securityprinciple/{id}/rolestatus", + ReturnType = "RoleStatus", + Params =new Dictionary{{"id","string"}} + }, + new() { + OperationName = "GetAccessRoles", + OperationMethod = HttpGet, + Comment = "Get list of available security Roles", + PublicAPIController = "Access", + PublicAPIRoute = "roles", + ServiceAPIRoute = "access/roles", + ReturnType = "ICollection" + }, + new() { + OperationName = "GetAssignedAccessTokens", + OperationMethod = HttpGet, + Comment = "Get list of API assigned access tokens", + PublicAPIController = "Access", + PublicAPIRoute = "assignedtoken", + ServiceAPIRoute = "access/assignedtoken/list", + ReturnType = "ICollection" + }, + new() { + OperationName = "AddAssignedAccessToken", + OperationMethod = HttpPost, + Comment = "Add new assigned access token", + PublicAPIController = "Access", + PublicAPIRoute = "assignedtoken", + ServiceAPIRoute = "access/assignedtoken", + ReturnType = "Models.Config.ActionResult", + Params = new Dictionary{{"token", "Certify.Models.Hub.AssignedAccessToken" } } + }, + new() { + + OperationName = "GetSecurityPrinciples", + OperationMethod = HttpGet, + Comment = "Get list of available security principles", + PublicAPIController = "Access", + PublicAPIRoute = "securityprinciples", + ServiceAPIRoute = "access/securityprinciples", + ReturnType = "ICollection" + }, + new() { + OperationName = "ValidateSecurityPrinciplePassword", + OperationMethod = HttpPost, + Comment = "Check password valid for security principle", + PublicAPIController = "Access", + PublicAPIRoute = "validate", + ServiceAPIRoute = "access/validate", + ReturnType = "Certify.Models.Hub.SecurityPrincipleCheckResponse", + Params = new Dictionary{{"passwordCheck", "Certify.Models.Hub.SecurityPrinciplePasswordCheck" } } + }, + new() { + + OperationName = "UpdateSecurityPrinciplePassword", + OperationMethod = HttpPost, + Comment = "Update password for security principle", + PublicAPIController = "Access", + PublicAPIRoute = "updatepassword", + ServiceAPIRoute = "access/updatepassword", + ReturnType = "Models.Config.ActionResult", + Params = new Dictionary{{"passwordUpdate", "Certify.Models.Hub.SecurityPrinciplePasswordUpdate" } } + }, + new() { + + OperationName = "AddSecurityPrinciple", + OperationMethod = HttpPost, + Comment = "Add new security principle", + PublicAPIController = "Access", + PublicAPIRoute = "securityprinciple", + ServiceAPIRoute = "access/securityprinciple", + ReturnType = "Models.Config.ActionResult", + Params = new Dictionary{{"principle", "Certify.Models.Hub.SecurityPrinciple" } } + }, + new() { + + OperationName = "UpdateSecurityPrinciple", + OperationMethod = HttpPost, + Comment = "Update existing security principle", + PublicAPIController = "Access", + PublicAPIRoute = "securityprinciple/update", + ServiceAPIRoute = "access/securityprinciple/update", + ReturnType = "Models.Config.ActionResult", + Params = new Dictionary{ + { "principle", "Certify.Models.Hub.SecurityPrinciple" } + } + }, + new() { + OperationName = "UpdateSecurityPrincipleAssignedRoles", + OperationMethod = HttpPost, + Comment = "Update assigned roles for a security principle", + PublicAPIController = "Access", + PublicAPIRoute = "securityprinciple/roles/update", + ServiceAPIRoute = "access/securityprinciple/roles/update", + ReturnType = "Models.Config.ActionResult", + Params = new Dictionary{ + { "update", "Certify.Models.Hub.SecurityPrincipleAssignedRoleUpdate" } + } + }, + new() { + OperationName = "RemoveSecurityPrinciple", + OperationMethod = HttpDelete, + Comment = "Remove security principle", + PublicAPIController = "Access", + PublicAPIRoute = "securityprinciple", + ServiceAPIRoute = "access/securityprinciple/{id}", + ReturnType = "Models.Config.ActionResult", + Params = new Dictionary{{"id","string"}} + }, + new() { + OperationName = "GetManagedChallenges", + OperationMethod = HttpGet, + Comment = "Get list of available managed challenges (DNS challenge delegation etc)", + PublicAPIController = "ManagedChallenge", + PublicAPIRoute = "list", + ServiceAPIRoute = "managedchallenge", + ReturnType = "ICollection", + RequiredPermissions = [new ("managedchallenge", "managedchallenge_list")] + }, + new() { + OperationName = "UpdateManagedChallenge", + OperationMethod = HttpPost, + Comment = "Add/update a managed challenge (DNS challenge delegation etc)", + PublicAPIController = "ManagedChallenge", + PublicAPIRoute = "update", + ServiceAPIRoute = "managedchallenge", + ReturnType = "Models.Config.ActionResult", + Params = new Dictionary{ + { "update", "Certify.Models.Hub.ManagedChallenge" } + }, + RequiredPermissions = [new ("managedchallenge", "managedchallenge_update")] + }, + new() { + OperationName = "RemoveManagedChallenge", + OperationMethod = HttpDelete, + Comment = "Delete a managed challenge (DNS challenge delegation etc)", + PublicAPIController = "ManagedChallenge", + PublicAPIRoute = "remove", + ServiceAPIRoute = "managedchallenge/{id}", + ReturnType = "Models.Config.ActionResult", + Params = new Dictionary{ + { "id", "string" } + }, + RequiredPermissions = [new ("managedchallenge", "managedchallenge_delete")] + }, + new() { + OperationName = "PerformManagedChallenge", + OperationMethod = HttpPost, + Comment = "Perform a managed challenge (DNS challenge delegation etc)", + PublicAPIController = null, // skip public controller implementation + ServiceAPIRoute = "managedchallenge/request", + ReturnType = "Models.Config.ActionResult", + Params = new Dictionary{ + { "request", "Certify.Models.Hub.ManagedChallengeRequest" } + }, + RequiredPermissions = [new ("managedchallenge", "managedchallenge_request")] + }, + new() { + OperationName = "CleanupManagedChallenge", + OperationMethod = HttpPost, + Comment = "Perform cleanup for a previously managed challenge (DNS challenge delegation etc)", + PublicAPIController = null, // skip public controller implementation + ServiceAPIRoute = "managedchallenge/cleanup", + ReturnType = "Models.Config.ActionResult", + Params = new Dictionary{ + { "request", "Certify.Models.Hub.ManagedChallengeRequest" } + } + }, + /* per instance API, via management hub */ + new() { + OperationName = "GetAcmeAccounts", + OperationMethod = HttpGet, + Comment = "Get All Acme Accounts", + UseManagementAPI = true, + PublicAPIController = "CertificateAuthority", + PublicAPIRoute = "{instanceId}/accounts/", + ReturnType = "ICollection", + Params = new Dictionary { { "instanceId", "string" } } + }, + new() { + OperationName = "AddAcmeAccount", + OperationMethod = HttpPost, + Comment = "Add New Acme Account", + UseManagementAPI = true, + PublicAPIController = "CertificateAuthority", + PublicAPIRoute = "{instanceId}/account/", + ReturnType = "Models.Config.ActionResult", + Params = new Dictionary { { "instanceId", "string" }, { "registration", "Certify.Models.ContactRegistration" } } + }, + new() { + OperationName = "GetCertificateAuthorities", + OperationMethod = HttpGet, + Comment = "Get list of defined Certificate Authorities", + UseManagementAPI = true, + PublicAPIController = "CertificateAuthority", + PublicAPIRoute = "{instanceId}/authority", + ReturnType = "ICollection", + Params = new Dictionary { { "instanceId", "string" } } + }, + new() { + OperationName = "UpdateCertificateAuthority", + OperationMethod = HttpPost, + Comment = "Add/Update Certificate Authority", + UseManagementAPI = true, + PublicAPIController = "CertificateAuthority", + PublicAPIRoute = "{instanceId}/authority", + ReturnType = "Models.Config.ActionResult", + Params = new Dictionary { { "instanceId", "string" }, { "ca", "Certify.Models.CertificateAuthority" } } + }, + new() { + OperationName = "RemoveCertificateAuthority", + OperationMethod = HttpDelete, + Comment = "Remove Certificate Authority", + UseManagementAPI = true, + PublicAPIController = "CertificateAuthority", + PublicAPIRoute = "{instanceId}/authority/{id}", + ReturnType = "Models.Config.ActionResult", + Params = new Dictionary { { "instanceId", "string" }, { "id", "string" } } + }, + new() { + OperationName = "RemoveAcmeAccount", + OperationMethod = HttpDelete, + Comment = "Remove ACME Account", + UseManagementAPI = true, + PublicAPIController = "CertificateAuthority", + PublicAPIRoute = "{instanceId}/accounts/{storageKey}/{deactivate}", + ReturnType = "Models.Config.ActionResult", + Params = new Dictionary { { "instanceId", "string" }, { "storageKey", "string" }, { "deactivate", "bool" } } + }, + new() { + OperationName = "GetStoredCredentials", + OperationMethod = HttpGet, + Comment = "Get List of Stored Credentials", + UseManagementAPI = true, + PublicAPIController = "StoredCredential", + PublicAPIRoute = "{instanceId}", + ReturnType = "ICollection", + Params = new Dictionary { { "instanceId", "string" } } + }, + new() { + OperationName = "UpdateStoredCredential", + OperationMethod = HttpPost, + Comment = "Add/Update Stored Credential", + PublicAPIController = "StoredCredential", + PublicAPIRoute = "{instanceId}", + ReturnType = "Models.Config.ActionResult", + UseManagementAPI = true, + Params = new Dictionary { { "instanceId", "string" }, { "item", "Models.Config.StoredCredential" } } + }, + new() { + OperationName = "RemoveStoredCredential", + OperationMethod = HttpDelete, + Comment = "Remove Stored Credential", + UseManagementAPI = true, + PublicAPIController = "StoredCredential", + PublicAPIRoute = "{instanceId}/{storageKey}", + ReturnType = "Models.Config.ActionResult", + Params = new Dictionary { { "instanceId", "string" }, { "storageKey", "string" } } + }, + new() { + OperationName = "GetDeploymentProviders", + OperationMethod = HttpGet, + Comment = "Get Deployment Task Providers", + UseManagementAPI = true, + PublicAPIController = "DeploymentTask", + PublicAPIRoute = "{instanceId}", + ReturnType = "ICollection", + Params = new Dictionary{ + { "instanceId", "string" } + } + }, + new() { + OperationName = "GetTargetServiceTypes", + OperationMethod = HttpGet, + Comment = "Get Service Types present on instance (IIS, nginx etc)", + UseManagementAPI = true, + ManagementHubCommandType = Models.Hub.ManagementHubCommands.GetTargetServiceTypes, + PublicAPIController = "Target", + PublicAPIRoute = "{instanceId}/types", + ReturnType = "ICollection", + Params = new Dictionary{ + { "instanceId", "string" } + } + }, + new() { + OperationName = "GetTargetServiceItems", + OperationMethod = HttpGet, + Comment = "Get Service items (sites) present on instance (IIS, nginx etc).", + UseManagementAPI = true, + ManagementHubCommandType = Models.Hub.ManagementHubCommands.GetTargetServiceItems, + PublicAPIController = "Target", + PublicAPIRoute = "{instanceId}/{serviceType}/items", + ReturnType = "ICollection", + Params = new Dictionary{ + { "instanceId", "string" }, + { "serviceType", "string" } + } + }, + new() { + OperationName = "GetTargetServiceItemIdentifiers", + OperationMethod = HttpGet, + Comment = "Get Service item identifiers (domains on a website etc) present on instance (IIS, nginx etc)", + UseManagementAPI = true, + ManagementHubCommandType = Models.Hub.ManagementHubCommands.GetTargetServiceItemIdentifiers, + PublicAPIController = "Target", + PublicAPIRoute = "{instanceId}/{serviceType}/item/{itemId}/identifiers", + ReturnType = "ICollection", + Params = new Dictionary{ + { "instanceId", "string" }, + { "serviceType", "string" }, + { "itemId", "string" } + } + }, + new() { + OperationName = "GetChallengeProviders", + OperationMethod = HttpGet, + Comment = "Get Dns Challenge Providers", + UseManagementAPI = true, + PublicAPIController = "ChallengeProvider", + PublicAPIRoute = "{instanceId}", + ReturnType = "ICollection", + Params = new Dictionary{ + { "instanceId", "string" } + } + }, + new() { + OperationName = "GetDnsZones", + OperationMethod = HttpGet, + Comment = "Get List of Zones with the current DNS provider and credential", + UseManagementAPI = true, + PublicAPIController = "ChallengeProvider", + PublicAPIRoute = "{instanceId}/dnszones/{providerTypeId}/{credentialId}", + ReturnType = "ICollection", + Params = new Dictionary{ + { "instanceId", "string" } , + { "providerTypeId", "string" }, + { "credentialId", "string" } + } + }, + new() { + OperationName = "ExecuteDeploymentTask", + OperationMethod = HttpGet, + Comment = "Execute Deployment Task", + UseManagementAPI = true, + PublicAPIController = "DeploymentTask", + PublicAPIRoute = "{instanceId}/execute/{managedCertificateId}/{taskId}", + ReturnType = "ICollection", + Params = new Dictionary{ + { "instanceId", "string" }, + { "managedCertificateId", "string" }, + { "taskId", "string" } + } + }, + new() { + OperationName = "RemoveManagedCertificate", + OperationMethod = HttpDelete, + Comment = "Remove Managed Certificate", + UseManagementAPI = true, + PublicAPIController = "Certificate", + PublicAPIRoute = "{instanceId}/settings/{managedCertId}", + ReturnType = "Models.Config.ActionResult", + Params = new Dictionary { { "instanceId", "string" }, { "managedCertId", "string" } } + }, + // TODO + new() { + OperationName = "PerformExport", + OperationMethod = HttpPost, + Comment = "Perform an export of all settings", + PublicAPIController = "System", + PublicAPIRoute = "system/migration/export", + ServiceAPIRoute = "system/migration/export", + ReturnType = "Models.Config.Migration.ImportExportPackage", + Params = new Dictionary { { "exportRequest", "Certify.Models.Config.Migration.ExportRequest" } } + }, + new() { + OperationName = "PerformImport", + OperationMethod = HttpPost, + Comment = "Perform an import of all settings", + PublicAPIController = "System", + PublicAPIRoute = "system/migration/import", + ServiceAPIRoute = "system/migration/import", + ReturnType = "ICollection", + Params = new Dictionary { { "importRequest", "Certify.Models.Config.Migration.ImportRequest" } } + }, + }; + } + } +} diff --git a/src/Certify.SourceGenerators/Certify.SourceGenerators.csproj b/src/Certify.SourceGenerators/Certify.SourceGenerators.csproj new file mode 100644 index 000000000..95ec81c7e --- /dev/null +++ b/src/Certify.SourceGenerators/Certify.SourceGenerators.csproj @@ -0,0 +1,23 @@ + + + + netstandard2.0 + true + latest + + + + 1701;1702;RS1035 + + + + 1701;1702;RS1035 + + + + + + + + + diff --git a/src/Certify.SourceGenerators/PublicAPISourceGenerator.cs b/src/Certify.SourceGenerators/PublicAPISourceGenerator.cs new file mode 100644 index 000000000..388ea23b2 --- /dev/null +++ b/src/Certify.SourceGenerators/PublicAPISourceGenerator.cs @@ -0,0 +1,329 @@ +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text; +using Certify.SourceGenerators; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace SourceGenerator +{ + public class GeneratedAPI + { + public string OperationName { get; set; } = string.Empty; + public string OperationMethod { get; set; } = string.Empty; + public string Comment { get; set; } = string.Empty; + public string PublicAPIController { get; set; } = string.Empty; + + public string PublicAPIRoute { get; set; } = string.Empty; + public List RequiredPermissions { get; set; } = []; + public bool UseManagementAPI { get; set; } = false; + public string ManagementHubCommandType { get; set; } = string.Empty; + public string ServiceAPIRoute { get; set; } = string.Empty; + public string ReturnType { get; set; } = string.Empty; + public Dictionary Params { get; set; } = new Dictionary(); + } + + public class PermissionSpec + { + public string ResourceType { get; set; } + public string Action { get; set; } + public PermissionSpec(string resourceType, string action) + { + ResourceType = resourceType; + Action = action; + } + } + [Generator] + public class PublicAPISourceGenerator : ISourceGenerator + { + public void Execute(GeneratorExecutionContext context) + { + + // get list of items we want to generate for our API glue + var list = ApiMethods.GetApiDefinitions(); + + Debug.WriteLine(context.Compilation.AssemblyName); + + foreach (var config in list) + { + var paramSet = config.Params.ToList(); + paramSet.Add(new KeyValuePair("authContext", "AuthContext")); + var apiParamDecl = paramSet.Any() ? string.Join(", ", paramSet.Select(p => $"{p.Value} {p.Key}")) : ""; + var apiParamDeclWithoutAuthContext = config.Params.Any() ? string.Join(", ", config.Params.Select(p => $"{p.Value} {p.Key}")) : ""; + + var apiParamCall = paramSet.Any() ? string.Join(", ", paramSet.Select(p => $"{p.Key}")) : ""; + var apiParamCallWithoutAuthContext = config.Params.Any() ? string.Join(", ", config.Params.Select(p => $"{p.Key}")) : ""; + + if (context.Compilation.AssemblyName.EndsWith("Hub.Api") && config.PublicAPIController != null) + { + ImplementPublicAPI(context, config, apiParamDeclWithoutAuthContext, apiParamDecl, apiParamCall); + } + + if (context.Compilation.AssemblyName.EndsWith("Certify.UI.Blazor")) + { + ImplementAppModel(context, config, apiParamDeclWithoutAuthContext, apiParamCallWithoutAuthContext); + } + + if (context.Compilation.AssemblyName.EndsWith("Certify.Client") && !config.UseManagementAPI) + { + // for methods which directly call the backend service (e.g. main server settings), implement the client API + ImplementInternalAPIClient(context, config, apiParamDecl, apiParamCall); + } + } + } + + private static void ImplementAppModel(GeneratorExecutionContext context, GeneratedAPI config, string apiParamDeclWithoutAuthContext, string apiParamCallWithoutAuthContext) + { + context.AddSource($"AppModel.{config.OperationName}.g.cs", SourceText.From($@" + using System.Collections.Generic; + using System.Threading.Tasks; + using Certify.Models; + using Certify.Models.Providers; + using Certify.Models.Hub; + + namespace Certify.UI.Client.Core + {{ + public partial class AppModel + {{ + public async Task<{config.ReturnType}> {config.OperationName}({apiParamDeclWithoutAuthContext}) + {{ + return await _api.{config.OperationName}Async({apiParamCallWithoutAuthContext}); + }} + }} + }} + ", Encoding.UTF8)); + } + + private static void ImplementPublicAPI(GeneratorExecutionContext context, GeneratedAPI config, string apiParamDeclWithoutAuthContext, string apiParamDecl, string apiParamCall) + { + var publicApiSrc = $@" + + using Certify.Client; + using Certify.Server.Hub.Api.Controllers; + using Microsoft.AspNetCore.Authentication.JwtBearer; + using Microsoft.AspNetCore.Authorization; + using System.Collections.Generic; + using System.Threading.Tasks; + using Microsoft.AspNetCore.Http; + using Microsoft.AspNetCore.Mvc; + using Microsoft.Extensions.Logging; + using Certify.Models; + using Certify.Models.Hub; + + + namespace Certify.Server.Hub.Api.Controllers + {{ + public partial class {config.PublicAPIController}Controller + {{ + /// + /// {config.Comment} [Generated] + /// + /// + [{config.OperationMethod}] + [Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof({config.ReturnType}))] + [Route(""""""{config.PublicAPIRoute}"""""")] + public async Task {config.OperationName}({apiParamDeclWithoutAuthContext}) + {{ + + [RequiredPermissions] + + var result = await {(config.UseManagementAPI ? "_mgmtAPI" : "_client")}.{config.OperationName}({apiParamCall.Replace("authContext", "CurrentAuthContext")}); + return new OkObjectResult(result); + }} + }} + }}; + "; + + if (config.RequiredPermissions.Any()) + { + var fragment = ""; + foreach (var perm in config.RequiredPermissions) + { + fragment += $@" + if (!await IsAuthorized(_client, ""{perm.ResourceType}"" , ""{perm.Action}"")) + {{ + {{ + return Unauthorized(); + }} + }} + "; + } + + publicApiSrc = publicApiSrc.Replace("[RequiredPermissions]", fragment); + } + else + { + publicApiSrc = publicApiSrc.Replace("[RequiredPermissions]", ""); + } + + context.AddSource($"{config.PublicAPIController}Controller.{config.OperationName}.g.cs", SourceText.From(publicApiSrc, Encoding.UTF8)); + + // Management API service + + if (!string.IsNullOrEmpty(config.ManagementHubCommandType)) + { + var src = $@" + + using Certify.Client; + using Certify.Models.Hub; + using Certify.Models; + using Certify.Models.Config; + using Certify.Models.Providers; + using Certify.Models.Reporting; + using Microsoft.AspNetCore.SignalR; + + namespace Certify.Server.Hub.Api.Services + {{ + public partial class ManagementAPI + {{ + /// + /// {config.Comment} [Generated] + /// + /// + internal async Task<{config.ReturnType}> {config.OperationName}({apiParamDecl}) + {{ + var args = new KeyValuePair[] {{ + {string.Join(",", config.Params.Select(s => $"new (\"{s.Key}\", {s.Key})").ToArray())} + }}; + + return await PerformInstanceCommandTaskWithResult<{config.ReturnType}>(instanceId, args, ""{config.ManagementHubCommandType}"") ?? []; + }} + }} + }} + "; + context.AddSource($"ManagementAPI.{config.OperationName}.g.cs", SourceText.From(src, Encoding.UTF8)); + } + } + + private static void ImplementInternalAPIClient(GeneratorExecutionContext context, GeneratedAPI config, string apiParamDecl, string apiParamCall) + { + var template = @" + using Certify.Models; + using Certify.Models.Config.Migration; + using Certify.Models.Providers; + using Certify.Models.Hub; + using System.Collections.Generic; + using System.Threading.Tasks; + + namespace Certify.Client + { + MethodTemplate + } + "; + + if (config.OperationMethod == "HttpGet") + { + var code = template.Replace("MethodTemplate", $@" + + public partial interface ICertifyInternalApiClient + {{ + /// + /// {config.Comment} [Generated] + /// + /// + Task<{config.ReturnType}> {config.OperationName}({apiParamDecl}); + + }} + + public partial class CertifyApiClient + {{ + /// + /// {config.Comment} [Generated] + /// + /// + public async Task<{config.ReturnType}> {config.OperationName}({apiParamDecl}) + {{ + var result = await FetchAsync($""{config.ServiceAPIRoute}"", authContext); + return JsonToObject<{config.ReturnType}>(result); + }} + + }} + "); + var source = SourceText.From(code, Encoding.UTF8); + context.AddSource($"{config.PublicAPIController}.{config.OperationName}.ICertifyInternalApiClient.g.cs", source); + } + + if (config.OperationMethod == "HttpPost") + { + var postAPIRoute = config.ServiceAPIRoute; + var postApiCall = apiParamCall; + var postApiParamDecl = apiParamDecl; + + if (config.UseManagementAPI) + { + postApiCall = apiParamCall.Replace("instanceId,", ""); + postApiParamDecl = apiParamDecl.Replace("string instanceId,", ""); + } + + context.AddSource($"{config.PublicAPIController}.{config.OperationName}.ICertifyInternalApiClient.g.cs", SourceText.From(template.Replace("MethodTemplate", $@" + + public partial interface ICertifyInternalApiClient + {{ + /// + /// {config.Comment} [Generated] + /// + /// + Task<{config.ReturnType}> {config.OperationName}({postApiParamDecl}); + + }} + + public partial class CertifyApiClient + {{ + /// + /// {config.Comment} [Generated] + /// + /// + public async Task<{config.ReturnType}> {config.OperationName}({postApiParamDecl}) + {{ + var result = await PostAsync($""{postAPIRoute}"", {postApiCall}); + return JsonToObject<{config.ReturnType}>(await result.Content.ReadAsStringAsync()); + }} + }} + "), Encoding.UTF8)); + } + + if (config.OperationMethod == "HttpDelete") + { + context.AddSource($"{config.PublicAPIController}.{config.OperationName}.ICertifyInternalApiClient.g.cs", SourceText.From(template.Replace("MethodTemplate", $@" + + public partial interface ICertifyInternalApiClient + {{ + /// + /// {config.Comment} [Generated] + /// + /// + Task<{config.ReturnType}> {config.OperationName}({apiParamDecl}); + }} + + public partial class CertifyApiClient + {{ + /// + /// {config.Comment} [Generated] + /// + /// + public async Task<{config.ReturnType}> {config.OperationName}({apiParamDecl}) + {{ + var route = $""{config.ServiceAPIRoute}""; + var result = await DeleteAsync(route, authContext); + return JsonToObject<{config.ReturnType}>(await result.Content.ReadAsStringAsync()); + }} + }} + "), Encoding.UTF8)); + } + } + + public void Initialize(GeneratorInitializationContext context) + { +#if DEBUG + // uncomment this to launch a debug session which code generation runs + // then add a watch on + if (!Debugger.IsAttached) + { + // Debugger.Launch(); + } +#endif + } + } +} diff --git a/src/Certify.Tests/Certify.Core.Tests.Integration/CertRequestTests.cs b/src/Certify.Tests/Certify.Core.Tests.Integration/CertRequestTests.cs index 230c91ddc..6fc3ef24a 100644 --- a/src/Certify.Tests/Certify.Core.Tests.Integration/CertRequestTests.cs +++ b/src/Certify.Tests/Certify.Core.Tests.Integration/CertRequestTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; @@ -11,10 +11,11 @@ using Certify.Models; using Certify.Providers.ACME.Anvil; using Microsoft.VisualStudio.TestTools.UnitTesting; -using Serilog; namespace Certify.Core.Tests { + +#pragma warning disable CS0618 // Type or member is obsolete [TestClass] /// /// Integration tests for CertifyManager @@ -33,11 +34,7 @@ public class CertRequestTests : IntegrationTestBase, IDisposable public CertRequestTests() { - var log = new LoggerConfiguration() - .WriteTo.Debug() - .CreateLogger(); - _log = new Loggy(log); certifyManager = new CertifyManager(); certifyManager.Init().Wait(); @@ -47,7 +44,7 @@ public CertRequestTests() PrimaryTestDomain = ConfigSettings["Cloudflare_TestDomain"]; testSiteDomain = "integration1." + PrimaryTestDomain; - testSitePath = _primaryWebRoot; + testSitePath = PrimaryWebRootPath; _testCredStorageKey = ConfigSettings["TestCredentialsKey_Cloudflare"]; @@ -72,7 +69,7 @@ public async Task SetupIIS() await iisManager.DeleteSite(testSiteName); } - var site = await iisManager.CreateSite(testSiteName, testSiteDomain, _primaryWebRoot, "DefaultAppPool", port: testSiteHttpPort); + var site = await iisManager.CreateSite(testSiteName, testSiteDomain, PrimaryWebRootPath, "DefaultAppPool", port: testSiteHttpPort); Assert.IsTrue(await iisManager.SiteExists(testSiteName)); _siteId = site.Id.ToString(); } @@ -81,6 +78,7 @@ public async Task TeardownIIS() { await iisManager.DeleteSite(testSiteName); Assert.IsFalse(await iisManager.SiteExists(testSiteName)); + certifyManager.Dispose(); } [TestMethod, TestCategory("MegaTest")] @@ -529,7 +527,7 @@ public async Task TestChallengeRequestDNSWildcard() await iisManager.DeleteSite(testWildcardSiteName); } - var site = await iisManager.CreateSite(testWildcardSiteName, "test" + testStr + "." + PrimaryTestDomain, _primaryWebRoot, "DefaultAppPool", port: testSiteHttpPort); + var site = await iisManager.CreateSite(testWildcardSiteName, "test" + testStr + "." + PrimaryTestDomain, PrimaryWebRootPath, "DefaultAppPool", port: testSiteHttpPort); ManagedCertificate managedCertificate = null; X509Certificate2 certInfo = null; @@ -611,7 +609,7 @@ public async Task TestChallengeRequestDNSWildcard() } [TestMethod] - public async Task TestRequestTnAuthCSR() + public void TestRequestTnAuthCSR() { var pemKey = ConfigSettings["TestAuthTokenPrivateKey"]; @@ -687,37 +685,49 @@ public async Task TestRequestTnAuthList() var result = await certifyManager.PerformCertificateRequest(_log, dummyManagedCertificate); - //ensure cert request was successful - Assert.IsTrue(result.IsSuccess, "Certificate Request Not Completed"); + X509Certificate2 certInfo = null; - //check details of cert, subject alternative name should include domain and expiry must be great than 89 days in the future - var managedCertificates = await certifyManager.GetManagedCertificates(new ManagedCertificateFilter { Id = dummyManagedCertificate.Id }); - var managedCertificate = managedCertificates.FirstOrDefault(m => m.Id == dummyManagedCertificate.Id); + try + { + //ensure cert request was successful + Assert.IsTrue(result.IsSuccess, "Certificate Request Not Completed"); - //emsure we have a new managed site - Assert.IsNotNull(managedCertificate); + //check details of cert, subject alternative name should include domain and expiry must be great than 89 days in the future + var managedCertificates = await certifyManager.GetManagedCertificates(new ManagedCertificateFilter { Id = dummyManagedCertificate.Id }); + var managedCertificate = managedCertificates.FirstOrDefault(m => m.Id == dummyManagedCertificate.Id); - //have cert file details - Assert.IsNotNull(managedCertificate.CertificatePath); + //ensure we have a new managed site + Assert.IsNotNull(managedCertificate); - var fileExists = System.IO.File.Exists(managedCertificate.CertificatePath); - Assert.IsTrue(fileExists); + //have cert file details + Assert.IsNotNull(managedCertificate.CertificatePath); - //check cert is correct - var certInfo = CertificateManager.LoadCertificate(managedCertificate.CertificatePath); - Assert.IsNotNull(certInfo); + var fileExists = System.IO.File.Exists(managedCertificate.CertificatePath); + Assert.IsTrue(fileExists); - var isRecentlyCreated = Math.Abs((DateTimeOffset.UtcNow - certInfo.NotBefore).TotalDays) < 2; - Assert.IsTrue(isRecentlyCreated); + //check cert is correct + certInfo = CertificateManager.LoadCertificate(managedCertificate.CertificatePath); + Assert.IsNotNull(certInfo); - var expiresInFuture = (certInfo.NotAfter - DateTimeOffset.UtcNow).TotalDays >= 89; - Assert.IsTrue(expiresInFuture); + var isRecentlyCreated = Math.Abs((DateTimeOffset.UtcNow - certInfo.NotBefore).TotalDays) < 2; + Assert.IsTrue(isRecentlyCreated); - // remove managed site - await certifyManager.DeleteManagedCertificate(managedCertificate.Id); + var expiresInFuture = (certInfo.NotAfter - DateTimeOffset.UtcNow).TotalDays >= 89; + Assert.IsTrue(expiresInFuture); + } + finally + { - // cleanup certificate - CertificateManager.RemoveCertificate(certInfo); + // remove managed site + await certifyManager.DeleteManagedCertificate(dummyManagedCertificate.Id); + + // cleanup certificate + if (certInfo != null) + { + CertificateManager.RemoveCertificate(certInfo); + } + } } } +#pragma warning restore CS0618 // Type or member is obsolete } diff --git a/src/Certify.Tests/Certify.Core.Tests.Integration/CertificateStoreCleanup.cs b/src/Certify.Tests/Certify.Core.Tests.Integration/CertificateStoreCleanup.cs index 5cf46006b..4f359b428 100644 --- a/src/Certify.Tests/Certify.Core.Tests.Integration/CertificateStoreCleanup.cs +++ b/src/Certify.Tests/Certify.Core.Tests.Integration/CertificateStoreCleanup.cs @@ -29,7 +29,7 @@ public async Task TestCertCleanupAtExpiry() await iisManager.DeleteSite(testSiteDomain); } - var site = await iisManager.CreateSite(testSiteDomain, testSiteDomain, _primaryWebRoot, "DefaultAppPool"); + var site = await iisManager.CreateSite(testSiteDomain, testSiteDomain, PrimaryWebRootPath, "DefaultAppPool"); await iisManager.AddOrUpdateSiteBinding( new Models.BindingInfo @@ -92,7 +92,7 @@ public async Task TestCertCleanupByThumbprint() await iisManager.DeleteSite(testSiteDomain); } - var site = await iisManager.CreateSite(testSiteDomain, testSiteDomain, _primaryWebRoot, "DefaultAppPool"); + var site = await iisManager.CreateSite(testSiteDomain, testSiteDomain, PrimaryWebRootPath, "DefaultAppPool"); await iisManager.AddOrUpdateSiteBinding( new Models.BindingInfo @@ -156,7 +156,7 @@ public async Task TestCertCleanupFull() await iisManager.DeleteSite(testSiteDomain); } - var site = await iisManager.CreateSite(testSiteDomain, testSiteDomain, _primaryWebRoot, "DefaultAppPool"); + var site = await iisManager.CreateSite(testSiteDomain, testSiteDomain, PrimaryWebRootPath, "DefaultAppPool"); await iisManager.AddOrUpdateSiteBinding( new Models.BindingInfo @@ -223,7 +223,7 @@ public async Task TestCertCleanupAfterRenewal() await iisManager.DeleteSite(testSiteDomain); } - var site = await iisManager.CreateSite(testSiteDomain, testSiteDomain, _primaryWebRoot, "DefaultAppPool"); + var site = await iisManager.CreateSite(testSiteDomain, testSiteDomain, PrimaryWebRootPath, "DefaultAppPool"); await iisManager.AddOrUpdateSiteBinding( new Models.BindingInfo diff --git a/src/Certify.Tests/Certify.Core.Tests.Integration/Certify.Core.Tests.Integration.csproj b/src/Certify.Tests/Certify.Core.Tests.Integration/Certify.Core.Tests.Integration.csproj index 4e6df448e..4e8fbae1b 100644 --- a/src/Certify.Tests/Certify.Core.Tests.Integration/Certify.Core.Tests.Integration.csproj +++ b/src/Certify.Tests/Certify.Core.Tests.Integration/Certify.Core.Tests.Integration.csproj @@ -1,7 +1,7 @@ - + - net7.0;net462; - Debug;Release;Debug;Release + net9.0;net462; + Debug;Release; Certify.Core.Tests Certify.Core.Tests @@ -13,7 +13,7 @@ DEBUG;TRACE prompt 4 - x64 + AnyCPU pdbonly @@ -23,24 +23,7 @@ prompt 4 - - true - bin\x64\Debug\ - DEBUG;TRACE - full - x64 - prompt - MinimumRecommendedRules.ruleset - - - bin\x64\Release\ - TRACE - true - pdbonly - x64 - prompt - MinimumRecommendedRules.ruleset - + Debug AnyCPU @@ -60,7 +43,19 @@ - x64 + AnyCPU + + + 1701;1702;NU1701 + + + 1701;1702;NU1701 + + + 1701;1702;NU1701 + + + 1701;1702;NU1701 @@ -76,22 +71,23 @@ - + - - - + + + - + + - + diff --git a/src/Certify.Tests/Certify.Core.Tests.Integration/CertifyManagerServerTypeTests.cs b/src/Certify.Tests/Certify.Core.Tests.Integration/CertifyManagerServerTypeTests.cs new file mode 100644 index 000000000..08f4fd600 --- /dev/null +++ b/src/Certify.Tests/Certify.Core.Tests.Integration/CertifyManagerServerTypeTests.cs @@ -0,0 +1,281 @@ +using System; +using System.Threading.Tasks; +using Certify.Management; +using Certify.Management.Servers; +using Certify.Models; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Certify.Core.Tests +{ + [TestClass] + public class CertifyManagerServerTypeTests : IntegrationTestBase, IDisposable + { + private readonly CertifyManager _certifyManager; + private readonly ServerProviderIIS _iisManager; + private readonly string _testSiteName = "Test1ServerTypes"; + private readonly string _testSiteDomain = "integration1.anothertest.com"; + private readonly string _testSiteIp = "192.168.68.20"; + private readonly int _testSiteHttpPort = 80; + private string _testSiteId = ""; + + public CertifyManagerServerTypeTests() + { + // Must set IncludeExternalPlugins to true in C:\ProgramData\certify\appsettings.json and run copy-plugins.bat from certify-internal + _certifyManager = new CertifyManager(); + _certifyManager.Init().Wait(); + + _iisManager = new ServerProviderIIS(); + SetupIIS().Wait(); + } + + public void Dispose() => TeardownIIS().Wait(); + + public async Task SetupIIS() + { + if (await _iisManager.SiteExists(_testSiteName)) + { + await _iisManager.DeleteSite(_testSiteName); + } + + var site = await _iisManager.CreateSite(_testSiteName, _testSiteDomain, PrimaryWebRootPath, "DefaultAppPool", ipAddress: _testSiteIp, port: _testSiteHttpPort); + Assert.IsTrue(await _iisManager.SiteExists(_testSiteName)); + _testSiteId = site.Id.ToString(); + } + + public async Task TeardownIIS() + { + await _iisManager.DeleteSite(_testSiteName); + Assert.IsFalse(await _iisManager.SiteExists(_testSiteName)); + _certifyManager.Dispose(); + } + + [TestMethod, Description("Happy path test for using CertifyManager.GetPrimaryWebSites() for IIS")] + public async Task TestCertifyManagerGetPrimaryWebSitesIIS() + { + // Request websites from CertifyManager.GetPrimaryWebSites() for IIS + var primaryWebsites = await _certifyManager.GetPrimaryWebSites(StandardServerTypes.IIS, true); + + // Evaluate return from CertifyManager.GetPrimaryWebSites() for IIS + Assert.IsNotNull(primaryWebsites, "Expected website list returned by CertifyManager.GetPrimaryWebSites() for IIS sites to not be null"); + Assert.IsTrue(primaryWebsites.Count > 0, "Expected website list returned by CertifyManager.GetPrimaryWebSites() for IIS sites to not be empty"); + Assert.IsTrue(primaryWebsites.Exists(s => s.IsEnabled), "Expected website list returned by CertifyManager.GetPrimaryWebSites() for IIS sites to have at least one enabled site"); + } + + [TestMethod, Description("Happy path test for using CertifyManager.GetPrimaryWebSites() for Apache")] + [Ignore] + public async Task TestCertifyManagerGetPrimaryWebSitesApache() + { + // TODO: Support for Apache via plugin must be added + // This test requires at least one website in Apache to be active + var primaryWebsites = await _certifyManager.GetPrimaryWebSites(StandardServerTypes.Apache, true); + + // Evaluate return from CertifyManager.GetPrimaryWebSites() for Apache + Assert.IsNotNull(primaryWebsites, "Expected website list returned by CertifyManager.GetPrimaryWebSites() for Apache sites to not be null"); + Assert.IsTrue(primaryWebsites.Count > 0, "Expected website list returned by CertifyManager.GetPrimaryWebSites() for Apache sites to not be empty"); + Assert.IsTrue(primaryWebsites.Exists(s => s.IsEnabled), "Expected website list returned by CertifyManager.GetPrimaryWebSites() for Apache sites to have at least one enabled site"); + } + + [TestMethod, Description("Happy path test for using CertifyManager.GetPrimaryWebSites() for Nginx")] + public async Task TestCertifyManagerGetPrimaryWebSitesNginx() + { + // This test requires at least one website in Nginx conf to be defined + var primaryWebsites = await _certifyManager.GetPrimaryWebSites(StandardServerTypes.Nginx, true); + + // Evaluate return from CertifyManager.GetPrimaryWebSites() for Nginx + Assert.IsNotNull(primaryWebsites, "Expected website list returned by CertifyManager.GetPrimaryWebSites() for Nginx sites to not be null"); + Assert.IsTrue(primaryWebsites.Count > 0, "Expected website list returned by CertifyManager.GetPrimaryWebSites() to not be empty"); + Assert.IsTrue(primaryWebsites.Exists(s => s.IsEnabled), "Expected website list returned by CertifyManager.GetPrimaryWebSites() for Nginx sites to have at least one enabled site"); + } + + [TestMethod, Description("Happy path test for using CertifyManager.GetPrimaryWebSites() for IIS using an item id")] + public async Task TestCertifyManagerGetPrimaryWebSitesItemId() + { + // Request website info from CertifyManager.GetPrimaryWebSites() for IIS using Item ID + var itemIdWebsite = await _certifyManager.GetPrimaryWebSites(StandardServerTypes.IIS, true, _testSiteId); + + // Evaluate return from CertifyManager.GetPrimaryWebSites() for IIS using item id + Assert.IsNotNull(itemIdWebsite, "Expected website list returned by CertifyManager.GetPrimaryWebSites() for IIS sites to not be null"); + Assert.AreEqual(1, itemIdWebsite.Count, "Expected website list returned by CertifyManager.GetPrimaryWebSites() for IIS sites to not be empty"); + Assert.AreEqual(_testSiteId, itemIdWebsite[0].Id, "Expected the same Item Id for SiteInfo objects returned by CertifyManager.GetPrimaryWebSites() for IIS sites"); + Assert.AreEqual(_testSiteName, itemIdWebsite[0].Name, "Expected the same Name for SiteInfo objects returned by CertifyManager.GetPrimaryWebSites() for IIS sites"); + } + + [TestMethod, Description("Test for using CertifyManager.GetPrimaryWebSites() for IIS using a bad item id")] + public async Task TestCertifyManagerGetPrimaryWebSitesBadItemId() + { + // Request website from CertifyManager.GetPrimaryWebSites() using a non-existent Item ID + var itemIdWebsite = await _certifyManager.GetPrimaryWebSites(StandardServerTypes.IIS, true, "bad_id"); + + // Evaluate return from CertifyManager.GetPrimaryWebSites() for IIS using a non-existent Item ID + Assert.IsNotNull(itemIdWebsite, "Expected website list returned by CertifyManager.GetPrimaryWebSites() for IIS sites to not be null"); + Assert.AreEqual(1, itemIdWebsite.Count, "Expected website list returned by CertifyManager.GetPrimaryWebSites() for IIS sites to not be empty"); + Assert.IsNull(itemIdWebsite[0], "Expected website list object returned by CertifyManager.GetPrimaryWebSites() for IIS with a bad itemId to be null"); + } + + [TestMethod, Description("Happy path test for using CertifyManager.GetPrimaryWebSites() for IIS including stopped sites")] + public async Task TestCertifyManagerGetPrimaryWebSitesIncludeStoppedSites() + { + // This test requires at least one website in IIS that is stopped + var primaryWebsites = await _certifyManager.GetPrimaryWebSites(StandardServerTypes.IIS, false); + + // Evaluate return from CertifyManager.GetPrimaryWebSites() for IIS + Assert.IsNotNull(primaryWebsites, "Expected website list returned by CertifyManager.GetPrimaryWebSites() for IIS sites to not be null"); + Assert.IsTrue(primaryWebsites.Count > 0, "Expected website list returned by CertifyManager.GetPrimaryWebSites() for IIS sites to not be empty"); + Assert.IsTrue(primaryWebsites.Exists(s => s.IsEnabled), "Expected website list returned by CertifyManager.GetPrimaryWebSites() for IIS sites to have at least one enabled site"); + Assert.IsTrue(primaryWebsites.Exists(s => s.IsEnabled == false), "Expected website list returned by CertifyManager.GetPrimaryWebSites() for IIS sites to have at least one disabled site"); + } + + [TestMethod, Description("Test for using CertifyManager.GetPrimaryWebSites() when server type is not found")] + public async Task TestCertifyManagerGetPrimaryWebSitesServerTypeNotFound() + { + // Request websites from CertifyManager.GetPrimaryWebSites() using StandardServerTypes.Other + var primaryWebsites = await _certifyManager.GetPrimaryWebSites(StandardServerTypes.Other, true); + + // Evaluate return from CertifyManager.GetPrimaryWebSites() for StandardServerTypes.Other + Assert.IsNotNull(primaryWebsites, "Expected website list returned by CertifyManager.GetPrimaryWebSites() for StandardServerTypes.Other to not be null"); + Assert.AreEqual(0, primaryWebsites.Count, "Expected website list returned by CertifyManager.GetPrimaryWebSites() for StandardServerTypes.Other to be empty"); + } + + [TestMethod, Description("Happy path test for using CertifyManager.GetDomainOptionsFromSite() for IIS")] + public async Task TestCertifyManagerGetDomainOptionsFromSite() + { + // Request website Domain Options using Item ID + var siteDomainOptions = await _certifyManager.GetDomainOptionsFromSite(StandardServerTypes.IIS, _testSiteId); + + // Evaluate return from CertifyManager.GetDomainOptionsFromSite() for IIS + Assert.IsNotNull(siteDomainOptions, "Expected domain options list returned by CertifyManager.GetDomainOptionsFromSite() for IIS to not be null"); + Assert.AreEqual(1, siteDomainOptions.Count, "Expected domain options list returned by CertifyManager.GetDomainOptionsFromSite() for IIS to not be empty"); + } + + [TestMethod, Description("Happy path test for using CertifyManager.GetDomainOptionsFromSite() for IIS site with no defined domain")] + public async Task TestCertifyManagerGetDomainOptionsFromSiteNoDomain() + { + // Verify no domain site does not exist from previous test run + var noDomainSiteName = "NoDomainSite"; + if (await _iisManager.SiteExists(noDomainSiteName)) + { + await _iisManager.DeleteSite(noDomainSiteName); + } + + // Add no domain site + var noDomainSite = await _iisManager.CreateSite(noDomainSiteName, "", PrimaryWebRootPath, "DefaultAppPool", port: 81); + Assert.IsTrue(await _iisManager.SiteExists(_testSiteName), "Expected no domain site to be created"); + var noDomainSiteId = noDomainSite.Id.ToString(); + + // Request website Domain Options using Item ID + var siteDomainOptions = await _certifyManager.GetDomainOptionsFromSite(StandardServerTypes.IIS, noDomainSiteId); + + // Evaluate return from CertifyManager.GetDomainOptionsFromSite() for IIS + Assert.IsNotNull(siteDomainOptions, "Expected domain options list returned by CertifyManager.GetDomainOptionsFromSite() for IIS to not be null"); + Assert.AreEqual(0, siteDomainOptions.Count, "Expected domain options list returned by CertifyManager.GetDomainOptionsFromSite() for IIS to be empty"); + + // Remove no domain site + await _iisManager.DeleteSite(noDomainSiteName); + Assert.IsFalse(await _iisManager.SiteExists(noDomainSiteName), "Expected no domain site to be deleted"); + } + + [TestMethod, Description("Test for using CertifyManager.GetDomainOptionsFromSite() when server type is not found")] + public async Task TestCertifyManagerGetDomainOptionsFromSiteServerTypeNotFound() + { + // Request website Domain Options for a non-initialized server type (StandardServerTypes.Other) + var siteDomainOptions = await _certifyManager.GetDomainOptionsFromSite(StandardServerTypes.Other, "1"); + + // Evaluate return from CertifyManager.GetDomainOptionsFromSite() for StandardServerTypes.Other + Assert.IsNotNull(siteDomainOptions, "Expected domain options list returned by CertifyManager.GetDomainOptionsFromSite() for StandardServerTypes.Other to not be null"); + Assert.AreEqual(0, siteDomainOptions.Count, "Expected domain options list returned by CertifyManager.GetDomainOptionsFromSite() for StandardServerTypes.Other to be empty"); + } + + [TestMethod, Description("Test for using CertifyManager.GetDomainOptionsFromSite() for IIS using a bad item id")] + public async Task TestCertifyManagerGetDomainOptionsFromSiteBadItemId() + { + // Request website Domain Options using a non-existent Item ID for IIS + var siteDomainOptions = await _certifyManager.GetDomainOptionsFromSite(StandardServerTypes.IIS, "bad_id"); + + // Evaluate return from CertifyManager.GetDomainOptionsFromSite() using a non-existent Item ID + Assert.IsNotNull(siteDomainOptions, "Expected domain options list returned by CertifyManager.GetDomainOptionsFromSite() to not be null"); + Assert.AreEqual(0, siteDomainOptions.Count, "Expected domain options list returned by CertifyManager.GetDomainOptionsFromSite() for a non-existent Item ID to be empty"); + } + + [TestMethod, Description("Happy path test for using CertifyManager.IsServerTypeAvailable()")] + public async Task TestCertifyManagerIsServerTypeAvailable() + { + // This test requires at least one website in Nginx conf to be defined + var isIisAvailable = await _certifyManager.IsServerTypeAvailable(StandardServerTypes.IIS); + var isNginxAvailable = await _certifyManager.IsServerTypeAvailable(StandardServerTypes.Nginx); + var isApacheAvailable = await _certifyManager.IsServerTypeAvailable(StandardServerTypes.Apache); + var isOtherAvailable = await _certifyManager.IsServerTypeAvailable(StandardServerTypes.Other); + + // Evaluate returns from CertifyManager.IsServerTypeAvailable() + Assert.IsTrue(isIisAvailable, "Expected return from CertifyManager.IsServerTypeAvailable() to be true when at least one IIS site is active"); + + Assert.IsTrue(isNginxAvailable, "Expected return from CertifyManager.IsServerTypeAvailable() to be true when at least one Nginx site is active"); + + Assert.IsFalse(isApacheAvailable, "Expected return from CertifyManager.IsServerTypeAvailable() to be false when Apache plugin does not exist"); + // TODO: Support for Apache via plugin must be added to enable the next assert + //Assert.IsTrue(isApacheAvailable, "Expected return from CertifyManager.IsServerTypeAvailable() to be true when at least one Apache site is active"); + + Assert.IsFalse(isOtherAvailable, "Expected return from CertifyManager.IsServerTypeAvailable() to be false for StandardServerTypes.Other"); + } + + [TestMethod, Description("Happy path test for using CertifyManager.GetServerTypeVersion()")] + public async Task TestCertifyManagerGetServerTypeVersion() + { + // This test requires at least one website in Nginx conf to be defined + var iisServerVersion = await _certifyManager.GetServerTypeVersion(StandardServerTypes.IIS); + var nginxServerVersion = await _certifyManager.GetServerTypeVersion(StandardServerTypes.Nginx); + var apacheServerVersion = await _certifyManager.GetServerTypeVersion(StandardServerTypes.Apache); + var otherServerVersion = await _certifyManager.GetServerTypeVersion(StandardServerTypes.Other); + + var unknownVersion = new Version(0, 0); + + // Evaluate returns from CertifyManager.GetServerTypeVersion() + Assert.AreNotEqual(unknownVersion, iisServerVersion, "Expected return from CertifyManager.GetServerTypeVersion() to be known when at least one IIS site is active"); + Assert.IsTrue(iisServerVersion.Major > 0); + + Assert.AreNotEqual(unknownVersion, nginxServerVersion, "Expected return from CertifyManager.GetServerTypeVersion() to be known when at least one Nginx site is active"); + Assert.IsTrue(nginxServerVersion.Major > 0); + + Assert.AreEqual(unknownVersion, apacheServerVersion, "Expected return from CertifyManager.GetServerTypeVersion() to be unknown when Apache plugin does not exist"); + // TODO: Support for Apache via plugin must be added to enable the next assert + //Assert.AreNotEqual(unknownVersion, isApacheAvailable, "Expected return from CertifyManager.GetServerTypeVersion() to be known when at least one Apache site is active"); + + Assert.AreEqual(unknownVersion, otherServerVersion, "Expected return from CertifyManager.GetServerTypeVersion() to be unknown for StandardServerTypes.Other"); + } + + [TestMethod, Description("Happy path test for using CertifyManager.RunServerDiagnostics() for IIS")] + public async Task TestCertifyManagerRunServerDiagnostics() + { + // Run diagnostics on the IIS site using Item ID + var siteDiagnostics = await _certifyManager.RunServerDiagnostics(StandardServerTypes.IIS, _testSiteId); + + // Evaluate return from CertifyManager.GetPrimaryWebSites() for IIS + Assert.IsNotNull(siteDiagnostics, "Expected diagnostics list returned by CertifyManager.RunServerDiagnostics() for IIS site to not be null"); + Assert.AreEqual(1, siteDiagnostics.Count, "Expected diagnostics list returned by CertifyManager.RunServerDiagnostics() for IIS site to not be empty"); + } + + [TestMethod, Description("Test for using CertifyManager.RunServerDiagnostics() when server type is not found")] + public async Task TestCertifyManagerRunServerDiagnosticsServerTypeNotFound() + { + // Run diagnostics for a non-initialized server type (StandardServerTypes.Other) + var siteDiagnostics = await _certifyManager.RunServerDiagnostics(StandardServerTypes.Other, _testSiteId); + + // Evaluate return from CertifyManager.GetPrimaryWebSites() for StandardServerTypes.Other + Assert.IsNotNull(siteDiagnostics, "Expected diagnostics list returned by CertifyManager.RunServerDiagnostics() for StandardServerTypes.Other to not be null"); + Assert.AreEqual(0, siteDiagnostics.Count, "Expected diagnostics list returned by CertifyManager.RunServerDiagnostics() for StandardServerTypes.Other to be empty"); + } + + [TestMethod, Description("Test for using CertifyManager.RunServerDiagnostics() using a bad item id")] + public async Task TestCertifyManagerRunServerDiagnosticsBadItemId() + { + // Run diagnostics on the IIS site using bad Item ID + var siteDiagnostics = await _certifyManager.RunServerDiagnostics(StandardServerTypes.IIS, "bad_id"); + + // Evaluate return from CertifyManager.GetPrimaryWebSites() for IIS with bad Item ID + Assert.IsNotNull(siteDiagnostics, "Expected diagnostics list returned by CertifyManager.RunServerDiagnostics() for IIS site to not be null"); + + // Note: There seems to be no difference at the moment as to whether the Item ID passed in is valid or not, + // as RunServerDiagnostics() for IIS never uses the passed siteId string (is this intentional?) + Assert.AreEqual(1, siteDiagnostics.Count, "Expected diagnostics list returned by CertifyManager.RunServerDiagnostics() for IIS site to be empty"); + } + } +} diff --git a/src/Certify.Tests/Certify.Core.Tests.Integration/CertifyManagerTests.cs b/src/Certify.Tests/Certify.Core.Tests.Integration/CertifyManagerTests.cs new file mode 100644 index 000000000..14f661f6c --- /dev/null +++ b/src/Certify.Tests/Certify.Core.Tests.Integration/CertifyManagerTests.cs @@ -0,0 +1,211 @@ +using System; +using System.Threading.Tasks; +using Certify.Management; +using Certify.Models; +using Certify.Models.Config.Migration; +using Certify.Service; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Certify.Core.Tests +{ + [TestClass] + public class CertifyManagerTests : IntegrationTestBase + { + private readonly CertifyManager _certifyManager; + + public CertifyManagerTests() + { + _certifyManager = new CertifyManager(); + _certifyManager.Init().Wait(); + } + + [TestCleanup] + public void Cleanup() + { + _certifyManager.Dispose(); + } + + [TestMethod, Description("Happy path test for using CertifyManager.GetACMEProvider()")] + public async Task TestCertifyManagerGetACMEProvider() + { + // Setup account registration info + var testCaId = StandardCertAuthorities.LETS_ENCRYPT; + var contactRegEmail = "admin." + Guid.NewGuid().ToString().Substring(0, 6) + "@test.com"; + var contactRegistration = new ContactRegistration + { + AgreedToTermsAndConditions = true, + CertificateAuthorityId = testCaId, + EmailAddress = contactRegEmail, + ImportedAccountKey = "", + ImportedAccountURI = "", + IsStaging = true + }; + + // Add new ACME account + var addAccountRes = await _certifyManager.AddAccount(contactRegistration); + Assert.IsTrue(addAccountRes.IsSuccess, $"Expected account creation to be successful for {contactRegEmail}"); + var accountDetails = (await _certifyManager.GetAccountRegistrations()).Find(a => a.Email == contactRegEmail); + + // Setup dummy ManagedCertificate + var testUrl = "test.com"; + var dummyManagedCert = new ManagedCertificate { CurrentOrderUri = testUrl, UseStagingMode = true }; + + // Get expected certificate authority staging URI + var expectedAcmeBaseUri = CertificateAuthority.CoreCertificateAuthorities.Find((c) => c.Id == testCaId).StagingAPIEndpoint; + + try + { + // Get results from CertifyManager.GetACMEProvider() + var acmeClientProvider = await _certifyManager.GetACMEProvider(dummyManagedCert, accountDetails); + + // Validate return from CertifyManager.GetACMEProvider() + Assert.IsNotNull(acmeClientProvider, "Expected response from CertifyManager.GetACMEProvider() to not be null"); + Assert.AreEqual(expectedAcmeBaseUri, acmeClientProvider.GetAcmeBaseURI(), "Unexpected CA Base URI in returned value from acmeClientProvider.GetAcmeBaseURI()"); + Assert.AreEqual("Anvil", acmeClientProvider.GetProviderName(), "Unexpected Provider name in returned value from acmeClientProvider.GetProviderName()"); + await Assert.ThrowsExceptionAsync(async () => await acmeClientProvider.GetAcmeAccountStatus(), "Expected acmeClientProvider.GetAcmeAccountStatus() to throw NotImplementedException"); + Assert.IsNotNull(await acmeClientProvider.GetAcmeDirectory(), "Expected acmeClientProvider.GetAcmeDirectory() to return a non-null value"); + } + finally + { + // Remove created ACME account + var removeAccountRes = await _certifyManager.RemoveAccount(accountDetails.StorageKey, true); + Assert.IsTrue(removeAccountRes.IsSuccess, $"Expected account removal to be successful for {contactRegEmail}"); + } + } + + [TestMethod, Description("Test for using CertifyManager.GetACMEProvider() with a null ca account")] + public async Task TestCertifyManagerGetACMEProviderNullCaAccount() + { + // Setup test data + var testUrl = "test.com"; + var dummyManagedCert = new ManagedCertificate { CurrentOrderUri = testUrl, UseStagingMode = true }; + + // Get results from CertifyManager.GetACMEProvider() + var acmeClientProvider = await _certifyManager.GetACMEProvider(dummyManagedCert, null); + + // Validate return from CertifyManager.GetACMEProvider() with null ca account + Assert.IsNull(acmeClientProvider, "Expected response from CertifyManager.GetACMEProvider() to be null"); + } + + [TestMethod, Description("Test for using CertifyManager.GetACMEProvider() with an invalid ca account")] + public async Task TestCertifyManagerGetACMEProviderBadCaAccount() + { + // Setup test data + var testUrl = "test.com"; + var dummyManagedCert = new ManagedCertificate { CurrentOrderUri = testUrl, UseStagingMode = true }; + var account = new AccountDetails + { + AccountKey = "", + AccountURI = "", + Title = "Dev", + Email = "test@certifytheweb.com", + CertificateAuthorityId = "badca.com", + StorageKey = "dev", + IsStagingAccount = true, + }; + + // Get results from CertifyManager.GetACMEProvider() + var acmeClientProvider = await _certifyManager.GetACMEProvider(dummyManagedCert, account); + + // Validate return from CertifyManager.GetACMEProvider() with invalid ca account + Assert.IsNull(acmeClientProvider, "Expected response from CertifyManager.GetACMEProvider() to be null"); + } + + [TestMethod, Description("Happy path test for using CertifyManager.ReportProgress()")] + public async Task TestCertifyManagerReportProgress() + { + // Setup test data + var testUrl = "test.com"; + var dummyManagedCert = new ManagedCertificate { CurrentOrderUri = testUrl, UseStagingMode = true }; + + var progressState = new RequestProgressState(RequestState.Running, "Starting..", dummyManagedCert); + var progressIndicator = new Progress(progressState.ProgressReport); + _certifyManager.SetStatusReporting(new StatusHubReporting()); + + // Set event handler for when Progress changes + var progressChanged = false; + var progressNewState = RequestState.Running; + progressIndicator.ProgressChanged += (obj, e) => + { + progressChanged = true; + progressNewState = e.CurrentState; + }; + + // Execute CertifyManager.ReportProgress() with new Warning state + _certifyManager.ReportProgress(progressIndicator, new RequestProgressState(RequestState.Warning, "Warning message", dummyManagedCert), logThisEvent: true); + await Task.Delay(100); + + // Validate events from CertifyManager.ReportProgress() + Assert.IsTrue(progressChanged, "Expected progressChanged to be true after CertifyManager.ReportProgress() completed"); + Assert.AreEqual(RequestState.Warning, progressNewState, "Expected progressNewState to be changed to RequestState.Warning"); + + // Execute CertifyManager.ReportProgress() with new Error state + progressChanged = false; + _certifyManager.ReportProgress(progressIndicator, new RequestProgressState(RequestState.Error, "Error message", dummyManagedCert), logThisEvent: true); + await Task.Delay(100); + + // Validate events from CertifyManager.ReportProgress() + Assert.IsTrue(progressChanged, "Expected progressChanged to be true after CertifyManager.ReportProgress() completed"); + Assert.AreEqual(RequestState.Error, progressNewState, "Expected progressNewState to be changed to RequestState.Error"); + } + + [TestMethod, Description("Happy path test for using CertifyManager.PerformRenewalTasks()")] + public async Task TestCertifyManagerPerformRenewalTasks() + { + // Get results from CertifyManager.PerformRenewalTasks() + var renewalPerformed = await _certifyManager.PerformRenewalTasks(); + + // Validate return from CertifyManager.PerformRenewalTasks() + Assert.IsTrue(renewalPerformed, "Expected response from CertifyManager.PerformRenewalTasks() to be true"); + } + + [TestMethod, Description("Happy path test for using CertifyManager.PerformExport() and CertifyManager.PerformImport()")] + public async Task TestCertifyManagerPerformExportAndImport() + { + // Setup export test data + var exportReq = new ExportRequest + { + Filter = new ManagedCertificateFilter { }, + Settings = new ExportSettings { ExportAllStoredCredentials = true, EncryptionSecret = "secret" }, + IsPreviewMode = false, + }; + + // Get results from CertifyManager.PerformExport() + var performExportRes = await _certifyManager.PerformExport(exportReq); + + // Validate return from CertifyManager.PerformExport() + Assert.IsNotNull(performExportRes, "Expected response from CertifyManager.PerformExport() to not be null"); + Assert.AreEqual(1, performExportRes.FormatVersion, "Expected FormatVersion of response from CertifyManager.PerformExport() to equal 1 by default"); + Assert.AreEqual("Certify The Web - Exported App Settings", performExportRes.Description, "Unexpected default Description in response from CertifyManager.PerformExport()"); + Assert.AreEqual(0, performExportRes.Errors.Count, "Unexpected Errors in response from CertifyManager.PerformExport()"); + Assert.AreEqual(Certify.Management.Util.GetAppVersion(), performExportRes.SystemVersion?.ToVersion(), "Unexpected SystemVersion in response from CertifyManager.PerformExport()"); + Assert.AreEqual(Environment.MachineName, performExportRes.SourceName, "Unexpected SourceName in response from CertifyManager.PerformExport()"); + Assert.AreEqual(DateTime.Now.Year, performExportRes.ExportDate.Year, "Unexpected ExportDate.Year in response from CertifyManager.PerformExport()"); + Assert.AreEqual(DateTime.Now.Day, performExportRes.ExportDate.Day, "Unexpected ExportDate.Year in response from CertifyManager.PerformExport()"); + Assert.AreEqual(DateTime.Now.Month, performExportRes.ExportDate.Month, "Unexpected ExportDate.Year in response from CertifyManager.PerformExport()"); + + // Setup import test data + var importReq = new ImportRequest + { + Package = performExportRes, + Settings = new ImportSettings { EncryptionSecret = "secret" }, + IsPreviewMode = false, + }; + + // Get results from CertifyManager.PerformImport() + var performImportRes = await _certifyManager.PerformImport(importReq); + + // Validate return from CertifyManager.PerformImport() + Assert.IsNotNull(performImportRes, "Expected response from CertifyManager.PerformImport() to not be null"); + Assert.IsTrue(0 < performImportRes.Count, "Expected response from CertifyManager.PerformImport() to not be an empty list"); + foreach (var step in performImportRes) + { + Assert.AreEqual("Import", step.Category, $"Unexpected Category value in step '{step.Title}' from response of CertifyManager.PerformImport()"); + Assert.IsTrue(!string.IsNullOrEmpty(step.Title), $"Unexpected Title value in step '{step.Title}' from response of CertifyManager.PerformImport()"); + Assert.IsTrue(!string.IsNullOrEmpty(step.Key), $"Unexpected Key value in step '{step.Title}' from response of CertifyManager.PerformImport()"); + Assert.IsFalse(step.HasError, $"Unexpected HasError value in step '{step.Title}' from response of CertifyManager.PerformImport()"); + Assert.IsFalse(step.HasWarning, $"Unexpected HasWarning value in step '{step.Title}' from response of CertifyManager.PerformImport()"); + } + } + } +} diff --git a/src/Certify.Tests/Certify.Core.Tests.Integration/DNS/DnsAPITest.AWSRoute53.cs b/src/Certify.Tests/Certify.Core.Tests.Integration/DNS/DnsAPITest.AWSRoute53.cs index 2513984b3..796867e5a 100644 --- a/src/Certify.Tests/Certify.Core.Tests.Integration/DNS/DnsAPITest.AWSRoute53.cs +++ b/src/Certify.Tests/Certify.Core.Tests.Integration/DNS/DnsAPITest.AWSRoute53.cs @@ -1,11 +1,11 @@ using System.Collections.Generic; using System.Diagnostics; using System.Threading.Tasks; -using Certify.Management; +using Certify.Datastore.SQLite; using Certify.Models.Providers; using Microsoft.VisualStudio.TestTools.UnitTesting; -namespace Certify.Core.Tests +namespace Certify.Core.Tests.DNS { [TestClass] public class DnsAPITestAWSRoute53 : IntegrationTestBase @@ -40,7 +40,7 @@ public async Task TestCreateRecord() Assert.IsTrue(createResult.IsSuccess); stopwatch.Stop(); - System.Diagnostics.Debug.WriteLine($"Create DNS Record {createRequest.RecordName} took {stopwatch.Elapsed.TotalSeconds} seconds"); + Debug.WriteLine($"Create DNS Record {createRequest.RecordName} took {stopwatch.Elapsed.TotalSeconds} seconds"); return createRequest; } @@ -62,7 +62,7 @@ public async Task TestCreateRecords() // also create a duplicate var record2 = await TestCreateRecord(); - System.Diagnostics.Debug.WriteLine($"Cloudflare DNS should now have record {record1.RecordName} with values {record1.RecordValue} and {record2.RecordValue}"); + Debug.WriteLine($"Cloudflare DNS should now have record {record1.RecordName} with values {record1.RecordValue} and {record2.RecordValue}"); } [TestMethod, TestCategory("DNS")] @@ -80,7 +80,7 @@ public async Task TestDeleteRecord() var deleteResult = await _provider.DeleteRecord(deleteRequest); Assert.IsTrue(deleteResult.IsSuccess); - System.Diagnostics.Debug.WriteLine($"Delete DNS Record {deleteRequest.RecordName} took {stopwatch.Elapsed.TotalSeconds} seconds"); + Debug.WriteLine($"Delete DNS Record {deleteRequest.RecordName} took {stopwatch.Elapsed.TotalSeconds} seconds"); } [TestMethod, TestCategory("DNS")] diff --git a/src/Certify.Tests/Certify.Core.Tests.Integration/DNS/DnsAPITest.Azure.cs b/src/Certify.Tests/Certify.Core.Tests.Integration/DNS/DnsAPITest.Azure.cs index bb2ed8933..733936dc1 100644 --- a/src/Certify.Tests/Certify.Core.Tests.Integration/DNS/DnsAPITest.Azure.cs +++ b/src/Certify.Tests/Certify.Core.Tests.Integration/DNS/DnsAPITest.Azure.cs @@ -1,11 +1,11 @@ using System.Collections.Generic; using System.Diagnostics; using System.Threading.Tasks; -using Certify.Management; +using Certify.Datastore.SQLite; using Certify.Models.Providers; using Microsoft.VisualStudio.TestTools.UnitTesting; -namespace Certify.Core.Tests +namespace Certify.Core.Tests.DNS { [TestClass] [Ignore("Requires credential setup")] @@ -41,7 +41,7 @@ private async Task TestCreateRecord() Assert.IsTrue(createResult.IsSuccess); stopwatch.Stop(); - System.Diagnostics.Debug.WriteLine($"Create DNS Record {createRequest.RecordName} took {stopwatch.Elapsed.TotalSeconds} seconds"); + Debug.WriteLine($"Create DNS Record {createRequest.RecordName} took {stopwatch.Elapsed.TotalSeconds} seconds"); return createRequest; } @@ -63,7 +63,7 @@ public async Task TestCreateRecords() // also create a duplicate var record2 = await TestCreateRecord(); - System.Diagnostics.Debug.WriteLine($"Azure DNS should now have record {record1.RecordName} with values {record1.RecordValue} and {record2.RecordValue}"); + Debug.WriteLine($"Azure DNS should now have record {record1.RecordName} with values {record1.RecordValue} and {record2.RecordValue}"); } [TestMethod, TestCategory("DNS")] @@ -81,7 +81,7 @@ public async Task TestDeleteRecord() var deleteResult = await _provider.DeleteRecord(deleteRequest); Assert.IsTrue(deleteResult.IsSuccess); - System.Diagnostics.Debug.WriteLine($"Delete DNS Record {deleteRequest.RecordName} took {stopwatch.Elapsed.TotalSeconds} seconds"); + Debug.WriteLine($"Delete DNS Record {deleteRequest.RecordName} took {stopwatch.Elapsed.TotalSeconds} seconds"); } } } diff --git a/src/Certify.Tests/Certify.Core.Tests.Integration/DNS/DnsAPITest.Cloudflare.cs b/src/Certify.Tests/Certify.Core.Tests.Integration/DNS/DnsAPITest.Cloudflare.cs index 3657e7f57..906999019 100644 --- a/src/Certify.Tests/Certify.Core.Tests.Integration/DNS/DnsAPITest.Cloudflare.cs +++ b/src/Certify.Tests/Certify.Core.Tests.Integration/DNS/DnsAPITest.Cloudflare.cs @@ -1,11 +1,11 @@ using System.Collections.Generic; using System.Diagnostics; using System.Threading.Tasks; -using Certify.Management; +using Certify.Datastore.SQLite; using Certify.Models.Providers; using Microsoft.VisualStudio.TestTools.UnitTesting; -namespace Certify.Core.Tests +namespace Certify.Core.Tests.DNS { [TestClass] public class DnsAPITestCloudflare : IntegrationTestBase @@ -33,7 +33,7 @@ public async Task TestCreateRecord() Assert.IsTrue(createResult.IsSuccess); stopwatch.Stop(); - System.Diagnostics.Debug.WriteLine($"Create DNS Record {createRequest.RecordName} took {stopwatch.Elapsed.TotalSeconds} seconds"); + Debug.WriteLine($"Create DNS Record {createRequest.RecordName} took {stopwatch.Elapsed.TotalSeconds} seconds"); return createRequest; } @@ -62,7 +62,7 @@ public async Task TestCreateRecords() // also create a duplicate var record2 = await TestCreateRecord(); - System.Diagnostics.Debug.WriteLine($"Cloudflare DNS should now have record {record1.RecordName} with values {record1.RecordValue} and {record2.RecordValue}"); + Debug.WriteLine($"Cloudflare DNS should now have record {record1.RecordName} with values {record1.RecordValue} and {record2.RecordValue}"); } [TestMethod, TestCategory("DNS")] @@ -80,7 +80,7 @@ public async Task TestDeleteRecord() var deleteResult = await _provider.DeleteRecord(deleteRequest); Assert.IsTrue(deleteResult.IsSuccess); - System.Diagnostics.Debug.WriteLine($"Delete DNS Record {deleteRequest.RecordName} took {stopwatch.Elapsed.TotalSeconds} seconds"); + Debug.WriteLine($"Delete DNS Record {deleteRequest.RecordName} took {stopwatch.Elapsed.TotalSeconds} seconds"); } } } diff --git a/src/Certify.Tests/Certify.Core.Tests.Integration/DataStores/AccessControlDataStoreTests.cs b/src/Certify.Tests/Certify.Core.Tests.Integration/DataStores/AccessControlDataStoreTests.cs new file mode 100644 index 000000000..697a14004 --- /dev/null +++ b/src/Certify.Tests/Certify.Core.Tests.Integration/DataStores/AccessControlDataStoreTests.cs @@ -0,0 +1,190 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Certify.Core.Management.Access; +using Certify.Datastore.SQLite; +using Certify.Models.Hub; +using Certify.Providers; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Certify.Core.Tests.DataStores +{ + [TestClass] + public class AccessControlDataStoreTests + { + private string _storeType = "sqlite"; + private const string TEST_PATH = "Tests"; + + public static IEnumerable TestDataStores + { + get + { + return new[] + { + new object[] { "sqlite" }, + //new object[] { "postgres" }, + //new object[] { "sqlserver" } + }; + } + } + + private IConfigurationStore GetStore(string storeType = null) + { + IConfigurationStore store = null; + + if (storeType == null) + { + storeType = _storeType; + } + + if (storeType == "sqlite") + { + store = new SQLiteConfigurationStore(storageSubfolder: TEST_PATH); + } + /* else if (storeType == "postgres") + { + return new PostgresCredentialStore(Environment.GetEnvironmentVariable("CERTIFY_TEST_POSTGRES")); + } + else if (storeType == "sqlserver") + { + return new SQLServerCredentialStore(Environment.GetEnvironmentVariable("CERTIFY_TEST_SQLSERVER")); + }*/ + else + { + throw new ArgumentOutOfRangeException(nameof(storeType), "Unsupported store type " + storeType); + } + + return store; + } + + [TestMethod] + [DynamicData(nameof(TestDataStores))] + public async Task TestStoreSecurityPrinciple(string storeType) + { + var store = GetStore(storeType ?? _storeType); + + var sp = new SecurityPrinciple + { + Email = "test@test.com", + PrincipleType = SecurityPrincipleType.User, + Username = "test", + Provider = StandardIdentityProviders.INTERNAL + }; + + try + { + await store.Add(nameof(SecurityPrinciple), sp); + + var list = await store.GetItems(nameof(SecurityPrinciple)); + + Assert.IsTrue(list.Any(l => l.Id == sp.Id), "Security Principle retrieved"); + } + finally + { + // cleanup + await store.Delete(nameof(SecurityPrinciple), sp.Id); + } + } + + [TestMethod] + [DynamicData(nameof(TestDataStores))] + public async Task TestStoreRole(string storeType) + { + var store = GetStore(storeType ?? _storeType); + + var role1 = new Role("test", "Test Role", "A test role"); + var role2 = new Role("test2", "Test Role 2", "A test role 2"); + + try + { + await store.Add(nameof(Role), role1); + await store.Add(nameof(Role), role2); + + var item = await store.Get(nameof(Role), role1.Id); + + Assert.IsTrue(item.Id == role1.Id, "Role retrieved"); + } + finally + { + // cleanup + await store.Delete(nameof(Role), role1.Id); + await store.Delete(nameof(Role), role2.Id); + } + } + + [TestMethod] + public void TestStorePasswordHashing() + { + var store = GetStore(_storeType); + var access = new AccessControl(null, store); + + var firstHash = access.HashPassword("secret"); + + Assert.IsNotNull(firstHash); + + Assert.IsTrue(access.IsPasswordValid("secret", firstHash)); + } + + [TestMethod] + [DynamicData(nameof(TestDataStores))] + public async Task TestStoreGeneralAccessControl(string storeType) + { + + var store = GetStore(storeType ?? _storeType); + + var access = new AccessControl(null, store); + + var adminSp = new SecurityPrinciple + { + Id = "admin_01", + Email = "admin@test.com", + Description = "Primary test admin", + PrincipleType = SecurityPrincipleType.User, + Username = "admin01", + Provider = StandardIdentityProviders.INTERNAL + }; + + var consumerSp = new SecurityPrinciple + { + Id = "dev_01", + Email = "dev_test01@test.com", + Description = "Consumer test", + PrincipleType = SecurityPrincipleType.User, + Username = "dev01", + Password = "oldpassword", + Provider = StandardIdentityProviders.INTERNAL + }; + + try + { + var list = await access.GetSecurityPrinciples(adminSp.Id); + + // add first admin security principle, bypass role check as there is no user to check yet + + await access.AddSecurityPrinciple(adminSp.Id, adminSp, bypassIntegrityCheck: true); + + await access.AddAssignedRole(adminSp.Id, new AssignedRole { Id = new Guid().ToString(), SecurityPrincipleId = adminSp.Id, RoleId = StandardRoles.Administrator.Id }); + + // add second security principle, bypass role check as this is just a data store test + var added = await access.AddSecurityPrinciple(adminSp.Id, consumerSp, bypassIntegrityCheck: true); + + Assert.IsTrue(added, "Should be able to add a security principle"); + + list = await access.GetSecurityPrinciples(adminSp.Id); + + Assert.IsTrue(list.Any(), "Should have security principles in store"); + + // get updated sp so that password is hashed for comparison check + consumerSp = await access.GetSecurityPrinciple(adminSp.Id, consumerSp.Id); + + Assert.IsTrue(access.IsPasswordValid("oldpassword", consumerSp.Password)); + } + finally + { + await access.DeleteSecurityPrinciple(adminSp.Id, consumerSp.Id); + await access.DeleteSecurityPrinciple(adminSp.Id, adminSp.Id, allowSelfDelete: true); + } + } + } +} diff --git a/src/Certify.Tests/Certify.Core.Tests.Integration/ManagedItemDataStoreTests.cs b/src/Certify.Tests/Certify.Core.Tests.Integration/DataStores/ManagedItemDataStoreTests.cs similarity index 88% rename from src/Certify.Tests/Certify.Core.Tests.Integration/ManagedItemDataStoreTests.cs rename to src/Certify.Tests/Certify.Core.Tests.Integration/DataStores/ManagedItemDataStoreTests.cs index 2bde078e2..39ed09e60 100644 --- a/src/Certify.Tests/Certify.Core.Tests.Integration/ManagedItemDataStoreTests.cs +++ b/src/Certify.Tests/Certify.Core.Tests.Integration/DataStores/ManagedItemDataStoreTests.cs @@ -11,7 +11,7 @@ using Certify.Providers; using Microsoft.VisualStudio.TestTools.UnitTesting; -namespace Certify.Core.Tests +namespace Certify.Core.Tests.DataStores { [TestClass] public class ManagedItemDataStoreTests @@ -46,7 +46,7 @@ private IManagedItemStore GetManagedItemStore(string storeType = null) if (storeType == "sqlite") { - return new SQLiteManagedItemStore(TEST_PATH, highPerformanceMode: true); + return new SQLiteManagedItemStore(TEST_PATH); } else if (storeType == "postgres") { @@ -58,7 +58,7 @@ private IManagedItemStore GetManagedItemStore(string storeType = null) } else { - throw new ArgumentOutOfRangeException(nameof(storeType), "Unsupport store type " + storeType); + throw new ArgumentOutOfRangeException(nameof(storeType), "Unsupported store type " + storeType); } } @@ -101,9 +101,13 @@ public async Task TestLoadManagedCertificates(string storeType = null) try { var managedCertificate = await itemManager.Update(testCert); + var filter = new ManagedCertificateFilter { MaxResults = 10 }; + var managedCertificates = await itemManager.Find(filter); - var managedCertificates = await itemManager.Find(new ManagedCertificateFilter { MaxResults = 10 }); Assert.IsTrue(managedCertificates.Count > 0); + + var total = await itemManager.CountAll(filter); + Assert.IsTrue(total > 0); } finally { @@ -342,11 +346,12 @@ public async Task TestManagedCertificateFilters(string storeType = null) newTestItem.Name = "FilterMultiTest_" + i; newTestItem.Id = Guid.NewGuid().ToString(); newTestItem.RequestConfig.PrimaryDomain = i + "_" + testItem.RequestConfig.PrimaryDomain; - newTestItem.DateExpiry = DateTimeOffset.UtcNow.AddDays(new Random().Next(5, 90)); - newTestItem.DateStart = DateTimeOffset.UtcNow.AddDays(-new Random().Next(1, 30)); - newTestItem.DateLastOcspCheck = DateTimeOffset.UtcNow.AddMinutes(-new Random().Next(1, 60)); - newTestItem.DateLastRenewalInfoCheck = DateTimeOffset.UtcNow.AddMinutes(-new Random().Next(1, 30)); - newTestItem.DateRenewed = DateTimeOffset.UtcNow.AddDays(-new Random().Next(1, 30)); + newTestItem.DateExpiry = DateTimeOffset.UtcNow.AddDays(rnd.Next(5, 90)); + newTestItem.DateStart = newTestItem.DateExpiry.Value.AddDays(-rnd.Next(1, 30)); + newTestItem.DateLastOcspCheck = DateTimeOffset.UtcNow.AddMinutes(-rnd.Next(1, 60)); + newTestItem.DateLastRenewalInfoCheck = DateTimeOffset.UtcNow.AddMinutes(-rnd.Next(1, 30)); + newTestItem.DateRenewed = newTestItem.DateStart; + newTestItem.DateLastRenewalAttempt = newTestItem.DateRenewed; if (rnd.Next(0, 10) >= 8) { @@ -365,11 +370,12 @@ public async Task TestManagedCertificateFilters(string storeType = null) newTestItem.Name = "ExtraMultiTest_" + i; newTestItem.Id = Guid.NewGuid().ToString(); newTestItem.RequestConfig.PrimaryDomain = i + "_" + testItem.RequestConfig.PrimaryDomain; - newTestItem.DateExpiry = DateTimeOffset.UtcNow.AddDays(new Random().Next(5, 90)); - newTestItem.DateStart = DateTimeOffset.UtcNow.AddDays(-new Random().Next(1, 30)); - newTestItem.DateLastOcspCheck = DateTimeOffset.UtcNow.AddMinutes(-new Random().Next(1, 30)); - newTestItem.DateLastRenewalInfoCheck = DateTimeOffset.UtcNow.AddMinutes(-new Random().Next(1, 30)); - newTestItem.DateRenewed = DateTimeOffset.UtcNow.AddDays(-new Random().Next(1, 30)); + newTestItem.DateExpiry = DateTimeOffset.UtcNow.AddDays(rnd.Next(5, 90)); + newTestItem.DateStart = DateTimeOffset.UtcNow.AddDays(-rnd.Next(1, 30)); + newTestItem.DateLastOcspCheck = DateTimeOffset.UtcNow.AddMinutes(-rnd.Next(1, 30)); + newTestItem.DateLastRenewalInfoCheck = DateTimeOffset.UtcNow.AddMinutes(-rnd.Next(1, 30)); + newTestItem.DateRenewed = DateTimeOffset.UtcNow.AddDays(-rnd.Next(1, 30)); + newTestItem.DateLastRenewalAttempt = newTestItem.DateRenewed; inMemoryList.Add(newTestItem); } @@ -423,6 +429,7 @@ public async Task TestManagedCertificateFilters(string storeType = null) new ManagedCertificateFilter { Keyword = "FilterMultiTest_", PageIndex=0, PageSize =5, FilterDescription="Paging test 0" }, new ManagedCertificateFilter { Keyword = "FilterMultiTest_", PageIndex=1, PageSize =5, FilterDescription="Paging test 1" }, new ManagedCertificateFilter { Keyword = "FilterMultiTest_", PageIndex=2, PageSize =5, FilterDescription="Paging test 3" }, + new ManagedCertificateFilter { Keyword = "FilterMultiTest_", PageIndex=2, PageSize =5, FilterDescription="Paging test 4 with sorting by renewal date", OrderBy= ManagedCertificateFilter.SortMode.RENEWAL_ASC }, new ManagedCertificateFilter { Keyword = "FilterMultiTest_", ChallengeType ="http-01", FilterDescription="Challenge type filter"}, new ManagedCertificateFilter { Keyword = "FilterMultiTest_", ChallengeProvider ="A.Test.Provider", FilterDescription="Challenge provider filter"}, new ManagedCertificateFilter { Keyword = "FilterMultiTest_", StoredCredentialKey ="ABCD123", FilterDescription="Stored Credential filter"} @@ -443,9 +450,21 @@ public async Task TestManagedCertificateFilters(string storeType = null) && (filter.ChallengeType == null || i.RequestConfig.Challenges.Any(c => c.ChallengeType == filter.ChallengeType)) && (filter.ChallengeProvider == null || i.RequestConfig.Challenges.Any(c => c.ChallengeProvider == filter.ChallengeProvider)) && (filter.StoredCredentialKey == null || i.RequestConfig.Challenges.Any(c => c.ChallengeCredentialKey == filter.StoredCredentialKey)) - ) - .OrderBy(t => t.Name) - .AsQueryable(); + ).AsQueryable(); + + if (filter.OrderBy == ManagedCertificateFilter.SortMode.NAME_ASC) + { + expectedResult = expectedResult + .OrderBy(t => t.Name) + .AsQueryable(); + } + + if (filter.OrderBy == ManagedCertificateFilter.SortMode.RENEWAL_ASC) + { + expectedResult = expectedResult + .OrderBy(t => t.DateLastRenewalAttempt) + .AsQueryable(); + } if (filter.PageIndex != null && filter.PageSize != null) { @@ -467,8 +486,17 @@ public async Task TestManagedCertificateFilters(string storeType = null) Assert.AreEqual(expectedResult.Count(), testResult.Count, filter.FilterDescription); - Assert.IsTrue(expectedResult.First().Id == testResult.First().Id, $"{filter.FilterDescription} Test and expected should return same first items"); - Assert.IsTrue(expectedResult.Last().Id == testResult.Last().Id, $"{filter.FilterDescription} Test and expected should return same last items"); + if (filter.OrderBy == ManagedCertificateFilter.SortMode.NAME_ASC) + { + Assert.IsTrue(expectedResult.First().Id == testResult.First().Id, $"{filter.FilterDescription} Test and expected should return same first items"); + Assert.IsTrue(expectedResult.Last().Id == testResult.Last().Id, $"{filter.FilterDescription} Test and expected should return same last items"); + } + + if (filter.OrderBy == ManagedCertificateFilter.SortMode.RENEWAL_ASC) + { + Assert.IsTrue(expectedResult.First().Id == testResult.First().Id, $"{filter.FilterDescription} Test and expected should return same first items"); + Assert.IsTrue(expectedResult.Last().Id == testResult.Last().Id, $"{filter.FilterDescription} Test and expected should return same last items"); + } } } finally diff --git a/src/Certify.Tests/Certify.Core.Tests.Integration/StoredCredentialsDataStoreTests.cs b/src/Certify.Tests/Certify.Core.Tests.Integration/DataStores/StoredCredentialsDataStoreTests.cs similarity index 97% rename from src/Certify.Tests/Certify.Core.Tests.Integration/StoredCredentialsDataStoreTests.cs rename to src/Certify.Tests/Certify.Core.Tests.Integration/DataStores/StoredCredentialsDataStoreTests.cs index 4915a7e3d..a59b53f7a 100644 --- a/src/Certify.Tests/Certify.Core.Tests.Integration/StoredCredentialsDataStoreTests.cs +++ b/src/Certify.Tests/Certify.Core.Tests.Integration/DataStores/StoredCredentialsDataStoreTests.cs @@ -3,18 +3,19 @@ using System.Linq; using System.Threading.Tasks; using Certify.Datastore.Postgres; +using Certify.Datastore.SQLite; using Certify.Datastore.SQLServer; using Certify.Management; using Certify.Models.Config; using Microsoft.VisualStudio.TestTools.UnitTesting; -namespace Certify.Core.Tests +namespace Certify.Core.Tests.DataStores { [TestClass] public class StoredCredentialsDataStoreTests { private string _storeType = "postgres"; - private const string TEST_PATH = "Tests\\credentials"; + private const string TEST_PATH = "Tests"; public static IEnumerable TestDataStores { @@ -50,7 +51,7 @@ private ICredentialsManager GetCredentialManager(string storeType = null) } else { - throw new ArgumentOutOfRangeException(nameof(storeType), "Unsupport store type " + storeType); + throw new ArgumentOutOfRangeException(nameof(storeType), "Unsupported store type " + storeType); } } diff --git a/src/Certify.Tests/Certify.Core.Tests.Integration/DeploymentPreviewTests.cs b/src/Certify.Tests/Certify.Core.Tests.Integration/DeploymentPreviewTests.cs index 038c76688..504be11c8 100644 --- a/src/Certify.Tests/Certify.Core.Tests.Integration/DeploymentPreviewTests.cs +++ b/src/Certify.Tests/Certify.Core.Tests.Integration/DeploymentPreviewTests.cs @@ -9,7 +9,6 @@ using Certify.Management.Servers; using Certify.Models; using Microsoft.VisualStudio.TestTools.UnitTesting; -using Serilog; namespace Certify.Core.Tests { @@ -27,11 +26,7 @@ public class DeploymentPreviewTests : IntegrationTestBase, IDisposable public DeploymentPreviewTests() { - var log = new LoggerConfiguration() - .WriteTo.Debug() - .CreateLogger(); - _log = new Loggy(log); certifyManager = new CertifyManager(); certifyManager.Init().Wait(); iisManager = new ServerProviderIIS(); @@ -40,7 +35,7 @@ public DeploymentPreviewTests() PrimaryTestDomain = ConfigSettings["AWS_TestDomain"]; testSiteDomain = "integration1." + PrimaryTestDomain; - testSitePath = _primaryWebRoot; + testSitePath = PrimaryWebRootPath; _awsCredStorageKey = ConfigSettings["TestCredentialsKey_Route53"]; @@ -65,7 +60,7 @@ public async Task SetupIIS() await iisManager.DeleteSite(testSiteName); } - var site = await iisManager.CreateSite(testSiteName, testSiteDomain, _primaryWebRoot, "DefaultAppPool", port: testSiteHttpPort); + var site = await iisManager.CreateSite(testSiteName, testSiteDomain, PrimaryWebRootPath, "DefaultAppPool", port: testSiteHttpPort); Assert.IsTrue(await iisManager.SiteExists(testSiteName)); _siteId = site.Id.ToString(); } @@ -74,6 +69,7 @@ public async Task TeardownIIS() { await iisManager.DeleteSite(testSiteName); Assert.IsFalse(await iisManager.SiteExists(testSiteName)); + certifyManager.Dispose(); } [TestMethod] @@ -89,7 +85,7 @@ public async Task TestPreviewWildcard() await iisManager.DeleteSite(testPreviewSiteName); } - var site = await iisManager.CreateSite(testPreviewSiteName, hostname, _primaryWebRoot, "DefaultAppPool", port: testSiteHttpPort); + var site = await iisManager.CreateSite(testPreviewSiteName, hostname, PrimaryWebRootPath, "DefaultAppPool", port: testSiteHttpPort); ManagedCertificate managedCertificate = null; X509Certificate2 certInfo = null; @@ -170,7 +166,7 @@ public async Task TestPreviewStaticIPBindings() var ipAddress = GetTestStaticIP(); - var site = await iisManager.CreateSite(testPreviewSiteName, hostname, _primaryWebRoot, "DefaultAppPool", "http", ipAddress, testSiteHttpPort); + var site = await iisManager.CreateSite(testPreviewSiteName, hostname, PrimaryWebRootPath, "DefaultAppPool", "http", ipAddress, testSiteHttpPort); ManagedCertificate managedCertificate = null; X509Certificate2 certInfo = null; diff --git a/src/Certify.Tests/Certify.Core.Tests.Integration/DeploymentTaskTests.cs b/src/Certify.Tests/Certify.Core.Tests.Integration/DeploymentTaskTests.cs index 174315295..01ef472e3 100644 --- a/src/Certify.Tests/Certify.Core.Tests.Integration/DeploymentTaskTests.cs +++ b/src/Certify.Tests/Certify.Core.Tests.Integration/DeploymentTaskTests.cs @@ -8,7 +8,6 @@ using Certify.Models; using Certify.Models.Config; using Microsoft.VisualStudio.TestTools.UnitTesting; -using Serilog; namespace Certify.Core.Tests { @@ -23,16 +22,16 @@ public class DeploymentTaskTests : IntegrationTestBase public DeploymentTaskTests() { - var log = new LoggerConfiguration() - .WriteTo.Debug() - .CreateLogger(); - - _log = new Loggy(log); - certifyManager = new CertifyManager(); certifyManager.Init().Wait(); } + [TestCleanup] + public void Cleanup() + { + certifyManager?.Dispose(); + } + private DeploymentTaskConfig GetMockTaskConfig( string name, string msg = "Hello World", @@ -180,7 +179,7 @@ public async Task TestRunPostTasksWithTaskFailTrigger() //ensure 3rd task runs because task 1 failed var expectedFailureTaskStepKey = managedCertificate.PostRequestTasks.First(t => t.TaskName == "Post Task 3 (on task fail)").Id; - + var skippedStep = result .Actions.Find(s => s.Key == "PostRequestTasks") .Substeps.Find(s => s.Key == expectedFailureTaskStepKey); diff --git a/src/Certify.Tests/Certify.Core.Tests.Integration/IntegrationTestBase.cs b/src/Certify.Tests/Certify.Core.Tests.Integration/IntegrationTestBase.cs index 2008ace16..fbe5e29ff 100644 --- a/src/Certify.Tests/Certify.Core.Tests.Integration/IntegrationTestBase.cs +++ b/src/Certify.Tests/Certify.Core.Tests.Integration/IntegrationTestBase.cs @@ -4,39 +4,39 @@ using System.IO; using Certify.Models; using Certify.Models.Providers; +using Microsoft.Extensions.Logging; using Newtonsoft.Json; -using Serilog; namespace Certify.Core.Tests { public class IntegrationTestBase { - public string PrimaryTestDomain = "test.certifytheweb.com"; // TODO: get this from debug config as it changes per dev machine - public string _primaryWebRoot = @"c:\inetpub\wwwroot\"; public Dictionary ConfigSettings = new Dictionary(); + + public string PrimaryTestDomain = "test.certifytheweb.com"; // TODO: get this from debug config as it changes per dev machine + public string PrimaryWebRootPath = @"c:\inetpub\wwwroot\"; + + private string _testConfigPath = @"c:\temp\Certify\TestConfigSettings.json"; + internal ILog _log; public IntegrationTestBase() { - if (Environment.GetEnvironmentVariable("CERTIFYSSLDOMAIN") != null) + if (Environment.GetEnvironmentVariable("CERTIFY_TESTDOMAIN") != null) { - PrimaryTestDomain = Environment.GetEnvironmentVariable("CERTIFYSSLDOMAIN"); + PrimaryTestDomain = Environment.GetEnvironmentVariable("CERTIFY_TESTDOMAIN"); } - /* ConfigSettings.Add("AWS_ZoneId", "example"); - ConfigSettings.Add("Azure_ZoneId", "example"); - ConfigSettings.Add("Cloudflare_ZoneId", "example"); - System.IO.File.WriteAllText("C:\\temp\\TestConfigSettings.json", JsonConvert.SerializeObject(ConfigSettings)); - */ - - ConfigSettings = JsonConvert.DeserializeObject>(System.IO.File.ReadAllText("C:\\temp\\Certify\\TestConfigSettings.json")); - - var logImp = new LoggerConfiguration() - .WriteTo.Debug() - .CreateLogger(); - - _log = new Loggy(logImp); + if (File.Exists("C:\\temp\\Certify\\TestConfigSettings.json")) + { + ConfigSettings = JsonConvert.DeserializeObject>(System.IO.File.ReadAllText(_testConfigPath)); + } + else + { + System.Diagnostics.Debug.WriteLine("Test config file not found: " + _testConfigPath); + } + _log = new Loggy(LoggerFactory.Create(builder => builder.AddDebug()).CreateLogger()); } public ManagedCertificate GetMockManagedCertificate(string siteName, string testDomain, string siteId = null, string testPath = null) diff --git a/src/Certify.Tests/Certify.Core.Tests.Integration/MigrationManagerTests.cs b/src/Certify.Tests/Certify.Core.Tests.Integration/MigrationManagerTests.cs index 47e115dbc..e96117505 100644 --- a/src/Certify.Tests/Certify.Core.Tests.Integration/MigrationManagerTests.cs +++ b/src/Certify.Tests/Certify.Core.Tests.Integration/MigrationManagerTests.cs @@ -1,10 +1,10 @@ using System.Collections.Generic; using System.Threading.Tasks; -using Certify.Config.Migration; using Certify.Core.Management; using Certify.Datastore.SQLite; using Certify.Management; using Certify.Models; +using Certify.Models.Config.Migration; using Certify.Providers; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -18,14 +18,14 @@ private IManagedItemStore GetManagedItemStore() { var itemManager = new SQLiteManagedItemStore(); - itemManager.Init("", null); + itemManager.Init(string.Empty, null); return itemManager; } private ICredentialsManager GetCredentialsStore() { var itemManager = new SQLiteCredentialStore(); - itemManager.Init("", useWindowsNativeFeatures: true, null); + itemManager.Init(string.Empty, null); return itemManager; } diff --git a/src/Certify.Tests/Certify.Core.Tests.Integration/RdapTests.cs b/src/Certify.Tests/Certify.Core.Tests.Integration/RdapTests.cs index 743806cf8..62292dba2 100644 --- a/src/Certify.Tests/Certify.Core.Tests.Integration/RdapTests.cs +++ b/src/Certify.Tests/Certify.Core.Tests.Integration/RdapTests.cs @@ -8,12 +8,6 @@ namespace Certify.Core.Tests [TestClass] public class RdapTests { - - public RdapTests() - { - - } - [TestMethod, Description("Test Rdap Query")] [DataTestMethod] [DataRow("example.com", "OK", null)] diff --git a/src/Certify.Tests/Certify.Core.Tests.Integration/ServerManagers/IISManagerTests.cs b/src/Certify.Tests/Certify.Core.Tests.Integration/ServerManagers/IISManagerTests.cs index 0e7679732..57fa02f6b 100644 --- a/src/Certify.Tests/Certify.Core.Tests.Integration/ServerManagers/IISManagerTests.cs +++ b/src/Certify.Tests/Certify.Core.Tests.Integration/ServerManagers/IISManagerTests.cs @@ -51,7 +51,7 @@ public async Task SetupIIS() await iisManager.DeleteSite(testSiteName); } - var site = await iisManager.CreateSite(testSiteName, testSiteDomain, _primaryWebRoot, "DefaultAppPool"); + var site = await iisManager.CreateSite(testSiteName, testSiteDomain, PrimaryWebRootPath, "DefaultAppPool"); _siteId = site.Id.ToString(); Assert.IsTrue(await iisManager.SiteExists(testSiteName)); } @@ -69,6 +69,13 @@ public async Task TestIISVersionCheck() Assert.IsTrue(version.Major >= 7); } + [TestMethod] + public async Task TestIISIsAvailable() + { + var isAvailable = await iisManager.IsAvailable(); + Assert.IsTrue(isAvailable); + } + [TestMethod] public async Task TestIISSiteRunning() { @@ -104,7 +111,7 @@ public async Task TestCreateUnusualBindings() try { // create net.msmq://localhost binding, no port or ip - await iisManager.CreateSite(siteName, "localhost", _primaryWebRoot, null, protocol: "net.msmq", ipAddress: null, port: null); + await iisManager.CreateSite(siteName, "localhost", PrimaryWebRootPath, null, protocol: "net.msmq", ipAddress: null, port: null); var sites = iisManager.GetSiteBindingList(false); } @@ -127,7 +134,7 @@ public async Task TestCreateFixedIPBindings() try { var ipAddress = Dns.GetHostEntry(Dns.GetHostName()).AddressList[0].ToString(); - var site = await iisManager.CreateSite(testName, testDomainName, _primaryWebRoot, "DefaultAppPool", "http", ipAddress); + var site = await iisManager.CreateSite(testName, testDomainName, PrimaryWebRootPath, "DefaultAppPool", "http", ipAddress); Assert.IsTrue(await iisManager.SiteExists(testSiteName)); @@ -158,7 +165,7 @@ public async Task TestManySiteBindingUpdates() await iisManager.DeleteSite(testSiteName); } - await iisManager.CreateSite(testSiteName, "site_" + i + "_toomany.com", _primaryWebRoot, null, protocol: "http"); + await iisManager.CreateSite(testSiteName, "site_" + i + "_toomany.com", PrimaryWebRootPath, null, protocol: "http"); var site = await iisManager.GetSiteBindingByDomain(domain); for (var d = 0; d < 2; d++) { @@ -168,7 +175,7 @@ public async Task TestManySiteBindingUpdates() { SiteId = site.SiteId, Host = testDomain, - PhysicalPath = _primaryWebRoot + PhysicalPath = PrimaryWebRootPath }, addNew: true)); } } @@ -193,7 +200,7 @@ public async Task TestManySiteBindingUpdates() { SiteId = site.SiteId, Host = testDomain, - PhysicalPath = _primaryWebRoot + PhysicalPath = PrimaryWebRootPath }, addNew: true)); } else @@ -202,7 +209,7 @@ public async Task TestManySiteBindingUpdates() { SiteId = site.SiteId, Host = testDomain, - PhysicalPath = _primaryWebRoot + PhysicalPath = PrimaryWebRootPath }, addNew: true)); } } @@ -254,7 +261,7 @@ public async Task TestTooManyBindings() try { // create net.msmq://localhost binding, no port or ip - await iisManager.CreateSite("ManyBindings", "toomany.com", _primaryWebRoot, null, protocol: "http"); + await iisManager.CreateSite("ManyBindings", "toomany.com", PrimaryWebRootPath, null, protocol: "http"); var site = await iisManager.GetSiteBindingByDomain("toomany.com"); var domains = new List(); for (var i = 0; i < 101; i++) @@ -280,7 +287,7 @@ public async Task TestLongBinding() await iisManager.DeleteSite(testName); } - var site = await iisManager.CreateSite(testName, testDomainName, _primaryWebRoot, null); + var site = await iisManager.CreateSite(testName, testDomainName, PrimaryWebRootPath, null); try { @@ -337,7 +344,7 @@ public async Task TestBindingMatch() } // create site with IP all unassigned, no hostname - var site = await iisManager.CreateSite(testBindingSiteName, "", _primaryWebRoot, "DefaultAppPool", port: testSiteHttpPort); + var site = await iisManager.CreateSite(testBindingSiteName, "", PrimaryWebRootPath, "DefaultAppPool", port: testSiteHttpPort); // add another hostname binding (matching cert and not matching cert) var testDomains = new List { testSiteDomain, "label1." + testSiteDomain, "nested.label." + testSiteDomain }; diff --git a/src/Certify.Tests/Certify.Core.Tests.Unit/.dockerignore b/src/Certify.Tests/Certify.Core.Tests.Unit/.dockerignore new file mode 100644 index 000000000..3aae53927 --- /dev/null +++ b/src/Certify.Tests/Certify.Core.Tests.Unit/.dockerignore @@ -0,0 +1,32 @@ +# Include any files or directories that you don't want to be copied to your +# container here (e.g., local build artifacts, temporary files, etc.). +# +# For more help, visit the .dockerignore file reference guide at +# https://docs.docker.com/engine/reference/builder/#dockerignore-file + +**/.DS_Store +**/.classpath +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/bin +**/charts +**/docker-compose* +**/compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md diff --git a/src/Certify.Tests/Certify.Core.Tests.Unit/AccessControlTests.cs b/src/Certify.Tests/Certify.Core.Tests.Unit/AccessControlTests.cs deleted file mode 100644 index 39bcdeec8..000000000 --- a/src/Certify.Tests/Certify.Core.Tests.Unit/AccessControlTests.cs +++ /dev/null @@ -1,185 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Threading.Tasks; -using Certify.Core.Management.Access; -using Certify.Models; -using Microsoft.VisualStudio.TestTools.UnitTesting; -using Serilog; - -namespace Certify.Core.Tests.Unit -{ - - public class MemoryObjectStore : IObjectStore - { - private ConcurrentDictionary _store = new ConcurrentDictionary(); - - public Task Load(string id) - { - if (_store.TryGetValue(id, out var value)) - { - return Task.FromResult((T)value); - } - else - { - var empty = (T)Activator.CreateInstance(typeof(T)); - - return Task.FromResult(empty); - } - } - - public Task Save(string id, object item) - { - _ = _store.AddOrUpdate(id, item, (key, oldVal) => item); - return Task.FromResult(true); - } - } - - [TestClass] - public class AccessControlTests - { - - private List GetTestSecurityPrinciples() - { - - return new List { - new SecurityPrinciple { - Id = "admin_01", - Username = "admin", - Description = "Administrator account", - Email="info@test.com", Password="ABCDEFG", - PrincipleType= SecurityPrincipleType.User, - SystemRoleIds=new List{ StandardRoles.Administrator.Id - } -}, - new SecurityPrinciple - { - Id = "domain_owner_01", - Username = "demo_owner", - Description = "Example domain owner", - Email = "domains@test.com", - Password = "ABCDEFG", - PrincipleType = SecurityPrincipleType.User, - SystemRoleIds = new List { StandardRoles.DomainOwner.Id } - }, - new SecurityPrinciple - { - Id = "devops_user_01", - Username = "devops_01", - Description = "Example devops user", - Email = "devops01@test.com", - Password = "ABCDEFG", - PrincipleType = SecurityPrincipleType.User, - SystemRoleIds = new List { StandardRoles.CertificateConsumer.Id, StandardRoles.DomainRequestor.Id } - }, - new SecurityPrinciple - { - Id = "devops_app_01", - Username = "devapp_01", - Description = "Example devops app domain consumer", - Email = "dev_app01@test.com", - Password = "ABCDEFG", - PrincipleType = SecurityPrincipleType.User, - SystemRoleIds = new List { StandardRoles.CertificateConsumer.Id } - } - }; - } - - public List GetTestResourceProfiles() - { - return new List { - new ResourceProfile { - ResourceType = ResourceTypes.System, - AssignedRoles = new List{ - new ResourceAssignedRole{ RoleId=StandardRoles.Administrator.Id, PrincipleId = "admin_01" }, - new ResourceAssignedRole{ RoleId=StandardRoles.CertificateConsumer.Id, PrincipleId = "devops_user_01" }, - new ResourceAssignedRole{ RoleId=StandardRoles.DomainRequestor.Id, PrincipleId = "devops_user_01" } - } - }, - new ResourceProfile { - ResourceType = ResourceTypes.Domain, - Identifier = "example.com", - AssignedRoles= new List{ - new ResourceAssignedRole{ RoleId=StandardRoles.CertificateConsumer.Id, PrincipleId = "devops_user_01" }, - new ResourceAssignedRole{ RoleId=StandardRoles.DomainRequestor.Id, PrincipleId = "devops_user_01" } - } - }, - new ResourceProfile { - ResourceType = ResourceTypes.Domain, - Identifier = "www.example.com", - AssignedRoles= new List{ - new ResourceAssignedRole{ RoleId=StandardRoles.CertificateConsumer.Id, PrincipleId = "devops_user_01" }, - new ResourceAssignedRole{ RoleId=StandardRoles.DomainRequestor.Id, PrincipleId = "devops_user_01" } - } - }, - new ResourceProfile { - ResourceType = ResourceTypes.Domain, - Identifier = "*.microsoft.com", - AssignedRoles= new List{ - new ResourceAssignedRole{ RoleId=StandardRoles.CertificateConsumer.Id, PrincipleId = "devops_user_01" } - } - } - }; - } - - [TestMethod] - public async Task TestAccessControlChecks() - { - var log = new LoggerConfiguration() - .WriteTo.Debug() - .CreateLogger(); - - var loggy = new Loggy(log); - - var access = new AccessControl(loggy, new MemoryObjectStore()); - - var contextUserId = "[test]"; - - // add test security principles - var allPrinciples = GetTestSecurityPrinciples(); - foreach (var p in allPrinciples) - { - _ = await access.AddSecurityPrinciple(p, contextUserId, bypassIntegrityCheck: true); - } - - // assign resource roles per principle - var allResourceProfiles = GetTestResourceProfiles(); - foreach (var r in allResourceProfiles) - { - _ = await access.AddResourceProfile(r, contextUserId, bypassIntegrityCheck: true); - } - - // assert - - var hasAccess = await access.IsPrincipleInRole("admin_01", StandardRoles.Administrator.Id, contextUserId); - Assert.IsTrue(hasAccess, "User should be in role"); - - hasAccess = await access.IsPrincipleInRole("admin_02", StandardRoles.Administrator.Id, contextUserId); - Assert.IsFalse(hasAccess, "User should not be in role"); - - // check user can consume a cert for a given domain - var isAuthorised = await access.IsAuthorised("devops_user_01", StandardRoles.CertificateConsumer.Id, ResourceTypes.Domain, "www.example.com", contextUserId); - Assert.IsTrue(isAuthorised, "User should be a cert consumer for this domain"); - - // check user can't consume a cert for a subdomain they haven't been granted - isAuthorised = await access.IsAuthorised("devops_user_01", StandardRoles.CertificateConsumer.Id, ResourceTypes.Domain, "secure.example.com", contextUserId); - Assert.IsFalse(isAuthorised, "User should not be a cert consumer for this domain"); - - // check user can consume any subdomain via a granted wildcard - isAuthorised = await access.IsAuthorised("devops_user_01", StandardRoles.CertificateConsumer.Id, ResourceTypes.Domain, "random.microsoft.com", contextUserId); - Assert.IsTrue(isAuthorised, "User should be a cert consumer for this subdomain via wildcard"); - - // check user can't consume a random wildcard - isAuthorised = await access.IsAuthorised("devops_user_01", StandardRoles.CertificateConsumer.Id, ResourceTypes.Domain, "* lkjhasdf98862364", contextUserId); - Assert.IsFalse(isAuthorised, "User should not be a cert consumer for random wildcard"); - - // check user can't consume a random wildcard - isAuthorised = await access.IsAuthorised("devops_user_01", StandardRoles.CertificateConsumer.Id, ResourceTypes.Domain, " lkjhasdf98862364.*.microsoft.com", contextUserId); - Assert.IsFalse(isAuthorised, "User should not be a cert consumer for random wildcard"); - - // random user should not be authorised - isAuthorised = await access.IsAuthorised("randomuser", StandardRoles.CertificateConsumer.Id, ResourceTypes.Domain, "random.microsoft.com", contextUserId); - Assert.IsFalse(isAuthorised, "Unknown user should not be a cert consumer for this subdomain via wildcard"); - } - } -} diff --git a/src/Certify.Tests/Certify.Core.Tests.Unit/Certify.Core.Tests.Unit.csproj b/src/Certify.Tests/Certify.Core.Tests.Unit/Certify.Core.Tests.Unit.csproj index 9e73f5bea..b09488563 100644 --- a/src/Certify.Tests/Certify.Core.Tests.Unit/Certify.Core.Tests.Unit.csproj +++ b/src/Certify.Tests/Certify.Core.Tests.Unit/Certify.Core.Tests.Unit.csproj @@ -1,152 +1,132 @@ - - net7.0;net462; - Debug;Release - - - true - full - false - bin\Debug\ - DEBUG;TRACE - prompt - 4 - x64 - - - true - full - false - bin\Debug\ - DEBUG;TRACE - prompt - 4 - x64 - - - pdbonly - true - bin\Release\ - TRACE - prompt - 4 - - - true - bin\x64\Debug\ - DEBUG;TRACE - full - x64 - prompt - MinimumRecommendedRules.ruleset - - - true - bin\x64\Debug\ - DEBUG;TRACE - full - x64 - prompt - MinimumRecommendedRules.ruleset - - - bin\x64\Release\ - TRACE - true - pdbonly - x64 - prompt - MinimumRecommendedRules.ruleset - - - Debug - AnyCPU - {C0534CD8-10E9-438D-B9A1-A8C09A6BB964} - Library - Properties - Certify.Core.Tests.Unit - Certify.Core.Tests.Unit - v4.6.2 - 512 - {3AC096D0-A1C2-E12C-1390-A8335801FDAB};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC} - 15.0 - $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) - $(ProgramFiles)\Common Files\microsoft shared\VSTT\$(VisualStudioVersion)\UITestExtensionPackages - False - UnitTest - - - true - true - PackageReference - PackageReference - - - - x64 - - - 1701;1702;NU1701 - - - 1701;1702;NU1701 - - - 1701;1702;NU1701 - - - 1701;1702;NU1701 - - - - - - - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - - - - - - - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + net462;net9.0; + Debug;Release + AnyCPU + latest + + + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + AnyCPU + + + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + AnyCPU + + + + Debug + AnyCPU + {C0534CD8-10E9-438D-B9A1-A8C09A6BB964} + Library + Properties + Certify.Core.Tests.Unit + Certify.Core.Tests.Unit + v4.6.2 + 512 + {3AC096D0-A1C2-E12C-1390-A8335801FDAB};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC} + 15.0 + $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) + $(ProgramFiles)\Common Files\microsoft shared\VSTT\$(VisualStudioVersion)\UITestExtensionPackages + False + UnitTest + + + true + true + PackageReference + PackageReference + + + + portable + + + + AnyCPU + + + 1701;1702;NU1701 + + + 1701;1702;NU1701 + + + 1701;1702;NU1701 + + + 1701;1702;NU1701 + + + $(MSBuildThisFileDirectory)\unit-test.runsettings + + + $(MSBuildThisFileDirectory)\unit-test-linux.runsettings + + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Certify.Tests/Certify.Core.Tests.Unit/Docker.md b/src/Certify.Tests/Certify.Core.Tests.Unit/Docker.md new file mode 100644 index 000000000..19cda4c7f --- /dev/null +++ b/src/Certify.Tests/Certify.Core.Tests.Unit/Docker.md @@ -0,0 +1,169 @@ +# Certify Core Unit Test Docker Images and Containers + +## Building Certify Core Unit Test Docker Images + +To build the Docker Images for the Certify Core Unit Tests, first navigate to `certify\src\Certify.Tests\Certify.Core.Tests.Unit` in a console window or Visual Studio's Developer Powershell Panel + +### Linux Images + +To build the Linux images, use the following Docker build commands (make sure Docker Desktop is switched to Linux containers): + +``` +// For .NET Core 9.0 +docker build ..\..\..\..\ -t certify-core-tests-unit-9_0-linux -f .\certify-core-tests-unit-9_0-linux.dockerfile +``` + +### Windows Images + +To build the Windows images, use the following Docker build commands (make sure Docker Desktop is switched to Windows containers): + +``` +// For .NET 4.6.2 +docker build ..\..\..\..\ -t certify-core-tests-unit-4_6_2-win -f .\certify-core-tests-unit-4_6_2-win.dockerfile -m 8GB + +// For .NET Core 9.0 +docker build ..\..\..\..\ -t certify-core-tests-unit-9_0-win -f .\certify-core-tests-unit-9_0-win.dockerfile -m 8GB + +// For the step-ca-win image +docker build . -t step-ca-win -f .\step-ca-win.dockerfile +``` + + +Since the context built for the Docker Daemon is quite large for the Certify images in Windows (depending on the size of your Certify workspace), you may need to run this in a Powershell terminal outside of Visual Studio with the IDE and other memory-heavy apps closed down (especailly if you have low RAM). + + +## Running Certify Core Unit Test Containers with Docker Compose + +### Linux Test Runs + +To run the Linux Tests in Docker, use the following Docker Compose command: + +``` +docker compose -f linux_compose.yaml up -d +``` + +To stop the Linux Tests in Docker, use the following Docker Compose command: + +``` +docker compose -f linux_compose.yaml down -v +``` + +### Windows Test Runs + +To run the Windows Tests in Docker, use the following Docker Compose command: + +``` +// For .NET 4.6.2 +docker compose --profile 4_6_2 -f windows_compose.yaml up -d + +// For .NET Core 9.0 +docker compose --profile 9_0 -f windows_compose.yaml up -d +``` + +To stop the Windows Tests in Docker, use the following Docker Compose command: + +``` +// For .NET 4.6.2 +docker compose --profile 4_6_2 -f windows_compose.yaml down -v + +// For .NET Core 8.0 +docker compose --profile 9_0 -f windows_compose.yaml down -v +``` + +### Debugging Tests Running in Containers with Docker Compose in Visual Studio + +Within each test Docker Compose file are commented out lines for debugging subsections of the Certify Core Unit Test code base. + +To run an individual class of tests, uncomment the following section of the corresponding Docker Compose file, with the name of the test class you wish to run following `ClassName=`: + +``` + entrypoint: "dotnet test Certify.Core.Tests.Unit.dll -f net9.0 --filter 'ClassName=Certify.Core.Tests.Unit.CertifyManagerAccountTests'" +``` + +To run an individual test, uncomment the following section of the corresponding Docker Compose file, with +the name of the test you wish to run following `Name=`: + +``` + entrypoint: "dotnet test Certify.Core.Tests.Unit.dll -f net9.0 --filter 'Name=TestCertifyManagerGetAccountDetailsDefinedCertificateAuthorityId'" +``` + +To run tests using the Visual Studio debugger, first ensure that you have the `Containers` window visible (`View -> Other Windows -> Containers`) + +Then, uncomment the following section of the corresponding Docker Compose file you wish to run: +``` + environment: + VSTEST_HOST_DEBUG: 1 +``` + +After starting the Docker Compose file with the `up` command, the container for the Unit Tests will show in the logs a message like this, showing the debug process to attach to (this may take a second while it waits for the health check of the Step-CA container): + +``` +Host debugging is enabled. Please attach debugger to testhost process to continue. +Process Id: 2044, Name: testhost +Waiting for debugger attach... +Process Id: 2044, Name: testhost +``` + +To attach to the process, right-click on the Unit Test container in the Visual Studio Container window, and select `Attach To Process`. Visual Studio may download the Debug tool to your container if missing. + +Visual Studio will then bring up a new window showing the running Processes on the selected container. Double-click the Process with the matching ID number from the logging. + +![Screenshot of the Visual Studio Attach to Process Window](../../../docs/images/VS_Container_Debug_Attach_To_Process_Window.png) + +For Linux containers, you may additionally have to select the proper code type for debugging in the following window (Always choose `Managed (.NET Core for Unix)`): + +![Screenshot of the Visual Studio Select Code Type Window](../../../docs/images/VS_Container_Debug_Select_Code_Type_Window.png) + +The Visual Studio's debugger will then take a moment to attach. Once ready, you will need to click the `Continue` button to start test code execution. + +**Be sure to uncomment any debugging lines from the compose files before committing changes to the `certify` repo.** + +## Running Certify Core Unit Tests with a Base Docker Image (No Building) + +Since building a custom image can take time while doing local development, you can also use a the base images referenced in the Dockerfiles for this project to run your code on your machine in a container. + +To do this, first navigate to `certify\src\Certify.Tests\Certify.Core.Tests.Unit` in a console window or Visual Studio's Developer Powershell Panel. + +### Running Certify Core Unit Tests with a Linux Base Image + +**Note: CertifyManagerAccountTests tests will not work properly unless a Docker container for step-ca has been started with the hostname `step-ca`** + +To run all of the Certify Core Unit Tests in a Linux container, use the following command: + +``` +docker run --name core-tests-unit-9_0-linux --rm -it -v ${pwd}\bin\Debug\net9.0:/app -w /app mcr.microsoft.com/dotnet/sdk:9.0 dotnet test Certify.Core.Tests.Unit.dll -f net9.0 +``` + +To run a specific class of Certify Core Unit Tests in a Linux container, use the following command, substituting the Class Name of the tests after `--filter "ClassName=`: + +``` +docker run --name core-tests-unit-9_0-linux --rm -it -v ${pwd}\bin\Debug\net9.0:/app -w /app mcr.microsoft.com/dotnet/sdk:9.0 dotnet test Certify.Core.Tests.Unit.dll -f net9.0 --filter "ClassName=Certify.Core.Tests.Unit.DnsQueryTests" +``` + +To run a single Certify Core Unit Test in a Linux container, use the following command, substituting the Test Name of the tests after `--filter "Name=`: + +``` +docker run --name core-tests-unit-9_0-linux --rm -it -v ${pwd}\bin\Debug\net9.0:/app -w /app mcr.microsoft.com/dotnet/sdk:9.0 dotnet test Certify.Core.Tests.Unit.dll -f net9.0 --filter "Name=MixedIPBindingChecksNoPreview" +``` + +To run Certify Core Unit Tests with Debugging in a Linux container, use the following command, add `-e VSTEST_HOST_DEBUG=1` as a `docker run` parameter like so: + +``` +docker run --name core-tests-unit-9_0-linux --rm -it -e VSTEST_HOST_DEBUG=1 -v ${pwd}\bin\Debug\net9.0:/app -w /app mcr.microsoft.com/dotnet/sdk:9.0 dotnet test Certify.Core.Tests.Unit.dll -f net9.0" +``` + +### Running Certify Core Unit Tests with a Windows Base Image + +**Note: CertifyManagerAccountTests tests will not work properly unless a Docker container for step-ca-win has been started with the hostname `step-ca`** + +To run all of the Certify Core Unit Tests in a Windows container, use the following command: + +``` +// For .NET 4.6.2 +docker run --name core-tests-unit-4_6_2-win --rm -it -v ${pwd}\bin\Debug\net462:C:\app -w C:\app mcr.microsoft.com/dotnet/sdk:9.0-preview-windowsservercore-ltsc2022 dotnet test Certify.Core.Tests.Unit.dll -f net462 + +// For .NET Core 8.0 +docker run --name core-tests-unit-9_0-win --rm -it -v ${pwd}\bin\Debug\net9.0:C:\app -w C:\app mcr.microsoft.com/dotnet/sdk:9.0-preview-windowsservercore-ltsc2022 dotnet test Certify.Core.Tests.Unit.dll -f net9.0 +``` + +See the above Linux examples to see how to run tests selectively or with debugging enabled. diff --git a/src/Certify.Tests/Certify.Core.Tests.Unit/MiscTests.cs b/src/Certify.Tests/Certify.Core.Tests.Unit/MiscTests.cs deleted file mode 100644 index a74dbe87b..000000000 --- a/src/Certify.Tests/Certify.Core.Tests.Unit/MiscTests.cs +++ /dev/null @@ -1,66 +0,0 @@ -using System; -using System.Threading.Tasks; -using Microsoft.VisualStudio.TestTools.UnitTesting; - -namespace Certify.Core.Tests.Unit -{ - [TestClass] - public class MiscTests - { - - public MiscTests() - { - - } - - [TestMethod, Description("Test null/blank coalesce of string")] - public void TestNullOrBlankCoalesce() - { - string testValue = null; - - var result = testValue.WithDefault("ok"); - Assert.AreEqual(result, "ok"); - - testValue = "test"; - result = testValue.WithDefault("ok"); - Assert.AreEqual(result, "test"); - - var ca = new Models.CertificateAuthority(); - ca.Description = null; - result = ca.Description.WithDefault("default"); - Assert.AreEqual(result, "default"); - - ca = null; - result = ca?.Description.WithDefault("default"); - Assert.AreEqual(result, null); - } - - [TestMethod, Description("Test ntp check")] - public async Task TestNtp() - { - var check = await Certify.Management.Util.CheckTimeServer(); - - var timeDiff = check - DateTimeOffset.UtcNow; - - if (Math.Abs(timeDiff.Value.TotalSeconds) > 50) - { - Assert.Fail("NTP Time Difference Failed"); - } - } -#if NET7_0_OR_GREATER - [TestMethod, Description("Test ARI CertID encoding example")] - public void TestARICertIDEncoding() - { - // https://letsencrypt.org/2024/04/25/guide-to-integrating-ari-into-existing-acme-clients - var certAKIbytes = Convert.FromHexString("69:88:5B:6B:87:46:40:41:E1:B3:7B:84:7B:A0:AE:2C:DE:01:C8:D4".Replace(":", "")); - var certSerialBytes = Convert.FromHexString("00:87:65:43:21".Replace(":", "")); - - var certId = Certify.Management.Util.ToUrlSafeBase64String(certAKIbytes) - + "." - + Certify.Management.Util.ToUrlSafeBase64String(certSerialBytes); - - Assert.AreEqual("aYhba4dGQEHhs3uEe6CuLN4ByNQ.AIdlQyE", certId); - } -#endif - } -} diff --git a/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/AccessControlTests.cs b/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/AccessControlTests.cs new file mode 100644 index 000000000..ee15f865f --- /dev/null +++ b/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/AccessControlTests.cs @@ -0,0 +1,824 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Certify.Core.Management.Access; +using Certify.Models; +using Certify.Models.Hub; +using Certify.Providers; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Newtonsoft.Json; + +namespace Certify.Core.Tests.Unit +{ + public class MemoryObjectStore : IConfigurationStore + { + private ConcurrentDictionary _store = new ConcurrentDictionary(); + + public Task Add(string itemType, ConfigurationStoreItem item) + { + item.ItemType = itemType; + + // clone the item to avoid reference issue mutating the same object, as we are using an in-memory store + var clonedItem = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(item)) as ConfigurationStoreItem; + return Task.FromResult(_store.TryAdd(clonedItem.Id, clonedItem)); + } + + public Task Delete(string itemType, string id) + { + return Task.FromResult((_store.TryRemove(id, out _))); + } + + public Task> GetItems(string itemType) + { + var items = _store.Values + .Where((s => s.ItemType == itemType)) + .Select(s => (T)Convert.ChangeType(s, typeof(T))); + + return Task.FromResult((items.ToList())); + } + + public Task Get(string itemType, string id) + { + _store.TryGetValue(id, out var value); + return Task.FromResult((T)Convert.ChangeType(value, typeof(T))); + } + + public Task Add(string itemType, T item) + { + var o = item as ConfigurationStoreItem; + o.ItemType = itemType; + + var clonedItem = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(o)) as ConfigurationStoreItem; + return Task.FromResult(_store.TryAdd(clonedItem.Id, clonedItem)); + } + + public Task Update(string itemType, T item) + { + var o = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(item)) as ConfigurationStoreItem; + + _store.TryGetValue(o.Id, out var value); + var c = Task.FromResult((T)Convert.ChangeType(value, typeof(T))).Result as ConfigurationStoreItem; + var r = Task.FromResult(_store.TryUpdate(o.Id, o, c)); + if (r.Result == false) + { + throw new Exception("Could not store item type"); + } + + return r; + } + } + + public class TestAssignedRoles + { + public static AssignedRole TestAdmin { get; } = new AssignedRole + { + // test administrator + RoleId = StandardRoles.Administrator.Id, + SecurityPrincipleId = TestSecurityPrinciples.TestAdmin.Id + }; + public static AssignedRole Admin { get; } = new AssignedRole + { + // administrator + RoleId = StandardRoles.Administrator.Id, + SecurityPrincipleId = TestSecurityPrinciples.Admin.Id + }; + public static AssignedRole DevopsUserDomainConsumer { get; } = new AssignedRole + { + // devops user in consumer role for a specific domain + RoleId = StandardRoles.CertificateConsumer.Id, + SecurityPrincipleId = TestSecurityPrinciples.DevopsAppDomainConsumer.Id, + IncludedResources = new List{ + new Resource{ ResourceType=ResourceTypes.Domain, Identifier="www.example.com" }, + } + }; + public static AssignedRole DevopsUserWildcardDomainConsumer { get; } = new AssignedRole + { + // devops user in consumer role for a wildcard domain + RoleId = StandardRoles.CertificateConsumer.Id, + SecurityPrincipleId = TestSecurityPrinciples.DevopsUser.Id, + IncludedResources = new List{ + new Resource{ ResourceType=ResourceTypes.Domain, Identifier="*.microsoft.com" }, + } + }; + } + + public class TestSecurityPrinciples + { + public static SecurityPrinciple TestAdmin => new SecurityPrinciple + { + Id = "[test]", + Username = "test administrator", + Description = "Example test administrator used as context user during test", + Email = "test_admin@test.com", + Password = "ABCDEFG", + PrincipleType = SecurityPrincipleType.User + }; + public static SecurityPrinciple Admin => new SecurityPrinciple + { + Id = "admin_01", + Username = "admin", + Description = "Administrator account", + Email = "info@test.com", + Password = "ABCDEFG", + PrincipleType = SecurityPrincipleType.User, + }; + public static SecurityPrinciple DomainOwner => new SecurityPrinciple + { + Id = "domain_owner_01", + Username = "demo_owner", + Description = "Example domain owner", + Email = "domains@test.com", + Password = "ABCDEFG", + PrincipleType = SecurityPrincipleType.User, + }; + public static SecurityPrinciple DevopsUser => new SecurityPrinciple + { + Id = "devops_user_01", + Username = "devops_01", + Description = "Example devops user", + Email = "devops01@test.com", + Password = "ABCDEFG", + PrincipleType = SecurityPrincipleType.User, + }; + public static SecurityPrinciple DevopsAppDomainConsumer => new SecurityPrinciple + { + Id = "devops_app_01", + Username = "devapp_01", + Description = "Example devops app domain consumer", + Email = "dev_app01@test.com", + Password = "ABCDEFG", + PrincipleType = SecurityPrincipleType.User, + }; + } + + [TestClass] + public class AccessControlTests + { + private Loggy loggy; + private AccessControl access; + private const string contextUserId = "[test]"; + + [TestInitialize] + public async Task TestInitialize() + { + this.loggy = new Loggy(LoggerFactory.Create(builder => builder.AddDebug()).CreateLogger()); + + this.access = new AccessControl(loggy, new MemoryObjectStore()); + } + + [TestMethod] + public async Task TestAddGetSecurityPrinciples() + { + // Add test security principles + var adminSecurityPrinciples = new List { TestSecurityPrinciples.Admin, TestSecurityPrinciples.TestAdmin }; + adminSecurityPrinciples.ForEach(async p => await access.AddSecurityPrinciple(contextUserId, p, bypassIntegrityCheck: true)); + + // Get stored security principles + var storedSecurityPrinciples = await access.GetSecurityPrinciples(contextUserId); + + // Validate SecurityPrinciple list returned by AccessControl.GetSecurityPrinciples() + Assert.IsNotNull(storedSecurityPrinciples, "Expected list returned by AccessControl.GetSecurityPrinciples() to not be null"); + Assert.AreEqual(2, storedSecurityPrinciples.Count, "Expected list returned by AccessControl.GetSecurityPrinciples() to have 2 SecurityPrinciple objects"); + foreach (var passedPrinciple in adminSecurityPrinciples) + { + Assert.IsNotNull(storedSecurityPrinciples.Find(x => x.Id == passedPrinciple.Id), $"Expected a SecurityPrinciple returned by GetSecurityPrinciples() to match Id '{passedPrinciple.Id}' of SecurityPrinciple passed into AddSecurityPrinciple()"); + } + } + + [TestMethod] + public async Task TestGetSecurityPrinciplesNoRoles() + { + // Add test security principles + var securityPrincipleAdded = await access.AddSecurityPrinciple(contextUserId, TestSecurityPrinciples.TestAdmin); + + // Get stored security principles + Assert.IsFalse(securityPrincipleAdded, $"Expected AddSecurityPrinciple() to be unsuccessful without roles defined for {contextUserId}"); + } + + [TestMethod] + public async Task TestAddGetSecurityPrinciple() + { + // Add test security principles + var adminSecurityPrinciples = new List { TestSecurityPrinciples.Admin, TestSecurityPrinciples.TestAdmin }; + adminSecurityPrinciples.ForEach(async p => await access.AddSecurityPrinciple(contextUserId, p, bypassIntegrityCheck: true)); + + foreach (var securityPrinciple in adminSecurityPrinciples) + { + // Get stored security principle + var storedSecurityPrinciple = await access.GetSecurityPrinciple(contextUserId, securityPrinciple.Id); + + // Validate SecurityPrinciple object returned by AccessControl.GetSecurityPrinciple() + Assert.IsNotNull(storedSecurityPrinciple, "Expected object returned by AccessControl.GetSecurityPrinciple() to not be null"); + Assert.AreEqual(storedSecurityPrinciple.Id, securityPrinciple.Id, $"Expected SecurityPrinciple returned by GetSecurityPrinciple() to match Id '{securityPrinciple.Id}' of SecurityPrinciple passed into AddSecurityPrinciple()"); + } + } + + [TestMethod] + public async Task TestAddGetAssignedRoles() + { + // Add test security principles + var adminSecurityPrinciples = new List { TestSecurityPrinciples.Admin, TestSecurityPrinciples.TestAdmin }; + adminSecurityPrinciples.ForEach(async p => await access.AddSecurityPrinciple(contextUserId, p, bypassIntegrityCheck: true)); + + // Setup security principle actions + var actions = Policies.GetStandardResourceActions().FindAll(a => a.ResourceType == ResourceTypes.System); + actions.ForEach(async a => await access.AddResourceAction(contextUserId, a)); + + // Setup policy with actions and add policy to store + var policy = Policies.GetStandardPolicies().Find(p => p.Id == StandardPolicies.AccessAdmin); + var addPolicy = await access.AddResourcePolicy(contextUserId, policy, bypassIntegrityCheck: true); + + Assert.IsTrue(addPolicy, "Expected to add role"); + + // Setup and add roles and policy assignments to store + var role = Policies.GetStandardRoles().Find(r => r.Id == StandardRoles.Administrator.Id); + var addedRole = await access.AddRole(contextUserId, role, bypassIntegrityCheck: true); + + Assert.IsTrue(addedRole, "Expected to add role"); + + // Assign security principles to roles and add roles and policy assignments to store + var assignedRoles = new List { TestAssignedRoles.Admin, TestAssignedRoles.TestAdmin }; + assignedRoles.ForEach(async r => await access.AddAssignedRole(contextUserId, r, bypassIntegrityCheck: true)); + + // Validate AssignedRole list returned by AccessControl.GetAssignedRoles() + foreach (var assignedRole in assignedRoles) + { + var adminAssignedRoles = await access.GetAssignedRoles(contextUserId, assignedRole.SecurityPrincipleId); + Assert.IsNotNull(adminAssignedRoles, "Expected list returned by AccessControl.GetAssignedRoles() to not be null"); + Assert.AreEqual(1, adminAssignedRoles.Count, "Expected list returned by AccessControl.GetAssignedRoles() to have 1 AssignedRole object"); + Assert.AreEqual(assignedRole.SecurityPrincipleId, adminAssignedRoles[0].SecurityPrincipleId, "Expected AssignedRole returned by GetAssignedRoles() to match SecurityPrincipleId of AssignedRole passed into AddAssignedRole()"); + } + } + + [TestMethod] + public async Task TestGetAssignedRolesNoRoles() + { + // Add test security principles + var adminSecurityPrinciples = new List { TestSecurityPrinciples.Admin, TestSecurityPrinciples.TestAdmin }; + adminSecurityPrinciples.ForEach(async p => await access.AddSecurityPrinciple(contextUserId, p, bypassIntegrityCheck: true)); + + // assigned admin role to TestAdmin (also the contextUserId) so they can check roles for the other admin user + await access.AddAssignedRole(TestSecurityPrinciples.TestAdmin.Id, TestAssignedRoles.TestAdmin, bypassIntegrityCheck: true); + + // Validate AssignedRole list returned by AccessControl.GetAssignedRoles() + var adminAssignedRoles = await access.GetAssignedRoles(contextUserId, adminSecurityPrinciples[0].Id); + Assert.IsNotNull(adminAssignedRoles, "Expected list returned by AccessControl.GetAssignedRoles() to not be null"); + Assert.AreEqual(0, adminAssignedRoles.Count, "Expected list returned by AccessControl.GetAssignedRoles() to have no AssignedRole objects"); + } + + [TestMethod] + public async Task TestAddResourcePolicyNoRoles() + { + // Add test security principles + var adminSecurityPrinciples = new List { TestSecurityPrinciples.Admin, TestSecurityPrinciples.TestAdmin }; + adminSecurityPrinciples.ForEach(async p => await access.AddSecurityPrinciple(contextUserId, p, bypassIntegrityCheck: true)); + + // Setup security principle actions + var actions = Policies.GetStandardResourceActions().FindAll(a => a.ResourceType == ResourceTypes.System); + actions.ForEach(async a => await access.AddResourceAction(contextUserId, a)); + + // Setup policy with actions and add policy to store + var policy = Policies.GetStandardPolicies().Find(p => p.Id == StandardPolicies.AccessAdmin); + var addedResourcePolicy = await access.AddResourcePolicy(contextUserId, policy); + + // Validate that AddResourcePolicy() failed when no roles are defined + Assert.IsFalse(addedResourcePolicy, $"Unable to add a resource policy using {contextUserId} when roles are undefined"); + } + + [TestMethod] + public async Task TestUpdateSecurityPrinciple() + { + // Add test security principles + var adminSecurityPrinciples = new List { TestSecurityPrinciples.Admin, TestSecurityPrinciples.TestAdmin }; + + adminSecurityPrinciples.ForEach(async p => await access.AddSecurityPrinciple(contextUserId, p, bypassIntegrityCheck: true)); + + // Setup security principle actions + var actions = Policies.GetStandardResourceActions().FindAll(a => a.ResourceType == ResourceTypes.System); + actions.ForEach(async a => await access.AddResourceAction(contextUserId, a)); + + // Setup policy with actions and add policy to store + var policy = Policies.GetStandardPolicies().Find(p => p.Id == StandardPolicies.AccessAdmin); + _ = await access.AddResourcePolicy(contextUserId, policy, bypassIntegrityCheck: true); + + // Setup and add roles and policy assignments to store + var role = Policies.GetStandardRoles().Find(r => r.Id == StandardRoles.Administrator.Id); + await access.AddRole(contextUserId, role); + + // Assign security principles to roles and add roles and policy assignments to store + var assignedRoles = new List { TestAssignedRoles.Admin, TestAssignedRoles.TestAdmin }; + assignedRoles.ForEach(async r => await access.AddAssignedRole(contextUserId, r, bypassIntegrityCheck: true)); + + // Validate email of SecurityPrinciple object returned by AccessControl.GetSecurityPrinciple() before update + var storedSecurityPrinciple = await access.GetSecurityPrinciple(contextUserId, adminSecurityPrinciples[0].Id); + Assert.AreEqual(storedSecurityPrinciple.Email, adminSecurityPrinciples[0].Email, $"Expected SecurityPrinciple returned by GetSecurityPrinciple() to match Email '{adminSecurityPrinciples[0].Email}' of SecurityPrinciple passed into AddSecurityPrinciple()"); + + // Update security principle in AccessControl with a new principle object of the same Id, but different email + var updateSecurityPrinciple = new SecurityPrinciple + { + Id = TestSecurityPrinciples.Admin.Id, + Username = TestSecurityPrinciples.Admin.Username, + Description = TestSecurityPrinciples.Admin.Description, + Email = "new_test_email@test.com" + }; + + var securityPrincipleUpdated = await access.UpdateSecurityPrinciple(contextUserId, updateSecurityPrinciple); + Assert.IsTrue(securityPrincipleUpdated, $"Expected security principle update for {updateSecurityPrinciple.Id} to succeed"); + + // Validate email of SecurityPrinciple object returned by AccessControl.GetSecurityPrinciple() after update + storedSecurityPrinciple = await access.GetSecurityPrinciple(contextUserId, updateSecurityPrinciple.Id); + Assert.AreNotEqual(storedSecurityPrinciple.Email, adminSecurityPrinciples[0].Email, $"Expected SecurityPrinciple returned by GetSecurityPrinciple() to not match previous Email '{adminSecurityPrinciples[0].Email}' of SecurityPrinciple passed into AddSecurityPrinciple()"); + Assert.AreEqual(storedSecurityPrinciple.Email, updateSecurityPrinciple.Email, $"Expected SecurityPrinciple returned by GetSecurityPrinciple() to match updated Email '{updateSecurityPrinciple.Email}' of SecurityPrinciple passed into AddSecurityPrinciple()"); + } + + [TestMethod] + public async Task TestUpdateSecurityPrincipleNoRoles() + { + // Add test security principles + var adminSecurityPrinciples = new List { TestSecurityPrinciples.Admin, TestSecurityPrinciples.TestAdmin }; + adminSecurityPrinciples.ForEach(async p => await access.AddSecurityPrinciple(contextUserId, p, bypassIntegrityCheck: true)); + + // Validate email of SecurityPrinciple object returned by AccessControl.GetSecurityPrinciple() before update + var storedSecurityPrinciple = await access.GetSecurityPrinciple(contextUserId, adminSecurityPrinciples[0].Id); + Assert.AreEqual(storedSecurityPrinciple.Email, adminSecurityPrinciples[0].Email, $"Expected SecurityPrinciple returned by GetSecurityPrinciple() to match Email '{adminSecurityPrinciples[0].Email}' of SecurityPrinciple passed into AddSecurityPrinciple()"); + + // Update security principle in AccessControl with a new principle object of the same Id, but different email, with roles undefined + var newSecurityPrinciple = TestSecurityPrinciples.Admin; + newSecurityPrinciple.Email = "new_test_email@test.com"; + + var securityPrincipleUpdated = await access.UpdateSecurityPrinciple(contextUserId, newSecurityPrinciple); + Assert.IsFalse(securityPrincipleUpdated, $"Expected security principle update for {newSecurityPrinciple.Id} to be unsuccessful without roles defined"); + } + + [TestMethod] + public async Task TestUpdateSecurityPrincipleBadUpdate() + { + // Add test security principles + var adminSecurityPrinciples = new List { TestSecurityPrinciples.Admin, TestSecurityPrinciples.TestAdmin }; + adminSecurityPrinciples.ForEach(async p => await access.AddSecurityPrinciple(contextUserId, p, bypassIntegrityCheck: true)); + + // Setup security principle actions + var actions = Policies.GetStandardResourceActions().FindAll(a => a.ResourceType == ResourceTypes.System); + actions.ForEach(async a => await access.AddResourceAction(contextUserId, a)); + + // Setup policy with actions and add policy to store + var policy = Policies.GetStandardPolicies().Find(p => p.Id == StandardPolicies.AccessAdmin); + _ = await access.AddResourcePolicy(contextUserId, policy, bypassIntegrityCheck: true); + + // Setup and add roles and policy assignments to store + var role = Policies.GetStandardRoles().Find(r => r.Id == StandardRoles.Administrator.Id); + await access.AddRole(contextUserId, role, bypassIntegrityCheck: true); + + // Assign security principles to roles and add roles and policy assignments to store + var assignedRoles = new List { TestAssignedRoles.Admin, TestAssignedRoles.TestAdmin }; + assignedRoles.ForEach(async r => await access.AddAssignedRole(contextUserId, r, bypassIntegrityCheck: true)); + + // Validate email of SecurityPrinciple object returned by AccessControl.GetSecurityPrinciple() before update + var storedSecurityPrinciple = await access.GetSecurityPrinciple(contextUserId, adminSecurityPrinciples[0].Id); + Assert.AreEqual(storedSecurityPrinciple.Email, adminSecurityPrinciples[0].Email, $"Expected SecurityPrinciple returned by GetSecurityPrinciple() to match Email '{adminSecurityPrinciples[0].Email}' of SecurityPrinciple passed into AddSecurityPrinciple()"); + + // Update security principle in AccessControl with a new principle object with a bad Id name and different email + var newSecurityPrinciple = TestSecurityPrinciples.Admin; + newSecurityPrinciple.Email = "new_test_email@test.com"; + newSecurityPrinciple.Id = "missing_username"; + var securityPrincipleUpdated = await access.UpdateSecurityPrinciple(contextUserId, newSecurityPrinciple); + + Assert.IsFalse(securityPrincipleUpdated, $"Expected security principle update for {newSecurityPrinciple.Id} to be unsuccessful with bad update data (Id does not already exist in store)"); + } + + [TestMethod] + public async Task TestUpdateSecurityPrinciplePassword() + { + // Add test security principles + var adminSecurityPrinciples = new List { TestSecurityPrinciples.Admin, TestSecurityPrinciples.TestAdmin }; + var firstPassword = adminSecurityPrinciples[0].Password; + adminSecurityPrinciples.ForEach(async p => await access.AddSecurityPrinciple(contextUserId, p, bypassIntegrityCheck: true)); + + // Setup security principle actions + var actions = Policies.GetStandardResourceActions().FindAll(a => a.ResourceType == ResourceTypes.System); + actions.ForEach(async a => await access.AddResourceAction(contextUserId, a)); + + // Setup policy with actions and add policy to store + var policy = Policies.GetStandardPolicies().Find(p => p.Id == StandardPolicies.AccessAdmin); + _ = await access.AddResourcePolicy(contextUserId, policy, bypassIntegrityCheck: true); + + // Setup and add roles and policy assignments to store + var role = Policies.GetStandardRoles().Find(r => r.Id == StandardRoles.Administrator.Id); + await access.AddRole(contextUserId, role, bypassIntegrityCheck: true); + + // Assign security principles to roles and add roles and policy assignments to store + var assignedRoles = new List { TestAssignedRoles.Admin, TestAssignedRoles.TestAdmin }; + assignedRoles.ForEach(async r => await access.AddAssignedRole(contextUserId, r, bypassIntegrityCheck: true)); + + // Validate password of SecurityPrinciple object returned by AccessControl.GetSecurityPrinciple() before update + var storedSecurityPrinciple = await access.GetSecurityPrinciple(contextUserId, adminSecurityPrinciples[0].Id); + var firstPasswordHashed = access.HashPassword(firstPassword, storedSecurityPrinciple.Password.Split('.')[1]); + Assert.AreEqual(storedSecurityPrinciple.Password, firstPasswordHashed, $"Expected SecurityPrinciple returned by GetSecurityPrinciple() to match Password '{firstPasswordHashed}' of SecurityPrinciple passed into AddSecurityPrinciple()"); + + // Update security principle in AccessControl with a new password + var newPassword = "GFEDCBA"; + var securityPrincipleUpdated = await access.UpdateSecurityPrinciplePassword(contextUserId, new Models.Hub.SecurityPrinciplePasswordUpdate(adminSecurityPrinciples[0].Id, firstPassword, newPassword)); + Assert.IsTrue(securityPrincipleUpdated, $"Expected security principle password update for {adminSecurityPrinciples[0].Id} to succeed"); + + // Validate password of SecurityPrinciple object returned by AccessControl.GetSecurityPrinciple() after update + storedSecurityPrinciple = await access.GetSecurityPrinciple(contextUserId, adminSecurityPrinciples[0].Id); + var newPasswordHashed = access.HashPassword(newPassword, storedSecurityPrinciple.Password.Split('.')[1]); + + Assert.AreNotEqual(storedSecurityPrinciple.Password, firstPasswordHashed, $"Expected SecurityPrinciple returned by GetSecurityPrinciple() to not match previous Password '{firstPasswordHashed}' of SecurityPrinciple passed into AddSecurityPrinciple()"); + Assert.AreEqual(storedSecurityPrinciple.Password, newPasswordHashed, $"Expected SecurityPrinciple returned by GetSecurityPrinciple() to match updated Password '{newPasswordHashed}' of SecurityPrinciple passed into AddSecurityPrinciple()"); + } + + [TestMethod] + public async Task TestUpdateSecurityPrinciplePasswordNoRoles() + { + // Add test security principles + var adminSecurityPrinciples = new List { TestSecurityPrinciples.Admin, TestSecurityPrinciples.TestAdmin }; + var firstPassword = adminSecurityPrinciples[0].Password; + adminSecurityPrinciples.ForEach(async p => await access.AddSecurityPrinciple(contextUserId, p, bypassIntegrityCheck: true)); + + // Update security principle in AccessControl with a new password + var newPassword = "GFEDCBA"; + var securityPrincipleUpdated = await access.UpdateSecurityPrinciplePassword(contextUserId, new Models.Hub.SecurityPrinciplePasswordUpdate(adminSecurityPrinciples[0].Id, firstPassword, newPassword)); + Assert.IsFalse(securityPrincipleUpdated, $"Expected security principle password update for {adminSecurityPrinciples[0].Id} to fail without roles"); + + // Validate password of SecurityPrinciple object returned by AccessControl.GetSecurityPrinciple() after failed update + var storedSecurityPrinciple = await access.GetSecurityPrinciple(contextUserId, adminSecurityPrinciples[0].Id); + var firstPasswordHashed = access.HashPassword(firstPassword, storedSecurityPrinciple.Password.Split('.')[1]); + + Assert.AreEqual(storedSecurityPrinciple.Password, firstPasswordHashed, $"Expected SecurityPrinciple returned by GetSecurityPrinciple() to match Password '{firstPasswordHashed}' of SecurityPrinciple passed into AddSecurityPrinciple()"); + } + + [TestMethod] + public async Task TestUpdateSecurityPrinciplePasswordBadPassword() + { + // Add test security principles + var adminSecurityPrinciples = new List { TestSecurityPrinciples.Admin, TestSecurityPrinciples.TestAdmin }; + var firstPassword = adminSecurityPrinciples[0].Password; + adminSecurityPrinciples.ForEach(async p => await access.AddSecurityPrinciple(contextUserId, p, bypassIntegrityCheck: true)); + + // Setup security principle actions + var actions = Policies.GetStandardResourceActions().FindAll(a => a.ResourceType == ResourceTypes.System); + actions.ForEach(async a => await access.AddResourceAction(contextUserId, a)); + + // Setup policy with actions and add policy to store + var policy = Policies.GetStandardPolicies().Find(p => p.Id == StandardPolicies.AccessAdmin); + _ = await access.AddResourcePolicy(contextUserId, policy, bypassIntegrityCheck: true); + + // Setup and add roles and policy assignments to store + var role = Policies.GetStandardRoles().Find(r => r.Id == StandardRoles.Administrator.Id); + await access.AddRole(contextUserId, role); + + // Assign security principles to roles and add roles and policy assignments to store + var assignedRoles = new List { TestAssignedRoles.Admin, TestAssignedRoles.TestAdmin }; + assignedRoles.ForEach(async r => await access.AddAssignedRole(contextUserId, r)); + + // Update security principle in AccessControl with a new password, but wrong original password + var newPassword = "GFEDCBA"; + var securityPrincipleUpdated = await access.UpdateSecurityPrinciplePassword(contextUserId, new Models.Hub.SecurityPrinciplePasswordUpdate(adminSecurityPrinciples[0].Id, firstPassword.ToLower(), newPassword)); + Assert.IsFalse(securityPrincipleUpdated, $"Expected security principle password update for {adminSecurityPrinciples[0].Id} to fail with wrong password"); + + // Validate password of SecurityPrinciple object returned by AccessControl.GetSecurityPrinciple() after failed update + var storedSecurityPrinciple = await access.GetSecurityPrinciple(contextUserId, adminSecurityPrinciples[0].Id); + var firstPasswordHashed = access.HashPassword(firstPassword, storedSecurityPrinciple.Password.Split('.')[1]); + Assert.AreEqual(storedSecurityPrinciple.Password, firstPasswordHashed, $"Expected SecurityPrinciple returned by GetSecurityPrinciple() to match Password '{firstPasswordHashed}' of SecurityPrinciple passed into AddSecurityPrinciple()"); + } + + [TestMethod] + public async Task TestDeleteSecurityPrinciple() + { + // Add test security principles + var adminSecurityPrinciples = new List { TestSecurityPrinciples.Admin, TestSecurityPrinciples.TestAdmin }; + adminSecurityPrinciples.ForEach(async p => await access.AddSecurityPrinciple(contextUserId, p, bypassIntegrityCheck: true)); + + // Setup security principle actions + var actions = Policies.GetStandardResourceActions().FindAll(a => a.ResourceType == ResourceTypes.System); + actions.ForEach(async a => await access.AddResourceAction(contextUserId, a)); + + // Setup policy with actions and add policy to store + var policy = Policies.GetStandardPolicies().Find(p => p.Id == StandardPolicies.AccessAdmin); + _ = await access.AddResourcePolicy(contextUserId, policy, bypassIntegrityCheck: true); + + // Setup and add roles and policy assignments to store + var role = Policies.GetStandardRoles().Find(r => r.Id == StandardRoles.Administrator.Id); + await access.AddRole(contextUserId, role, bypassIntegrityCheck: true); + + // Assign security principles to roles and add roles and policy assignments to store + var assignedRoles = new List { TestAssignedRoles.Admin, TestAssignedRoles.TestAdmin }; + assignedRoles.ForEach(async r => await access.AddAssignedRole(contextUserId, r, bypassIntegrityCheck: true)); + + // Validate SecurityPrinciple object returned by AccessControl.GetSecurityPrinciple() before delete is not null + var storedSecurityPrinciple = await access.GetSecurityPrinciple(contextUserId, adminSecurityPrinciples[0].Id); + Assert.IsNotNull(storedSecurityPrinciple, "Expected object returned by AccessControl.GetSecurityPrinciple() to not be null"); + Assert.AreEqual(storedSecurityPrinciple.Id, adminSecurityPrinciples[0].Id, $"Expected SecurityPrinciple returned by GetSecurityPrinciple() to match Id '{adminSecurityPrinciples[0].Id}' of SecurityPrinciple passed into AddSecurityPrinciple()"); + + // Delete first security principle in adminSecurityPrinciples list from AccessControl store + var securityPrincipleDeleted = await access.DeleteSecurityPrinciple(contextUserId, adminSecurityPrinciples[0].Id); + Assert.IsTrue(securityPrincipleDeleted, $"Expected security principle deletion for {adminSecurityPrinciples[0].Id} to succeed"); + + // Validate SecurityPrinciple object returned by AccessControl.GetSecurityPrinciple() after delete is null + storedSecurityPrinciple = await access.GetSecurityPrinciple(contextUserId, adminSecurityPrinciples[0].Id); + Assert.IsNull(storedSecurityPrinciple, $"Expected SecurityPrinciple for '{adminSecurityPrinciples[0].Id}' to be null from GetSecurityPrinciple()"); + } + + [TestMethod] + public async Task TestDeleteSecurityPrincipleNoRoles() + { + // Add test security principles + var adminSecurityPrinciples = new List { TestSecurityPrinciples.Admin, TestSecurityPrinciples.TestAdmin }; + adminSecurityPrinciples.ForEach(async p => await access.AddSecurityPrinciple(contextUserId, p, bypassIntegrityCheck: true)); + + // Validate SecurityPrinciple object returned by AccessControl.GetSecurityPrinciple() before delete is not null + var storedSecurityPrinciple = await access.GetSecurityPrinciple(contextUserId, adminSecurityPrinciples[0].Id); + Assert.IsNotNull(storedSecurityPrinciple, "Expected object returned by AccessControl.GetSecurityPrinciple() to not be null"); + Assert.AreEqual(storedSecurityPrinciple.Id, adminSecurityPrinciples[0].Id, $"Expected SecurityPrinciple returned by GetSecurityPrinciple() to match Id '{adminSecurityPrinciples[0].Id}' of SecurityPrinciple passed into AddSecurityPrinciple()"); + + // Try to delete first security principle in adminSecurityPrinciples list from AccessControl store + var securityPrincipleDeleted = await access.DeleteSecurityPrinciple(contextUserId, adminSecurityPrinciples[0].Id); + Assert.IsFalse(securityPrincipleDeleted, $"Expected security principle deletion for {adminSecurityPrinciples[0].Id} to fail without roles defined"); + + // Validate SecurityPrinciple object returned by AccessControl.GetSecurityPrinciple() after delete is not null + storedSecurityPrinciple = await access.GetSecurityPrinciple(contextUserId, adminSecurityPrinciples[0].Id); + Assert.IsNotNull(storedSecurityPrinciple, $"Expected SecurityPrinciple for '{adminSecurityPrinciples[0].Id}' to not be null from GetSecurityPrinciple()"); + } + + [TestMethod] + public async Task TestDeleteSecurityPrincipleSelfDeletion() + { + // Add test security principles + var adminSecurityPrinciples = new List { TestSecurityPrinciples.Admin, TestSecurityPrinciples.TestAdmin }; + adminSecurityPrinciples.ForEach(async p => await access.AddSecurityPrinciple(contextUserId, p, bypassIntegrityCheck: true)); + + // Setup security principle actions + var actions = Policies.GetStandardResourceActions().FindAll(a => a.ResourceType == ResourceTypes.System); + actions.ForEach(async a => await access.AddResourceAction(contextUserId, a)); + + // Setup policy with actions and add policy to store + var policy = Policies.GetStandardPolicies().Find(p => p.Id == StandardPolicies.AccessAdmin); + _ = await access.AddResourcePolicy(contextUserId, policy, bypassIntegrityCheck: true); + + // Setup and add roles and policy assignments to store + var role = Policies.GetStandardRoles().Find(r => r.Id == StandardRoles.Administrator.Id); + await access.AddRole(contextUserId, role); + + // Assign security principles to roles and add roles and policy assignments to store + var assignedRoles = new List { TestAssignedRoles.Admin, TestAssignedRoles.TestAdmin }; + assignedRoles.ForEach(async r => await access.AddAssignedRole(contextUserId, r)); + + // Validate SecurityPrinciple object returned by AccessControl.GetSecurityPrinciple() before delete is not null + var storedSecurityPrinciple = await access.GetSecurityPrinciple(contextUserId, adminSecurityPrinciples[1].Id); + Assert.IsNotNull(storedSecurityPrinciple, "Expected object returned by AccessControl.GetSecurityPrinciple() to not be null"); + Assert.AreEqual(storedSecurityPrinciple.Id, adminSecurityPrinciples[1].Id, $"Expected SecurityPrinciple returned by GetSecurityPrinciple() to match Id '{adminSecurityPrinciples[1].Id}' of SecurityPrinciple passed into AddSecurityPrinciple()"); + + // Try to delete second security principle in adminSecurityPrinciples list from AccessControl store + var securityPrincipleDeleted = await access.DeleteSecurityPrinciple(contextUserId, contextUserId); + Assert.IsFalse(securityPrincipleDeleted, $"Expected security principle self deletion for {contextUserId} to fail"); + + // Validate SecurityPrinciple object returned by AccessControl.GetSecurityPrinciple() after delete is not null + storedSecurityPrinciple = await access.GetSecurityPrinciple(contextUserId, adminSecurityPrinciples[1].Id); + Assert.IsNotNull(storedSecurityPrinciple, $"Expected SecurityPrinciple for '{adminSecurityPrinciples[1].Id}' to not be null from GetSecurityPrinciple()"); + } + + [TestMethod] + public async Task TestDeleteSecurityPrincipleBadId() + { + // Add test security principles + var adminSecurityPrinciples = new List { TestSecurityPrinciples.Admin, TestSecurityPrinciples.TestAdmin }; + adminSecurityPrinciples.ForEach(async p => await access.AddSecurityPrinciple(contextUserId, p, bypassIntegrityCheck: true)); + + // Setup security principle actions + var actions = Policies.GetStandardResourceActions().FindAll(a => a.ResourceType == ResourceTypes.System); + actions.ForEach(async a => await access.AddResourceAction(contextUserId, a)); + + // Setup policy with actions and add policy to store + var policy = Policies.GetStandardPolicies().Find(p => p.Id == StandardPolicies.AccessAdmin); + _ = await access.AddResourcePolicy(contextUserId, policy, bypassIntegrityCheck: true); + + // Setup and add roles and policy assignments to store + var role = Policies.GetStandardRoles().Find(r => r.Id == StandardRoles.Administrator.Id); + await access.AddRole(contextUserId, role); + + // Assign security principles to roles and add roles and policy assignments to store + var assignedRoles = new List { TestAssignedRoles.Admin, TestAssignedRoles.TestAdmin }; + assignedRoles.ForEach(async r => await access.AddAssignedRole(contextUserId, r)); + + // Validate SecurityPrinciple object returned by AccessControl.GetSecurityPrinciple() before delete is not null + var storedSecurityPrinciple = await access.GetSecurityPrinciple(contextUserId, adminSecurityPrinciples[1].Id); + Assert.IsNotNull(storedSecurityPrinciple, "Expected object returned by AccessControl.GetSecurityPrinciple() to not be null"); + Assert.AreEqual(storedSecurityPrinciple.Id, adminSecurityPrinciples[1].Id, $"Expected SecurityPrinciple returned by GetSecurityPrinciple() to match Id '{adminSecurityPrinciples[1].Id}' of SecurityPrinciple passed into AddSecurityPrinciple()"); + + // Try to delete second security principle in adminSecurityPrinciples list from AccessControl store + var securityPrincipleDeleted = await access.DeleteSecurityPrinciple(contextUserId, contextUserId.ToUpper()); + Assert.IsFalse(securityPrincipleDeleted, $"Expected security principle deletion for {contextUserId.ToUpper()} to fail"); + + // Validate SecurityPrinciple object returned by AccessControl.GetSecurityPrinciple() after delete is not null + storedSecurityPrinciple = await access.GetSecurityPrinciple(contextUserId, adminSecurityPrinciples[1].Id); + Assert.IsNotNull(storedSecurityPrinciple, $"Expected SecurityPrinciple for '{adminSecurityPrinciples[1].Id}' to not be null from GetSecurityPrinciple()"); + } + + [TestMethod] + public async Task TestIsPrincipleInRole() + { + // Add test security principles + var adminSecurityPrinciples = new List { TestSecurityPrinciples.Admin, TestSecurityPrinciples.TestAdmin }; + adminSecurityPrinciples.ForEach(async p => await access.AddSecurityPrinciple(contextUserId, p, bypassIntegrityCheck: true)); + + // Setup security principle actions + var actions = Policies.GetStandardResourceActions().FindAll(a => a.ResourceType == ResourceTypes.System); + actions.ForEach(async a => await access.AddResourceAction(contextUserId, a, bypassIntegrityCheck: true)); + + // Setup policy with actions and add policy to store + var policy = Policies.GetStandardPolicies().Find(p => p.Id == StandardPolicies.AccessAdmin); + _ = await access.AddResourcePolicy(contextUserId, policy, bypassIntegrityCheck: true); + + // Setup and add roles and policy assignments to store + var role = Policies.GetStandardRoles().Find(r => r.Id == StandardRoles.Administrator.Id); + await access.AddRole(contextUserId, role, bypassIntegrityCheck: true); + + // Assign security principles to roles and add roles and policy assignments to store + var assignedRoles = new List { TestAssignedRoles.Admin, TestAssignedRoles.TestAdmin }; + assignedRoles.ForEach(async r => await access.AddAssignedRole(contextUserId, r, bypassIntegrityCheck: true)); + + // Validate specified admin user is a principle role + bool hasAccess; + foreach (var assignedRole in assignedRoles) + { + hasAccess = await access.IsPrincipleInRole(contextUserId, assignedRole.SecurityPrincipleId, StandardRoles.Administrator.Id); + Assert.IsTrue(hasAccess, $"User '{assignedRole.SecurityPrincipleId}' should be in role"); + } + + // Validate fake admin user is not a principle role + hasAccess = await access.IsPrincipleInRole(contextUserId, "admin_02", StandardRoles.Administrator.Id); + Assert.IsFalse(hasAccess, "User should not be in role"); + } + + [TestMethod] + public async Task TestDomainAuth() + { + // Add test devops user security principle + _ = await access.AddSecurityPrinciple(contextUserId, TestSecurityPrinciples.DevopsUser, bypassIntegrityCheck: true); + + // Setup security principle actions + await access.AddResourceAction(contextUserId, Policies.GetStandardResourceActions().Find(r => r.Id == StandardResourceActions.CertificateDownload)); + + // Setup policy with actions and add policy to store + var policy = Policies.GetStandardPolicies().Find(p => p.Id == StandardPolicies.CertificateConsumer); + _ = await access.AddResourcePolicy(contextUserId, policy, bypassIntegrityCheck: true); + + // Setup and add roles and policy assignments to store + var role = Policies.GetStandardRoles().Find(r => r.Id == StandardRoles.CertificateConsumer.Id); + await access.AddRole(contextUserId, role, bypassIntegrityCheck: true); + + // Assign security principles to roles and add roles and policy assignments to store + await access.AddAssignedRole(contextUserId, TestAssignedRoles.DevopsUserDomainConsumer, true); // devops user in consumer role for a specific domain + + // Validate user can consume a cert for a given domain + var isAuthorised = await access.IsSecurityPrincipleAuthorised(contextUserId, new AccessCheck(TestSecurityPrinciples.DevopsAppDomainConsumer.Id, ResourceTypes.Domain, StandardResourceActions.CertificateDownload, identifier: "www.example.com")); + Assert.IsTrue(isAuthorised, "User should be a cert consumer for this domain"); + + // Validate user can't consume a cert for a subdomain they haven't been granted + isAuthorised = await access.IsSecurityPrincipleAuthorised(contextUserId, new AccessCheck(TestSecurityPrinciples.DevopsAppDomainConsumer.Id, ResourceTypes.Domain, StandardResourceActions.CertificateDownload, identifier: "secure.example.com")); + Assert.IsFalse(isAuthorised, "User should not be a cert consumer for this domain"); + } + + [TestMethod] + public async Task TestWildcardDomainAuth() + { + // Add test devops user security principle + _ = await access.AddSecurityPrinciple(contextUserId, TestSecurityPrinciples.DevopsUser, bypassIntegrityCheck: true); + + // Setup security principle actions + await access.AddResourceAction(contextUserId, Policies.GetStandardResourceActions().Find(r => r.Id == StandardResourceActions.CertificateDownload)); + + // Setup policy with actions and add policy to store + var policy = Policies.GetStandardPolicies().Find(p => p.Id == StandardPolicies.CertificateConsumer); + _ = await access.AddResourcePolicy(contextUserId, policy, bypassIntegrityCheck: true); + + // Setup and add roles and policy assignments to store + var role = Policies.GetStandardRoles().Find(r => r.Id == StandardRoles.CertificateConsumer.Id); + await access.AddRole(contextUserId, role, bypassIntegrityCheck: true); + + // Assign security principles to roles and add roles and policy assignments to store + await access.AddAssignedRole(contextUserId, TestAssignedRoles.DevopsUserWildcardDomainConsumer, bypassIntegrityCheck: true); // devops user in consumer role for a wildcard domain + + // Validate user can consume any subdomain via a granted wildcard + var isAuthorised = await access.IsSecurityPrincipleAuthorised(contextUserId, new AccessCheck(TestSecurityPrinciples.DevopsUser.Id, ResourceTypes.Domain, StandardResourceActions.CertificateDownload, identifier: "random.microsoft.com")); + Assert.IsTrue(isAuthorised, "User should be a cert consumer for this subdomain via wildcard"); + + // Validate user can't consume a random wildcard + isAuthorised = await access.IsSecurityPrincipleAuthorised(contextUserId, new AccessCheck(TestSecurityPrinciples.DevopsUser.Id, ResourceTypes.Domain, StandardResourceActions.CertificateDownload, identifier: "* lkjhasdf98862364")); + Assert.IsFalse(isAuthorised, "User should not be a cert consumer for random wildcard"); + + // Validate user can't consume a random wildcard + isAuthorised = await access.IsSecurityPrincipleAuthorised(contextUserId, new AccessCheck(TestSecurityPrinciples.DevopsUser.Id, ResourceTypes.Domain, StandardResourceActions.CertificateDownload, identifier: "lkjhasdf98862364.*.microsoft.com")); + Assert.IsFalse(isAuthorised, "User should not be a cert consumer for random wildcard"); + } + + [TestMethod] + public async Task TestRandomUserAuth() + { + // Add test devops user security principle + _ = await access.AddSecurityPrinciple(contextUserId, TestSecurityPrinciples.DevopsUser, bypassIntegrityCheck: true); + + // Setup security principle actions + await access.AddResourceAction(contextUserId, Policies.GetStandardResourceActions().Find(r => r.Id == StandardResourceActions.CertificateDownload)); + + // Setup policy with actions and add policy to store + var policy = Policies.GetStandardPolicies().Find(p => p.Id == StandardPolicies.CertificateConsumer); + _ = await access.AddResourcePolicy(contextUserId, policy, bypassIntegrityCheck: true); + + // Setup and add roles and policy assignments to store + var role = Policies.GetStandardRoles().Find(r => r.Id == StandardRoles.CertificateConsumer.Id); + await access.AddRole(contextUserId, role); + + // Assign security principles to roles and add roles and policy assignments to store + await access.AddAssignedRole(contextUserId, TestAssignedRoles.DevopsUserWildcardDomainConsumer); // devops user in consumer role for a wildcard domain + + // Validate that random user should not be authorised + var isAuthorised = await access.IsSecurityPrincipleAuthorised(contextUserId, new AccessCheck("randomuser", ResourceTypes.Domain, StandardResourceActions.CertificateDownload, identifier: "random.microsoft.com")); + Assert.IsFalse(isAuthorised, "Unknown user should not be a cert consumer for this subdomain via wildcard"); + } + + [TestMethod] + public async Task TestSecurityPrinciplePwdValid() + { + // Add test devops user security principle + _ = await access.AddSecurityPrinciple(contextUserId, TestSecurityPrinciples.DevopsUser, bypassIntegrityCheck: true); + var check = await access.CheckSecurityPrinciplePassword(contextUserId, new Models.Hub.SecurityPrinciplePasswordCheck(TestSecurityPrinciples.DevopsUser.Id, TestSecurityPrinciples.DevopsUser.Password)); + + Assert.IsTrue(check.IsSuccess, "Password should be valid"); + } + + [TestMethod] + public async Task TestSecurityPrinciplePwdInvalid() + { + // Add test devops user security principle + _ = await access.AddSecurityPrinciple(contextUserId, TestSecurityPrinciples.DevopsUser, bypassIntegrityCheck: true); + var check = await access.CheckSecurityPrinciplePassword(contextUserId, new Models.Hub.SecurityPrinciplePasswordCheck(TestSecurityPrinciples.DevopsUser.Id, "INVALID_PWD")); + + Assert.IsFalse(check.IsSuccess, "Password should not be valid"); + } + + [TestMethod] + public async Task TestUserAPIToken() + { + // setup a test security principle, add them to the certificate consumer role, assign an API token then test if they are authorized based on the API token + + // allow test admin to perform access checks + var assignedRoles = new List { TestAssignedRoles.TestAdmin }; + assignedRoles.ForEach(async r => await access.AddAssignedRole(contextUserId, r, bypassIntegrityCheck: true)); + + // Add test devops user security principle + _ = await access.AddSecurityPrinciple(contextUserId, TestSecurityPrinciples.DevopsUser, bypassIntegrityCheck: true); + + // Setup security principle actions + await access.AddResourceAction(contextUserId, Policies.GetStandardResourceActions().Find(r => r.Id == StandardResourceActions.CertificateDownload)); + + // Setup policy with actions and add policy to store + var policy = Policies.GetStandardPolicies().Find(p => p.Id == StandardPolicies.CertificateConsumer); + _ = await access.AddResourcePolicy(contextUserId, policy, bypassIntegrityCheck: true); + + // Setup and add roles and policy assignments to store + var role = Policies.GetStandardRoles().Find(r => r.Id == StandardRoles.CertificateConsumer.Id); + await access.AddRole(contextUserId, role); + + // Assign security principles to roles and add roles and policy assignments to store + await access.AddAssignedRole(contextUserId, TestAssignedRoles.DevopsUserWildcardDomainConsumer); // devops user in consumer role for a wildcard domain + + var assignedRolesForDevopsUser = await access.GetAssignedRoles(contextUserId, TestSecurityPrinciples.DevopsUser.Id); + + // create and assign a new API token + var apiToken = new AccessToken { ClientId = TestSecurityPrinciples.DevopsUser.Id, Secret = Guid.NewGuid().ToString(), TokenType = AccessTokenTypes.Simple, Description = "An example API token" }; + var apiExpiredToken = new AccessToken { ClientId = TestSecurityPrinciples.DevopsUser.Id, Secret = Guid.NewGuid().ToString(), TokenType = AccessTokenTypes.Simple, Description = "An example expired API token", DateExpiry = DateTimeOffset.UtcNow.AddDays(-1) }; + var apiRevokedToken = new AccessToken { ClientId = TestSecurityPrinciples.DevopsUser.Id, Secret = Guid.NewGuid().ToString(), TokenType = AccessTokenTypes.Simple, Description = "An example revoked API token", DateRevoked = DateTimeOffset.UtcNow.AddDays(-1) }; + var apiTokenBad = new AccessToken { ClientId = TestSecurityPrinciples.DomainOwner.Id, Secret = Guid.NewGuid().ToString(), TokenType = AccessTokenTypes.Simple, Description = "An example bad API token (invalid client id)" }; + var assignedToken = new AssignedAccessToken + { + AccessTokens = [apiToken, apiExpiredToken, apiRevokedToken], + SecurityPrincipleId = TestSecurityPrinciples.DevopsUser.Id, + Title = "test token", + ScopedAssignedRoles = [assignedRolesForDevopsUser.First(r => r.RoleId == StandardRoles.CertificateConsumer.Id).Id] + }; + + await access.AddAssignedAccessToken(contextUserId, assignedToken); + + var isAuthorized = await access.IsAccessTokenAuthorised(contextUserId, apiToken, new AccessCheck(null, ResourceTypes.Domain, StandardResourceActions.CertificateDownload, identifier: "random.microsoft.com")); + Assert.IsTrue(isAuthorized.IsSuccess, "Token should have access"); + + isAuthorized = await access.IsAccessTokenAuthorised(contextUserId, apiToken, new AccessCheck(null, ResourceTypes.Domain, StandardResourceActions.CertificateDownload, identifier: "random.test.com")); + Assert.IsFalse(isAuthorized.IsSuccess, "Token should not have access (wrong domain identifier resource)"); + + isAuthorized = await access.IsAccessTokenAuthorised(contextUserId, apiTokenBad, new AccessCheck(null, ResourceTypes.Domain, StandardResourceActions.CertificateDownload, identifier: "random.microsoft.com")); + Assert.IsFalse(isAuthorized.IsSuccess, "Token should not have access (bad token)"); + + isAuthorized = await access.IsAccessTokenAuthorised(contextUserId, apiExpiredToken, new AccessCheck(null, ResourceTypes.Domain, StandardResourceActions.CertificateDownload, identifier: "random.microsoft.com")); + Assert.IsFalse(isAuthorized.IsSuccess, "Token should not have access (expired)"); + + isAuthorized = await access.IsAccessTokenAuthorised(contextUserId, apiRevokedToken, new AccessCheck(null, ResourceTypes.Domain, StandardResourceActions.CertificateDownload, identifier: "random.microsoft.com")); + Assert.IsFalse(isAuthorized.IsSuccess, "Token should not have access (revoked)"); + + } + } +} diff --git a/src/Certify.Tests/Certify.Core.Tests.Unit/AccountKeyTests.cs b/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/AccountKeyTests.cs similarity index 100% rename from src/Certify.Tests/Certify.Core.Tests.Unit/AccountKeyTests.cs rename to src/Certify.Tests/Certify.Core.Tests.Unit/Tests/AccountKeyTests.cs diff --git a/src/Certify.Tests/Certify.Core.Tests.Unit/BindingMatchTests.cs b/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/BindingMatchTests.cs similarity index 99% rename from src/Certify.Tests/Certify.Core.Tests.Unit/BindingMatchTests.cs rename to src/Certify.Tests/Certify.Core.Tests.Unit/Tests/BindingMatchTests.cs index eb650ded1..21a3c4207 100644 --- a/src/Certify.Tests/Certify.Core.Tests.Unit/BindingMatchTests.cs +++ b/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/BindingMatchTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.IO; @@ -1240,7 +1240,7 @@ public async Task TestIPSpecific_ExistingHttpsBinding() SubjectAlternativeNames = new string[] { "ipspecific.test.com", "ipspecific2.test.com", "nonipspecific.test.com", "nonipspecific2.test.com", "nonipspecific3.test.com" }, PerformAutomatedCertBinding = true, DeploymentSiteOption = DeploymentOption.SingleSite, - + DeploymentBindingBlankHostname = true, BindingIPAddress = "127.0.0.1", BindingPort = "443", @@ -1261,7 +1261,7 @@ public async Task TestIPSpecific_ExistingHttpsBinding() var results = await deployment.StoreAndDeploy(mockTarget, testManagedCert, "test.pfx", pfxPwd: "", true, Certify.Management.CertificateManager.WEBHOSTING_STORE_NAME); - Assert.AreEqual(6, results.Count(r=>r.ObjectResult is BindingInfo)); + Assert.AreEqual(6, results.Count(r => r.ObjectResult is BindingInfo)); // existing IP specific https binding should be preserved var bindingInfo = results.Last(r => (r.ObjectResult as BindingInfo)?.Host == "ipspecific.test.com")?.ObjectResult as BindingInfo; diff --git a/src/Certify.Tests/Certify.Core.Tests.Unit/CAFailoverTests.cs b/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/CAFailoverTests.cs similarity index 90% rename from src/Certify.Tests/Certify.Core.Tests.Unit/CAFailoverTests.cs rename to src/Certify.Tests/Certify.Core.Tests.Unit/Tests/CAFailoverTests.cs index 25a0cf707..d6bbbabfd 100644 --- a/src/Certify.Tests/Certify.Core.Tests.Unit/CAFailoverTests.cs +++ b/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/CAFailoverTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using Certify.Management; @@ -12,6 +12,14 @@ public class CAFailoverTests { private const string DEFAULTCA = "letscertify"; + // TODO: This requires a valid test CA auth token to run + //private Dictionary ConfigSettings = new Dictionary(); + + //public CAFailoverTests() + //{ + // ConfigSettings = JsonConvert.DeserializeObject>(System.IO.File.ReadAllText("C:\\temp\\Certify\\TestConfigSettings.json")); + //} + private List GetTestCAs() { var caList = new List { @@ -331,4 +339,33 @@ public void TestBasicFailoverOccursOptionalLifetimeDays() Assert.IsTrue(selectedAccount.IsFailoverSelection, "Account should be marked as a failover choice"); } } + + // TODO: This test requires a valid test CA auth token to run + //[TestMethod, Description("Failover to an alternate CA when an item has repeatedly failed, with TnAuthList CA")] + //public void TestBasicFailoverOccursTnAuthList() + //{ + // // setup + // var accounts = GetTestAccounts(); + // var caList = GetTestCAs(); + + // var managedCertificate = GetBasicManagedCertificate(RequestState.Error, 3, lastCA: DEFAULTCA, + // new CertRequestConfig { + // //SubjectAlternativeNames = new List { "test.com", "anothertest.com", "www.test.com" }.ToArray(), + // AuthorityTokens = new ObservableCollection { + // new TkAuthToken{ + // Token = ConfigSettings["TestAuthToken"], + // Crl =ConfigSettings["TestAuthTokenCRL"] + // } + // } + // }); + + // // perform check + // var defaultCAAccount = accounts.FirstOrDefault(a => a.CertificateAuthorityId == DEFAULTCA && a.IsStagingAccount == managedCertificate.UseStagingMode); + + // var selectedAccount = RenewalManager.SelectCAWithFailover(caList, accounts, managedCertificate, defaultCAAccount); + + // // assert result + // Assert.IsTrue(selectedAccount.CertificateAuthorityId == "letsreluctantlyfallback", "Fallback CA should be selected"); + // Assert.IsTrue(selectedAccount.IsFailoverSelection, "Account should be marked as a failover choice"); + //} } diff --git a/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/CertificateEditorServiceTests.cs b/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/CertificateEditorServiceTests.cs new file mode 100644 index 000000000..1c797d81a --- /dev/null +++ b/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/CertificateEditorServiceTests.cs @@ -0,0 +1,261 @@ +using Certify.Models; +using Certify.Models.Shared.Validation; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Certify.Core.Tests.Unit +{ + [TestClass] + public class CertificateEditorServiceTests + { + + [TestMethod, Description("Test primary domain required")] + public void TestPrimaryDomainRequired() + { + + var item = new ManagedCertificate + { + DomainOptions = new System.Collections.ObjectModel.ObservableCollection + { + new DomainOption { Domain = "test.com", IsPrimaryDomain=false, IsSelected=true }, + new DomainOption { Domain = "www.test.com", IsPrimaryDomain=false, IsSelected=true } + }, + RequestConfig = new CertRequestConfig + { + Challenges = new System.Collections.ObjectModel.ObservableCollection + { + new CertRequestChallengeConfig + { + ChallengeType = SupportedChallengeTypes.CHALLENGE_TYPE_HTTP + } + }, + SubjectAlternativeNames = new[] { "test.com", "www.test.com" } + } + }; + + // skip auto config to that primary domain is not auto selected + var validationResult = CertificateEditorService.Validate(item, null, null, false); + + Assert.IsNotNull(validationResult); + Assert.IsFalse(validationResult.IsValid); + Assert.AreEqual(ValidationErrorCodes.PRIMARY_IDENTIFIER_REQUIRED.ToString(), validationResult.ErrorCode); + } + + [TestMethod, Description("Test primary domain too many")] + public void TestPrimaryDomainTooMany() + { + + var item = new ManagedCertificate + { + DomainOptions = new System.Collections.ObjectModel.ObservableCollection + { + new DomainOption { Domain = "test.com", IsPrimaryDomain=true, IsSelected=true }, + new DomainOption { Domain = "www.test.com", IsPrimaryDomain=true, IsSelected=true } + }, + RequestConfig = new CertRequestConfig + { + Challenges = new System.Collections.ObjectModel.ObservableCollection + { + new CertRequestChallengeConfig + { + ChallengeType = SupportedChallengeTypes.CHALLENGE_TYPE_HTTP + } + } + } + }; + + var validationResult = CertificateEditorService.Validate(item, null, null, true); + + Assert.IsNotNull(validationResult); + Assert.IsFalse(validationResult.IsValid); + Assert.AreEqual(ValidationErrorCodes.PRIMARY_IDENTIFIER_TOOMANY.ToString(), validationResult.ErrorCode); + } + + [TestMethod, Description("Test mixed wildcard label validation")] + public void TestMixedWildcardLabels() + { + + var item = new ManagedCertificate + { + DomainOptions = new System.Collections.ObjectModel.ObservableCollection + { + new DomainOption { Domain = "test.com", IsPrimaryDomain=true, IsSelected=true }, + new DomainOption { Domain = "www.test.com", IsPrimaryDomain=false,IsSelected=true }, + new DomainOption { Domain = "*.test.com", IsPrimaryDomain=false,IsSelected=true } + }, + RequestConfig = new CertRequestConfig + { + Challenges = new System.Collections.ObjectModel.ObservableCollection + { + new CertRequestChallengeConfig + { + ChallengeType = SupportedChallengeTypes.CHALLENGE_TYPE_DNS + } + } + } + }; + + var validationResult = CertificateEditorService.Validate(item, null, null, true); + + Assert.IsNotNull(validationResult); + Assert.IsFalse(validationResult.IsValid); + Assert.AreEqual(ValidationErrorCodes.MIXED_WILDCARD_WITH_LABELS.ToString(), validationResult.ErrorCode); + } + + [TestMethod, Description("Test mixed wildcard subdomain-like name allowed")] + public void TestMixedWildcardSubdomainLabels() + { + // in this example *.test.com and *.vs-test.com should be allowed as they are distinct + var item = new ManagedCertificate + { + DomainOptions = new System.Collections.ObjectModel.ObservableCollection + { + new DomainOption { Domain = "test.com", IsPrimaryDomain=true, IsSelected=true }, + new DomainOption { Domain = "vs-test.com", IsPrimaryDomain=false, IsSelected=true }, + new DomainOption { Domain = "*.test.com", IsPrimaryDomain=false,IsSelected=true }, + new DomainOption { Domain = "*.vs-test.com", IsPrimaryDomain=false,IsSelected=true } + }, + RequestConfig = new CertRequestConfig + { + Challenges = new System.Collections.ObjectModel.ObservableCollection + { + new CertRequestChallengeConfig + { + ChallengeType = SupportedChallengeTypes.CHALLENGE_TYPE_DNS, + ChallengeProvider = "DNS01.API.Route53" + } + } + } + }; + + var validationResult = CertificateEditorService.Validate(item, null, null, true); + + Assert.IsNotNull(validationResult); + Assert.IsTrue(validationResult.IsValid); + + } + + [TestMethod, Description("Test mixed wildcard subdomain-like with invalid subdomain label")] + public void TestMixedWildcardSubdomainWithInvalidLabels() + { + // in this example *.test.com and *.vs-test.com should be allowed as they are distinct + var item = new ManagedCertificate + { + DomainOptions = new System.Collections.ObjectModel.ObservableCollection + { + new DomainOption { Domain = "test.com", IsPrimaryDomain=true, IsSelected=true }, + new DomainOption { Domain = "vs-test.com", IsPrimaryDomain=false, IsSelected=true }, + new DomainOption { Domain = "*.test.com", IsPrimaryDomain=false,IsSelected=true }, + new DomainOption { Domain = "*.vs-test.com", IsPrimaryDomain=false,IsSelected=true }, + new DomainOption { Domain = "www.vs-test.com", IsPrimaryDomain=false,IsSelected=true } + }, + RequestConfig = new CertRequestConfig + { + Challenges = new System.Collections.ObjectModel.ObservableCollection + { + new CertRequestChallengeConfig + { + ChallengeType = SupportedChallengeTypes.CHALLENGE_TYPE_DNS, + ChallengeProvider = "DNS01.API.Route53" + } + } + } + }; + + var validationResult = CertificateEditorService.Validate(item, null, null, true); + + Assert.IsNotNull(validationResult); + Assert.IsFalse(validationResult.IsValid); + Assert.AreEqual(ValidationErrorCodes.MIXED_WILDCARD_WITH_LABELS.ToString(), validationResult.ErrorCode); + + } + + [TestMethod, Description("Test mixed wildcard invalid challenge type")] + public void TestMixedWildcardInvalidChallenge() + { + + var item = new ManagedCertificate + { + DomainOptions = new System.Collections.ObjectModel.ObservableCollection + { + new DomainOption { Domain = "test.com", IsPrimaryDomain=true, IsSelected=true }, + new DomainOption { Domain = "www.test.com", IsPrimaryDomain=false,IsSelected=true }, + new DomainOption { Domain = "*.test.com", IsPrimaryDomain=false,IsSelected=true } + }, + RequestConfig = new CertRequestConfig + { + Challenges = new System.Collections.ObjectModel.ObservableCollection + { + new CertRequestChallengeConfig + { + ChallengeType = SupportedChallengeTypes.CHALLENGE_TYPE_HTTP + } + } + } + }; + + var validationResult = CertificateEditorService.Validate(item, null, null, true); + + Assert.IsNotNull(validationResult); + Assert.IsFalse(validationResult.IsValid); + Assert.AreEqual(ValidationErrorCodes.CHALLENGE_TYPE_INVALID.ToString(), validationResult.ErrorCode); + } + + [TestMethod, Description("Test max CN length")] + public void TestMaxCNLength() + { + var item = new ManagedCertificate + { + DomainOptions = new System.Collections.ObjectModel.ObservableCollection + { + new DomainOption { Domain = "TherearemanyvariationsofpassagesofLoremIpsumavailablebutthemajorityhavesufferedalterationinsomeformbyinjectedhumourorrandomisedwordswhichdontlookevenslightlybelievable.com", IsPrimaryDomain=true, IsSelected=true }, + new DomainOption { Domain = "www.test.com", IsPrimaryDomain=false,IsSelected=true } + }, + RequestConfig = new CertRequestConfig + { + Challenges = new System.Collections.ObjectModel.ObservableCollection + { + new CertRequestChallengeConfig + { + ChallengeType = SupportedChallengeTypes.CHALLENGE_TYPE_HTTP + } + } + } + }; + + var validationResult = CertificateEditorService.Validate(item, null, null, true); + + Assert.IsNotNull(validationResult); + Assert.IsFalse(validationResult.IsValid); + Assert.AreEqual(ValidationErrorCodes.CN_LIMIT.ToString(), validationResult.ErrorCode); + } + + [TestMethod, Description("Test with invalid local hostname")] + public void TestInvalidHostname() + { + var item = new ManagedCertificate + { + DomainOptions = new System.Collections.ObjectModel.ObservableCollection + { + new DomainOption { Domain = "intranet.local", IsPrimaryDomain=true, IsSelected=true }, + new DomainOption { Domain = "exchange01", IsPrimaryDomain=false,IsSelected=true } + }, + RequestConfig = new CertRequestConfig + { + Challenges = new System.Collections.ObjectModel.ObservableCollection + { + new CertRequestChallengeConfig + { + ChallengeType = SupportedChallengeTypes.CHALLENGE_TYPE_HTTP + } + } + } + }; + + var validationResult = CertificateEditorService.Validate(item, null, null, true); + + Assert.IsNotNull(validationResult); + Assert.IsFalse(validationResult.IsValid); + Assert.AreEqual(ValidationErrorCodes.INVALID_HOSTNAME.ToString(), validationResult.ErrorCode); + } + } +} diff --git a/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/CertificateOperationTests.cs b/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/CertificateOperationTests.cs new file mode 100644 index 000000000..231022096 --- /dev/null +++ b/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/CertificateOperationTests.cs @@ -0,0 +1,153 @@ +using System; +using System.Diagnostics; +using System.Runtime.InteropServices; +using Certify.Management; +using Certify.Models; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Certify.Core.Tests.Unit +{ + [TestClass] + public class CertificateOperationTests + { + [TestMethod, Description("Test self signed cert")] + public void TestSelfSignedCertCreate() + { + + var cert = CertificateManager.GenerateSelfSignedCertificate("test.com", new DateTime(1934, 01, 01), new DateTime(1934, 03, 01), suffix: "[Certify](test)"); + Assert.IsNotNull(cert); + } + + [TestMethod, Description("Test self signed cert storage")] + public void TestSelfSignedCertCreateAndStore() + { + + var cert = CertificateManager.GenerateSelfSignedCertificate("test.com", new DateTime(1934, 01, 01), new DateTime(1934, 03, 01), suffix: "[Certify](test)"); + Assert.IsNotNull(cert); + + CertificateManager.StoreCertificate(cert, CertificateManager.DEFAULT_STORE_NAME); + + var storedCert = CertificateManager.GetCertificateByThumbprint(cert.Thumbprint, CertificateManager.DEFAULT_STORE_NAME); + Assert.IsNotNull(storedCert); + + CertificateManager.RemoveCertificate(storedCert, CertificateManager.DEFAULT_STORE_NAME); + } + + [TestMethod, Description("Test localhost cert")] + public void TestSelfSignedLocalhostCertCreateAndStore() + { + + var cert = CertificateManager.GenerateSelfSignedCertificate("localhost", DateTime.UtcNow, DateTime.UtcNow.AddDays(30), suffix: "[Certify](test)"); + Assert.IsNotNull(cert); + + CertificateManager.StoreCertificate(cert, CertificateManager.DEFAULT_STORE_NAME); + + var storedCert = CertificateManager.GetCertificateByThumbprint(cert.Thumbprint, CertificateManager.DEFAULT_STORE_NAME); + Assert.IsNotNull(storedCert); + + CertificateManager.RemoveCertificate(storedCert, CertificateManager.DEFAULT_STORE_NAME); + } + + [TestMethod, Description("Test get cert RSA private key file path")] + public void TestGetRSAPrivateKeyPath() + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + Debug.WriteLine("Test only valid on Windows, skipping"); + return; + } + + var cert = CertificateManager.GenerateSelfSignedCertificate("localhost", DateTime.UtcNow, DateTime.UtcNow.AddDays(30), suffix: "[Certify](test)", keyType: StandardKeyTypes.RSA256); + + CertificateManager.StoreCertificate(cert, CertificateManager.DEFAULT_STORE_NAME); + + var storedCert = CertificateManager.GetCertificateByThumbprint(cert.Thumbprint, CertificateManager.DEFAULT_STORE_NAME); + Assert.IsNotNull(storedCert); + + try + { + var path = CertificateManager.GetCertificatePrivateKeyPath(storedCert); + Assert.IsNotNull(path); + } + finally + { + CertificateManager.RemoveCertificate(storedCert, CertificateManager.DEFAULT_STORE_NAME); + } + } + + [TestMethod, Description("Test get cert ECDSA private key file path")] + public void TestGetECDSAPrivateKeyPath() + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + Debug.WriteLine("Test only valid on Windows, skipping"); + return; + } + + var cert = CertificateManager.GenerateSelfSignedCertificate("localhost", DateTime.UtcNow, DateTime.UtcNow.AddDays(30), suffix: "[Certify](test)", keyType: StandardKeyTypes.ECDSA256); + + CertificateManager.StoreCertificate(cert, CertificateManager.DEFAULT_STORE_NAME); + + var storedCert = CertificateManager.GetCertificateByThumbprint(cert.Thumbprint, CertificateManager.DEFAULT_STORE_NAME); + Assert.IsNotNull(storedCert); + + try + { + var path = CertificateManager.GetCertificatePrivateKeyPath(storedCert); + Assert.IsNotNull(path); + } + finally + { + CertificateManager.RemoveCertificate(storedCert, CertificateManager.DEFAULT_STORE_NAME); + } + } + + [TestMethod, Description("Test private key set ACL")] + [DataTestMethod] + [DataRow("NT AUTHORITY\\LOCAL SERVICE", StandardKeyTypes.RSA256, "read", true, "RSA Key Type, Read")] + [DataRow("NT AUTHORITY\\LOCAL SERVICE", StandardKeyTypes.RSA256, "fullcontrol", true, "RSA Key Type, Full Control")] + [DataRow("NT AUTHORITY\\LOCAL SERVICE", StandardKeyTypes.ECDSA256, "read", true, "ECDSA Key Type, Read")] + [DataRow("NT AUTHORITY\\LOCAL SERVICE", StandardKeyTypes.ECDSA256, "fullcontrol", true, "ECDSA Key Type, Full Control")] + [DataRow("NT AUTHORITY\\MadeUpUser", StandardKeyTypes.ECDSA256, "fullcontrol", false, "ECDSA Key Type, Full Control, Invalid User")] + public void TestSetACLOnPrivateKey(string account, string keyType, string fileSystemRights, bool isUserValid, string testDescription) + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + Debug.WriteLine("Test only valid on Windows, skipping"); + return; + } + + var log = new Loggy(LoggerFactory.Create(builder => builder.AddDebug()).CreateLogger()); + + var cert = CertificateManager.GenerateSelfSignedCertificate("localhost", DateTime.UtcNow, DateTime.UtcNow.AddDays(30), suffix: "[Certify](test)", keyType: keyType); + + CertificateManager.StoreCertificate(cert, CertificateManager.DEFAULT_STORE_NAME); + + var storedCert = CertificateManager.GetCertificateByThumbprint(cert.Thumbprint, CertificateManager.DEFAULT_STORE_NAME); + Assert.IsNotNull(storedCert); + + try + { + + var success = CertificateManager.GrantUserAccessToCertificatePrivateKey(storedCert, account, fileSystemRights: fileSystemRights, log); + + if (isUserValid) + { + Assert.IsTrue(success, "Updating the ACL for the private key should succeed"); + + var hasAccess = CertificateManager.HasUserAccessToCertificatePrivateKey(storedCert, account, fileSystemRights: fileSystemRights, log); + Assert.IsTrue(hasAccess, "User should have the required access on the private key"); + } + else + { + Assert.IsFalse(success, "Updating the ACL for the private key should fail due to invalid user specified"); + } + } + finally + { + CertificateManager.RemoveCertificate(storedCert, CertificateManager.DEFAULT_STORE_NAME); + } + } + } +} diff --git a/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/CertifyManagerAccountTests.cs b/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/CertifyManagerAccountTests.cs new file mode 100644 index 000000000..4af5bac94 --- /dev/null +++ b/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/CertifyManagerAccountTests.cs @@ -0,0 +1,1173 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Certify.ACME.Anvil; +using Certify.Management; +using Certify.Models; +using Certify.Providers.ACME.Anvil; +using DotNet.Testcontainers.Builders; +using DotNet.Testcontainers.Containers; +using DotNet.Testcontainers.Volumes; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Newtonsoft.Json; + +namespace Certify.Core.Tests.Unit +{ + [TestClass] + public class CertifyManagerAccountTests + { + private static readonly bool _isContainer = Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER") == "true"; + private static readonly bool _isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + private static readonly string _winRunnerTempDir = "C:\\Temp\\.step"; + private static string _caDomain; + private static int _caPort; + private static IContainer _caContainer; + private static IVolume _stepVolume; + private static Loggy _log; + private CertifyManager _certifyManager; + private CertificateAuthority _customCa; + private AccountDetails _customCaAccount; + + [ClassInitialize] + public static async Task ClassInit(TestContext context) + { + + _log = new Loggy(LoggerFactory.Create(builder => builder.AddDebug()).CreateLogger()); + + _caDomain = _isContainer ? "step-ca" : "localhost"; + _caPort = 9000; + + await BootstrapStepCa(); + await CheckCustomCaIsRunning(); + } + + [TestInitialize] + public async Task TestInit() + { + _certifyManager = new CertifyManager(); + _certifyManager.Init().Wait(); + + await AddCustomCa(); + await AddNewCustomCaAccount(); + await CheckForExistingLeAccount(); + } + + [TestCleanup] + public async Task Cleanup() + { + if (_customCaAccount != null) + { + await _certifyManager.RemoveAccount(_customCaAccount.StorageKey, true); + } + + if (_customCa != null) + { + await _certifyManager.RemoveCertificateAuthority(_customCa.Id); + } + + _certifyManager?.Dispose(); + } + + [ClassCleanup(ClassCleanupBehavior.EndOfClass)] + public static async Task ClassCleanup() + { + if (!_isContainer) + { + await _caContainer.DisposeAsync(); + if (_stepVolume != null) + { + await _stepVolume.DeleteAsync(); + await _stepVolume.DisposeAsync(); + } + else + { + Directory.Delete(_winRunnerTempDir, true); + } + } + + var stepConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".step", "config"); + if (Directory.Exists(stepConfigPath)) + { + Directory.Delete(stepConfigPath, true); + } + + var stepCertsPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".step", "certs"); + if (Directory.Exists(stepCertsPath)) + { + Directory.Delete(stepCertsPath, true); + } + } + + private static async Task BootstrapStepCa() + { + string stepCaFingerprint; + + // If running in a container + if (_isContainer) + { + // Step container volume path containing step-ca config based on OS + var configPath = _isWindows ? "C:\\step_share\\config\\defaults.json" : "/mnt/step_share/config/defaults.json"; + + // Wait till step-ca config file is written + while (!File.Exists(configPath)) { } + + // Read step-ca fingerprint from config file + var stepCaConfigJson = JsonReader.ReadFile(configPath); + stepCaFingerprint = stepCaConfigJson.fingerprint; + } + else + { + var dockerInfo = RunCommand("docker", "info --format \"{{ .OSType }}\"", "Get Docker Info"); + var runningWindowsDockerEngine = dockerInfo.output.Contains("windows"); + + // Start new step-ca container + await StartStepCaContainer(runningWindowsDockerEngine); + + // Read step-ca fingerprint from config file + if (_isWindows && runningWindowsDockerEngine) + { + // Read step-ca fingerprint from config file + var stepCaConfigJson = JsonReader.ReadFile($"{_winRunnerTempDir}\\config\\defaults.json"); + stepCaFingerprint = stepCaConfigJson.fingerprint; + } + else + { + var stepCaConfigBytes = await _caContainer.ReadFileAsync("/home/step/config/defaults.json"); + var stepCaConfigJson = JsonReader.ReadBytes(stepCaConfigBytes); + stepCaFingerprint = stepCaConfigJson.fingerprint; + } + } + + // Run bootstrap command + var args = $"ca bootstrap -f --ca-url https://{_caDomain}:{_caPort} --fingerprint {stepCaFingerprint}"; + RunCommand("step", args, "Bootstrap Step CA Script", 1000 * 30); + } + + private static async Task StartStepCaContainer(bool runningWindowsDockerEngine) + { + try + { + if (_isWindows && runningWindowsDockerEngine) + { + if (!Directory.Exists(_winRunnerTempDir)) + { + Directory.CreateDirectory(_winRunnerTempDir); + } + + // Create new step-ca container + _caContainer = new ContainerBuilder() + .WithName("step-ca") + // Set the image for the container to "webprofusion/step-ca-win:latest". + .WithImage("webprofusion/step-ca-win:latest") + .WithBindMount(_winRunnerTempDir, "C:\\Users\\ContainerUser\\.step") + // Bind port 9000 of the container to port 9000 on the host. + .WithPortBinding(_caPort) + .WithEnvironment("DOCKER_STEPCA_INIT_NAME", "Smallstep") + .WithEnvironment("DOCKER_STEPCA_INIT_DNS_NAMES", _caDomain) + .WithEnvironment("DOCKER_STEPCA_INIT_REMOTE_MANAGEMENT", "true") + .WithEnvironment("DOCKER_STEPCA_INIT_ACME", "true") + // Wait until the HTTPS endpoint of the container is available. + .WithWaitStrategy(Wait.ForUnixContainer().UntilMessageIsLogged($"Serving HTTPS on :{_caPort} ...")) + // Build the container configuration. + .Build(); + } + else + { + // Create new volume for step-ca container + _stepVolume = new VolumeBuilder().WithName("step").Build(); + await _stepVolume.CreateAsync(); + + // Create new step-ca container + _caContainer = new ContainerBuilder() + .WithName("step-ca") + // Set the image for the container to "smallstep/step-ca:latest". + .WithImage("smallstep/step-ca:latest") + .WithVolumeMount(_stepVolume, "/home/step") + // Bind port 9000 of the container to port 9000 on the host. + .WithPortBinding(_caPort) + .WithEnvironment("DOCKER_STEPCA_INIT_NAME", "Smallstep") + .WithEnvironment("DOCKER_STEPCA_INIT_DNS_NAMES", _caDomain) + .WithEnvironment("DOCKER_STEPCA_INIT_REMOTE_MANAGEMENT", "true") + .WithEnvironment("DOCKER_STEPCA_INIT_ACME", "true") + // Wait until the HTTPS endpoint of the container is available. + .WithWaitStrategy(Wait.ForUnixContainer().UntilMessageIsLogged($"Serving HTTPS on :{_caPort} ...")) + // Build the container configuration. + .Build(); + } + + // Start step-ca container + await _caContainer.StartAsync(); + } + catch (Exception) + { + throw; + } + } + + private static class JsonReader + { + public static T ReadFile(string filePath) + { + using (var streamReader = new StreamReader(File.Open(filePath, FileMode.Open))) + { + using (var jsonTextReader = new JsonTextReader(streamReader)) + { + var serializer = new JsonSerializer(); + return serializer.Deserialize(jsonTextReader); + } + } + } + + public static T ReadBytes(byte[] bytes) + { + using (var stringReader = new StringReader(Encoding.UTF8.GetString(bytes))) + { + using (var jsonTextReader = new JsonTextReader(stringReader)) + { + var serializer = new JsonSerializer(); + return serializer.Deserialize(jsonTextReader); + } + } + } + } + + private class StepCaConfig + { + [JsonProperty(PropertyName = "ca-url")] + public string ca_url = string.Empty; + [JsonProperty(PropertyName = "ca-config")] + public string ca_config = string.Empty; + public string fingerprint = string.Empty; + public string root = string.Empty; + } + + private static CommandOutput RunCommand(string program, string args, string description = null, int timeoutMS = Timeout.Infinite) + { + if (description == null) { description = string.Concat(program, " ", args); } + + var output = ""; + var errorOutput = ""; + + var startInfo = new ProcessStartInfo() + { + FileName = program, + Arguments = args, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + var process = new Process() { StartInfo = startInfo }; + + process.OutputDataReceived += (obj, a) => + { + if (!string.IsNullOrWhiteSpace(a.Data)) + { + _log.Information(a.Data); + output += a.Data; + } + }; + + process.ErrorDataReceived += (obj, a) => + { + if (!string.IsNullOrWhiteSpace(a.Data)) + { + _log.Error($"Error: {a.Data}"); + errorOutput += a.Data; + } + }; + + try + { + process.Start(); + + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + process.WaitForExit(timeoutMS); + } + catch (Exception exp) + { + _log.Error($"Error Running ${description}: " + exp.ToString()); + throw; + } + + _log.Information($"{description} is Finished"); + + return new CommandOutput { errorOutput = errorOutput, output = output, exitCode = process.ExitCode }; + } + + private struct CommandOutput + { + public string errorOutput { get; set; } + public string output { get; set; } + public int exitCode { get; set; } + } + + private static async Task CheckCustomCaIsRunning() + { + var httpHandler = new HttpClientHandler(); + + httpHandler.ServerCertificateCustomValidationCallback = (message, certificate, chain, sslPolicyErrors) => true; + + var loggingHandler = new LoggingHandler(httpHandler, _log, maxRequestsPerSecond: 2); + var stepCaHttp = new HttpClient(loggingHandler); + var healthRes = await stepCaHttp.GetAsync($"https://{_caDomain}:{_caPort}/health"); + var healthResStr = await healthRes.Content.ReadAsStringAsync(); + Assert.AreEqual("{\"status\":\"ok\"}\n", (healthResStr)); + } + + private async Task AddCustomCa() + { + _customCa = new CertificateAuthority + { + Id = "step-ca", + Title = "Custom Step CA", + IsCustom = true, + IsEnabled = true, + APIType = CertAuthorityAPIType.ACME_V2.ToString(), + ProductionAPIEndpoint = $"https://{_caDomain}:{_caPort}/acme/acme/directory", + StagingAPIEndpoint = $"https://{_caDomain}:{_caPort}/acme/acme/directory", + RequiresEmailAddress = true, + AllowUntrustedTls = true, + SANLimit = 100, + StandardExpiryDays = 90, + SupportedFeatures = new List + { + CertAuthoritySupportedRequests.DOMAIN_SINGLE.ToString(), + CertAuthoritySupportedRequests.DOMAIN_MULTIPLE_SAN.ToString(), + CertAuthoritySupportedRequests.DOMAIN_WILDCARD.ToString() + }, + SupportedKeyTypes = new List + { + StandardKeyTypes.ECDSA256, + } + }; + var updateCaRes = await _certifyManager.UpdateCertificateAuthority(_customCa); + Assert.IsTrue(updateCaRes.IsSuccess, $"Expected Custom CA creation for CA with ID {_customCa.Id} to be successful"); + } + + private async Task AddNewCustomCaAccount() + { + if (_customCa?.Id != null) + { + var contactRegistration = new ContactRegistration + { + AgreedToTermsAndConditions = true, + CertificateAuthorityId = _customCa.Id, + EmailAddress = "admin." + Guid.NewGuid().ToString().Substring(0, 6) + "@test.com", + ImportedAccountKey = "", + ImportedAccountURI = "", + IsStaging = true + }; + + // Add account + var addAccountRes = await _certifyManager.AddAccount(contactRegistration); + Assert.IsTrue(addAccountRes.IsSuccess, $"Expected account creation to be successful for {contactRegistration.EmailAddress}"); + _customCaAccount = (await _certifyManager.GetAccountRegistrations()).Find(a => a.Email == contactRegistration.EmailAddress); + } + } + + private async Task CheckForExistingLeAccount() + { + if ((await _certifyManager.GetAccountRegistrations()).Find(a => a.CertificateAuthorityId == "letsencrypt.org") == null) + { + var contactRegistration = new ContactRegistration + { + AgreedToTermsAndConditions = true, + CertificateAuthorityId = "letsencrypt.org", + EmailAddress = "admin." + Guid.NewGuid().ToString().Substring(0, 6) + "@test.com", + ImportedAccountKey = "", + ImportedAccountURI = "", + IsStaging = true + }; + + // Add account + var addAccountRes = await _certifyManager.AddAccount(contactRegistration); + Assert.IsTrue(addAccountRes.IsSuccess, $"Expected account creation to be successful for {contactRegistration.EmailAddress}"); + } + } + + [TestMethod, Description("Happy path test for using CertifyManager.GetAccountDetails()")] + public async Task TestCertifyManagerGetAccountDetails() + { + var dummyManagedCert = (new ManagedCertificate { UseStagingMode = true }); + var caAccount = await _certifyManager.GetAccountDetails(dummyManagedCert); + Assert.IsNotNull(caAccount, "Expected result of CertifyManager.GetAccountDetails() to not be null"); + } + + [TestMethod, Description("Test for using CertifyManager.GetAccountDetails() when passed in managed certificate is null")] + public async Task TestCertifyManagerGetAccountDetailsNullItem() + { + var caAccount = await _certifyManager.GetAccountDetails(null); + Assert.IsNotNull(caAccount, "Expected result of CertifyManager.GetAccountDetails() to not be null"); + } + + [TestMethod, Description("Test for using CertifyManager.GetAccountDetails() when allowCache is false")] + public async Task TestCertifyManagerGetAccountDetailsAllowCacheFalse() + { + var dummyManagedCert = (new ManagedCertificate { UseStagingMode = true }); + var caAccount = await _certifyManager.GetAccountDetails(dummyManagedCert, false); + Assert.IsNotNull(caAccount, "Expected result of CertifyManager.GetAccountDetails() to not be null"); + } + + [TestMethod, Description("Test for using CertifyManager.GetAccountDetails() when CertificateAuthorityId is defined in passed ManagedCertificate")] + public async Task TestCertifyManagerGetAccountDetailsDefinedCertificateAuthorityId() + { + var dummyManagedCert = (new ManagedCertificate { UseStagingMode = true, CertificateAuthorityId = _customCa.Id }); + var caAccount = await _certifyManager.GetAccountDetails(dummyManagedCert); + Assert.IsNotNull(caAccount, "Expected result of CertifyManager.GetAccountDetails() to not be null"); + Assert.AreEqual(_customCa.Id, caAccount.CertificateAuthorityId, $"Unexpected certificate authority id '{caAccount.CertificateAuthorityId}'"); + } + + [TestMethod, Description("Test for using CertifyManager.GetAccountDetails() when OverrideAccountDetails is defined in CertifyManager")] + public async Task TestCertifyManagerGetAccountDetailsDefinedOverrideAccountDetails() + { + + var account = new AccountDetails + { + AccountKey = "", + AccountURI = "", + Title = "Dev", + Email = "test@certifytheweb.com", + CertificateAuthorityId = _customCa.Id, + StorageKey = "dev", + IsStagingAccount = true, + }; + _certifyManager.OverrideAccountDetails = account; + + var dummyManagedCert = (new ManagedCertificate { UseStagingMode = true }); + var caAccount = await _certifyManager.GetAccountDetails(dummyManagedCert); + Assert.IsNotNull(caAccount, "Expected result of CertifyManager.GetAccountDetails() to not be null"); + Assert.AreEqual("test@certifytheweb.com", caAccount.Email); + + _certifyManager.OverrideAccountDetails = null; + } + + [TestMethod, Description("Test for using CertifyManager.GetAccountDetails() when there is no matching account")] + public async Task TestCertifyManagerGetAccountDetailsNoMatches() + { + var dummyManagedCert = (new ManagedCertificate { UseStagingMode = true, CertificateAuthorityId = "sectigo-ev" }); + var caAccount = await _certifyManager.GetAccountDetails(dummyManagedCert); + Assert.IsNull(caAccount, "Expected result of CertifyManager.GetAccountDetails() to be null"); + } + + [TestMethod, Description("Test for using CertifyManager.GetAccountDetails() when it is a resume order")] + public async Task TestCertifyManagerGetAccountDetailsIsResumeOrder() + { + var dummyManagedCert = (new ManagedCertificate { UseStagingMode = true, CertificateAuthorityId = "letsencrypt.org", LastAttemptedCA = "zerossl.com" }); + var caAccount = await _certifyManager.GetAccountDetails(dummyManagedCert, true, false, true); + Assert.IsNotNull(caAccount, "Expected result of CertifyManager.GetAccountDetails() to not be null"); + } + + [TestMethod, Description("Test for using CertifyManager.GetAccountDetails() when allowFailover is true")] + public async Task TestCertifyManagerGetAccountDetailsAllowFailover() + { + var dummyManagedCert = (new ManagedCertificate { UseStagingMode = true }); + var caAccount = await _certifyManager.GetAccountDetails(dummyManagedCert, true, true); + Assert.IsNotNull(caAccount, "Expected result of CertifyManager.GetAccountDetails() to not be null"); + } + + [TestMethod, Description("Happy path test for using CertifyManager.AddAccount()")] + public async Task TestCertifyManagerAddAccount() + { + AccountDetails accountDetails = null; + try + { + // Setup account registration info + var contactRegEmail = "admin." + Guid.NewGuid().ToString().Substring(0, 6) + "@test.com"; + var contactRegistration = new ContactRegistration + { + AgreedToTermsAndConditions = true, + CertificateAuthorityId = _customCa.Id, + EmailAddress = contactRegEmail, + ImportedAccountKey = "", + ImportedAccountURI = "", + IsStaging = true + }; + + // Add account + var addAccountRes = await _certifyManager.AddAccount(contactRegistration); + Assert.IsTrue(addAccountRes.IsSuccess, $"Expected account creation to be successful for {contactRegEmail}"); + accountDetails = (await _certifyManager.GetAccountRegistrations()).Find(a => a.Email == contactRegEmail); + Assert.IsNotNull(accountDetails, $"Expected one of the accounts returned by CertifyManager.GetAccountRegistrations() to be for {contactRegEmail}"); + } + finally + { + // Cleanup added account + if (accountDetails != null) + { + await _certifyManager.RemoveAccount(accountDetails.StorageKey, true); + } + } + } + + [TestMethod, Description("Happy path test for using CertifyManager.RemoveAccount()")] + public async Task TestCertifyManagerRemoveAccount() + { + // Setup account registration info + var contactRegEmail = "admin." + Guid.NewGuid().ToString().Substring(0, 6) + "@test.com"; + var contactRegistration = new ContactRegistration + { + AgreedToTermsAndConditions = true, + CertificateAuthorityId = _customCa.Id, + EmailAddress = contactRegEmail, + ImportedAccountKey = "", + ImportedAccountURI = "", + IsStaging = true + }; + + // Add account + var addAccountRes = await _certifyManager.AddAccount(contactRegistration); + Assert.IsTrue(addAccountRes.IsSuccess, $"Expected account creation to be successful for {contactRegEmail}"); + var accountDetails = (await _certifyManager.GetAccountRegistrations()).Find(a => a.Email == contactRegEmail); + Assert.IsNotNull(accountDetails, $"Expected one of the accounts returned by CertifyManager.GetAccountRegistrations() to be for {contactRegEmail}"); + + // Remove account + var removeAccountRes = await _certifyManager.RemoveAccount(accountDetails.StorageKey, true); + Assert.IsTrue(removeAccountRes.IsSuccess, $"Expected account removal to be successful for {contactRegEmail}"); + accountDetails = (await _certifyManager.GetAccountRegistrations()).Find(a => a.Email == contactRegEmail); + Assert.IsNull(accountDetails, $"Did not expect an account for {contactRegEmail} to be returned by CertifyManager.GetAccountRegistrations()"); + } + + [TestMethod, Description("Test for CertifyManager.AddAccount() when AgreedToTermsAndConditions is false")] + public async Task TestCertifyManagerAddAccountDidNotAgree() + { + // Setup account registration info + var contactRegEmail = "admin." + Guid.NewGuid().ToString().Substring(0, 6) + "@test.com"; + var contactRegistration = new ContactRegistration + { + AgreedToTermsAndConditions = false, + CertificateAuthorityId = _customCa.Id, + EmailAddress = contactRegEmail, + ImportedAccountKey = "", + ImportedAccountURI = "", + IsStaging = true + }; + + // Attempt to add account + var addAccountRes = await _certifyManager.AddAccount(contactRegistration); + Assert.IsFalse(addAccountRes.IsSuccess, $"Expected account creation to be unsuccessful for {contactRegEmail}"); + Assert.AreEqual(addAccountRes.Message, "You must agree to the terms and conditions of the Certificate Authority to register with them.", "Unexpected error message"); + var accountDetails = (await _certifyManager.GetAccountRegistrations()).Find(a => a.Email == contactRegEmail); + Assert.IsNull(accountDetails, $"Did not expect an account for {contactRegEmail} to be returned by CertifyManager.GetAccountRegistrations()"); + } + + [TestMethod, Description("Test for CertifyManager.AddAccount() when CertificateAuthorityId is a bad value")] + public async Task TestCertifyManagerAddAccountBadCaId() + { + // Setup account registration info + var contactRegEmail = "admin." + Guid.NewGuid().ToString().Substring(0, 6) + "@test.com"; + var contactRegistration = new ContactRegistration + { + AgreedToTermsAndConditions = true, + CertificateAuthorityId = "bad_ca.org", + EmailAddress = contactRegEmail, + ImportedAccountKey = "", + ImportedAccountURI = "", + IsStaging = true + }; + + // Attempt to add account + var addAccountRes = await _certifyManager.AddAccount(contactRegistration); + Assert.IsFalse(addAccountRes.IsSuccess, $"Expected account creation to be unsuccessful for {contactRegEmail}"); + Assert.AreEqual(addAccountRes.Message, "Invalid Certificate Authority specified.", "Unexpected error message"); + var accountDetails = (await _certifyManager.GetAccountRegistrations()).Find(a => a.Email == contactRegEmail); + Assert.IsNull(accountDetails, $"Did not expect an account for {contactRegEmail} to be returned by CertifyManager.GetAccountRegistrations()"); + } + + [TestMethod, Description("Test for CertifyManager.AddAccount() when ImportedAccountKey is a blank value")] + public async Task TestCertifyManagerAddAccountMissingAccountKey() + { + // Setup account registration info + var contactRegEmail = "admin." + Guid.NewGuid().ToString().Substring(0, 6) + "@test.com"; + var contactRegistration = new ContactRegistration + { + AgreedToTermsAndConditions = true, + CertificateAuthorityId = _customCa.Id, + EmailAddress = contactRegEmail, + ImportedAccountKey = "", + ImportedAccountURI = _customCaAccount.AccountURI, + IsStaging = true + }; + + // Attempt to add account + var addAccountRes = await _certifyManager.AddAccount(contactRegistration); + Assert.IsFalse(addAccountRes.IsSuccess, $"Expected account creation to be unsuccessful for {contactRegEmail}"); + Assert.AreEqual(addAccountRes.Message, "To import account details both the existing account URI and account key in PEM format are required. ", "Unexpected error message"); + var accountDetails = (await _certifyManager.GetAccountRegistrations()).Find(a => a.Email == contactRegEmail); + Assert.IsNull(accountDetails, $"Did not expect an account for {contactRegEmail} to be returned by CertifyManager.GetAccountRegistrations()"); + } + + [TestMethod, Description("Test for CertifyManager.AddAccount() when ImportedAccountURI is a blank value")] + public async Task TestCertifyManagerAddAccountMissingAccountUri() + { + // Setup account registration info + var contactRegEmail = "admin." + Guid.NewGuid().ToString().Substring(0, 6) + "@test.com"; + var contactRegistration = new ContactRegistration + { + AgreedToTermsAndConditions = true, + CertificateAuthorityId = _customCa.Id, + EmailAddress = contactRegEmail, + ImportedAccountKey = _customCaAccount.AccountKey, + ImportedAccountURI = "", + IsStaging = true + }; + + // Attempt to add account + var addAccountRes = await _certifyManager.AddAccount(contactRegistration); + Assert.IsFalse(addAccountRes.IsSuccess, $"Expected account creation to be unsuccessful for {contactRegEmail}"); + Assert.AreEqual(addAccountRes.Message, "To import account details both the existing account URI and account key in PEM format are required. ", "Unexpected error message"); + var accountDetails = (await _certifyManager.GetAccountRegistrations()).Find(a => a.Email == contactRegEmail); + Assert.IsNull(accountDetails, $"Did not expect an account for {contactRegEmail} to be returned by CertifyManager.GetAccountRegistrations()"); + } + + [TestMethod, Description("Test for CertifyManager.AddAccount() when ImportedAccountKey is a bad value")] + public async Task TestCertifyManagerAddAccountBadAccountKey() + { + // Setup account registration info + var contactRegEmail = "admin." + Guid.NewGuid().ToString().Substring(0, 6) + "@test.com"; + var contactRegistration = new ContactRegistration + { + AgreedToTermsAndConditions = true, + CertificateAuthorityId = _customCa.Id, + EmailAddress = contactRegEmail, + ImportedAccountKey = "tHiSiSnOtApEm", + ImportedAccountURI = _customCaAccount.AccountURI, + IsStaging = true + }; + + // Attempt to add account + var addAccountRes = await _certifyManager.AddAccount(contactRegistration); + Assert.IsFalse(addAccountRes.IsSuccess, $"Expected account creation to be unsuccessful for {contactRegEmail}"); + Assert.AreEqual(addAccountRes.Message, "The provided account key was invalid or not supported for import. A PEM (text) format RSA or ECDA private key is required.", "Unexpected error message"); + var accountDetails = (await _certifyManager.GetAccountRegistrations()).Find(a => a.Email == contactRegEmail); + Assert.IsNull(accountDetails, $"Did not expect an account for {contactRegEmail} to be returned by CertifyManager.GetAccountRegistrations()"); + } + + [TestMethod, Description("Test for CertifyManager.AddAccount() when ImportedAccountKey and ImportedAccountURI are valid")] + public async Task TestCertifyManagerAddAccountImport() + { + // Remove account + var removeAccountRes = await _certifyManager.RemoveAccount(_customCaAccount.StorageKey); + Assert.IsTrue(removeAccountRes.IsSuccess, $"Expected account removal to be successful for {_customCaAccount.Email}"); + var accountDetails = (await _certifyManager.GetAccountRegistrations()).Find(a => a.Email == _customCaAccount.Email); + Assert.IsNull(accountDetails, $"Did not expect an account for {_customCaAccount.Email} to be returned by CertifyManager.GetAccountRegistrations()"); + + // Setup account registration info + var contactRegistration = new ContactRegistration + { + AgreedToTermsAndConditions = true, + CertificateAuthorityId = _customCa.Id, + EmailAddress = _customCaAccount.Email, + ImportedAccountKey = _customCaAccount.AccountKey, + ImportedAccountURI = _customCaAccount.AccountURI, + IsStaging = true + }; + + // Add account + var addAccountRes = await _certifyManager.AddAccount(contactRegistration); + Assert.IsTrue(addAccountRes.IsSuccess, $"Expected account creation to be successful for {_customCaAccount.Email}"); + accountDetails = (await _certifyManager.GetAccountRegistrations()).Find(a => a.Email == _customCaAccount.Email); + Assert.IsNotNull(accountDetails, $"Expected one of the accounts returned by CertifyManager.GetAccountRegistrations() to be for {_customCaAccount.Email}"); + } + + [TestMethod, Description("Test for using CertifyManager.RemoveAccount() with a bad storage key")] + public async Task TestCertifyManagerRemoveAccountBadKey() + { + // Attempt to remove account with bad storage key + var badStorageKey = "8da1a662-18ed-4787-a0b1-dc36db5a866b"; + var removeAccountRes = await _certifyManager.RemoveAccount(badStorageKey, true); + Assert.IsFalse(removeAccountRes.IsSuccess, $"Expected account removal to be unsuccessful for storage key {badStorageKey}"); + Assert.AreEqual(removeAccountRes.Message, "Account not found.", "Unexpected error message"); + } + + [TestMethod, Description("Happy path test for using CertifyManager.GetAccountAndACMEProvider()")] + public async Task TestCertifyManagerGetAccountAndAcmeProvider() + { + AccountDetails accountDetails = null; + try + { + // Setup account registration info + var contactRegEmail = "admin." + Guid.NewGuid().ToString().Substring(0, 6) + "@test.com"; + var contactRegistration = new ContactRegistration + { + AgreedToTermsAndConditions = true, + CertificateAuthorityId = _customCa.Id, + EmailAddress = contactRegEmail, + ImportedAccountKey = "", + ImportedAccountURI = "", + IsStaging = true + }; + + // Add account + var addAccountRes = await _certifyManager.AddAccount(contactRegistration); + Assert.IsTrue(addAccountRes.IsSuccess, $"Expected account creation to be successful for {contactRegEmail}"); + accountDetails = (await _certifyManager.GetAccountRegistrations()).Find(a => a.Email == contactRegEmail); + Assert.IsNotNull(accountDetails, $"Expected one of the accounts returned by CertifyManager.GetAccountRegistrations() to be for {contactRegEmail}"); + + var (account, certAuthority, acmeProvider) = await _certifyManager.GetAccountAndACMEProvider(accountDetails.StorageKey); + Assert.IsNotNull(account, $"Expected account returned by GetAccountAndACMEProvider() to not be null for storage key {accountDetails.StorageKey}"); + Assert.IsNotNull(certAuthority, $"Expected certAuthority returned by GetAccountAndACMEProvider() to not be null for storage key {accountDetails.StorageKey}"); + Assert.IsNotNull(acmeProvider, $"Expected acmeProvider returned by GetAccountAndACMEProvider() to not be null for storage key {accountDetails.StorageKey}"); + } + finally + { + // Cleanup added account + if (accountDetails != null) + { + await _certifyManager.RemoveAccount(accountDetails.StorageKey, true); + } + } + } + + [TestMethod, Description("Test for using CertifyManager.GetAccountAndACMEProvider() with a bad storage key")] + public async Task TestCertifyManagerGetAccountAndAcmeProviderBadKey() + { + // Attempt to retrieve account with bad storage key + var badStorageKey = "8da1a662-18ed-4787-a0b1-dc36db5a866b"; + var (account, certAuthority, acmeProvider) = await _certifyManager.GetAccountAndACMEProvider(badStorageKey); + Assert.IsNull(account, $"Expected account returned by GetAccountAndACMEProvider() to be null for storage key {badStorageKey}"); + Assert.IsNull(certAuthority, $"Expected certAuthority returned by GetAccountAndACMEProvider() to be null for storage key {badStorageKey}"); + Assert.IsNull(acmeProvider, $"Expected acmeProvider returned by GetAccountAndACMEProvider() to be null for storage key {badStorageKey}"); + } + + [TestMethod, Description("Happy path test for using CertifyManager.UpdateAccountContact()")] + public async Task TestCertifyManagerUpdateAccountContact() + { + // Setup account registration info + var contactRegEmail = "admin." + Guid.NewGuid().ToString().Substring(0, 6) + "@test.com"; + var contactRegistration = new ContactRegistration + { + AgreedToTermsAndConditions = true, + CertificateAuthorityId = _customCa.Id, + EmailAddress = contactRegEmail, + ImportedAccountKey = "", + ImportedAccountURI = "", + IsStaging = true + }; + + // Add account + var addAccountRes = await _certifyManager.AddAccount(contactRegistration); + Assert.IsTrue(addAccountRes.IsSuccess, $"Expected account creation to be successful for {contactRegEmail}"); + var accountDetails = (await _certifyManager.GetAccountRegistrations()).Find(a => a.Email == contactRegEmail); + Assert.IsNotNull(accountDetails, $"Expected one of the accounts returned by CertifyManager.GetAccountRegistrations() to be for {contactRegEmail}"); + + // Update account + var newContactRegEmail = "admin." + Guid.NewGuid().ToString().Substring(0, 6) + "@test.com"; + var newContactRegistration = new ContactRegistration + { + AgreedToTermsAndConditions = true, + CertificateAuthorityId = _customCa.Id, + EmailAddress = newContactRegEmail, + ImportedAccountKey = "", + ImportedAccountURI = "", + IsStaging = true + }; + var updateAccountRes = await _certifyManager.UpdateAccountContact(accountDetails.StorageKey, newContactRegistration); + Assert.IsTrue(updateAccountRes.IsSuccess, $"Expected account creation to be successful for {newContactRegEmail}"); + accountDetails = (await _certifyManager.GetAccountRegistrations()).Find(a => a.Email == newContactRegEmail); + Assert.IsNotNull(accountDetails, $"Expected one of the accounts returned by CertifyManager.GetAccountRegistrations() to be for {newContactRegEmail}"); + + // Cleanup account + await _certifyManager.RemoveAccount(accountDetails.StorageKey, true); + } + + [TestMethod, Description("Test for using CertifyManager.UpdateAccountContact() when AgreedToTermsAndConditions is false")] + public async Task TestCertifyManagerUpdateAccountContactNoAgreement() + { + // Setup account registration info + var contactRegEmail = "admin." + Guid.NewGuid().ToString().Substring(0, 6) + "@test.com"; + var contactRegistration = new ContactRegistration + { + AgreedToTermsAndConditions = true, + CertificateAuthorityId = _customCa.Id, + EmailAddress = contactRegEmail, + ImportedAccountKey = "", + ImportedAccountURI = "", + IsStaging = true + }; + + // Add account + var addAccountRes = await _certifyManager.AddAccount(contactRegistration); + Assert.IsTrue(addAccountRes.IsSuccess, $"Expected account creation to be successful for {contactRegEmail}"); + var accountDetails = (await _certifyManager.GetAccountRegistrations()).Find(a => a.Email == contactRegEmail); + Assert.IsNotNull(accountDetails, $"Expected one of the accounts returned by CertifyManager.GetAccountRegistrations() to be for {contactRegEmail}"); + + // Update account + var newContactRegEmail = "admin." + Guid.NewGuid().ToString().Substring(0, 6) + "@test.com"; + var newContactRegistration = new ContactRegistration + { + AgreedToTermsAndConditions = false, + CertificateAuthorityId = _customCa.Id, + EmailAddress = newContactRegEmail, + ImportedAccountKey = "", + ImportedAccountURI = "", + IsStaging = true + }; + var updateAccountRes = await _certifyManager.UpdateAccountContact(accountDetails.StorageKey, newContactRegistration); + Assert.IsFalse(updateAccountRes.IsSuccess, $"Expected account creation to not be successful for {newContactRegEmail}"); + Assert.AreEqual(updateAccountRes.Message, "You must agree to the terms and conditions of the Certificate Authority to register with them.", "Unexpected error message"); + var newAccountDetails = (await _certifyManager.GetAccountRegistrations()).Find(a => a.Email == newContactRegEmail); + Assert.IsNull(newAccountDetails, $"Expected none of the accounts returned by CertifyManager.GetAccountRegistrations() to be for {newContactRegEmail}"); + accountDetails = (await _certifyManager.GetAccountRegistrations()).Find(a => a.Email == contactRegEmail); + Assert.IsNotNull(accountDetails, $"Expected one of the accounts returned by CertifyManager.GetAccountRegistrations() to be for {contactRegEmail}"); + + // Cleanup account + await _certifyManager.RemoveAccount(accountDetails.StorageKey, true); + } + + [TestMethod, Description("Test for using CertifyManager.UpdateAccountContact() when passed storage key doesn't exist")] + public async Task TestCertifyManagerUpdateAccountContactBadKey() + { + // Setup account registration info + var contactRegEmail = "admin." + Guid.NewGuid().ToString().Substring(0, 6) + "@test.com"; + var contactRegistration = new ContactRegistration + { + AgreedToTermsAndConditions = true, + CertificateAuthorityId = _customCa.Id, + EmailAddress = contactRegEmail, + ImportedAccountKey = "", + ImportedAccountURI = "", + IsStaging = true + }; + + // Add account + var addAccountRes = await _certifyManager.AddAccount(contactRegistration); + Assert.IsTrue(addAccountRes.IsSuccess, $"Expected account creation to be successful for {contactRegEmail}"); + var accountDetails = (await _certifyManager.GetAccountRegistrations()).Find(a => a.Email == contactRegEmail); + Assert.IsNotNull(accountDetails, $"Expected one of the accounts returned by CertifyManager.GetAccountRegistrations() to be for {contactRegEmail}"); + + // Update account + var newContactRegEmail = "admin." + Guid.NewGuid().ToString().Substring(0, 6) + "@test.com"; + var newContactRegistration = new ContactRegistration + { + AgreedToTermsAndConditions = true, + CertificateAuthorityId = _customCa.Id, + EmailAddress = newContactRegEmail, + ImportedAccountKey = "", + ImportedAccountURI = "", + IsStaging = true + }; + var badStorageKey = Guid.NewGuid().ToString(); + var updateAccountRes = await _certifyManager.UpdateAccountContact(badStorageKey, newContactRegistration); + Assert.IsFalse(updateAccountRes.IsSuccess, $"Expected account creation to not be successful for {newContactRegEmail}"); + Assert.AreEqual(updateAccountRes.Message, "Account not found.", "Unexpected error message"); + var newAccountDetails = (await _certifyManager.GetAccountRegistrations()).Find(a => a.Email == newContactRegEmail); + Assert.IsNull(newAccountDetails, $"Expected none of the accounts returned by CertifyManager.GetAccountRegistrations() to be for {newContactRegEmail}"); + accountDetails = (await _certifyManager.GetAccountRegistrations()).Find(a => a.Email == contactRegEmail); + Assert.IsNotNull(accountDetails, $"Expected one of the accounts returned by CertifyManager.GetAccountRegistrations() to be for {contactRegEmail}"); + + // Cleanup account + await _certifyManager.RemoveAccount(accountDetails.StorageKey, true); + } + + [TestMethod, Description("Happy path test for using CertifyManager.ChangeAccountKey()")] + public async Task TestCertifyManagerChangeAccountKey() + { + // Setup account registration info + var contactRegEmail = "admin." + Guid.NewGuid().ToString().Substring(0, 6) + "@test.com"; + var contactRegistration = new ContactRegistration + { + AgreedToTermsAndConditions = true, + CertificateAuthorityId = "letsencrypt.org", + EmailAddress = contactRegEmail, + ImportedAccountKey = "", + ImportedAccountURI = "", + IsStaging = true + }; + + // Add account + var addAccountRes = await _certifyManager.AddAccount(contactRegistration); + Assert.IsTrue(addAccountRes.IsSuccess, $"Expected account creation to be successful for {contactRegEmail}"); + var accountDetails = (await _certifyManager.GetAccountRegistrations()).Find(a => a.Email == contactRegEmail); + Assert.IsNotNull(accountDetails, $"Expected one of the accounts returned by CertifyManager.GetAccountRegistrations() to be for {contactRegEmail}"); + var firstAccountKey = accountDetails.AccountKey; + + // Update account key + var newKeyPem = KeyFactory.NewKey(KeyAlgorithm.ES256).ToPem(); + var changeAccountKeyRes = await _certifyManager.ChangeAccountKey(accountDetails.StorageKey, newKeyPem); + Assert.IsTrue(changeAccountKeyRes.IsSuccess, $"Expected account creation to be successful for {contactRegEmail}"); + Assert.AreEqual(changeAccountKeyRes.Message, "Completed account key rollover", "Unexpected message for CertifyManager.GetAccountRegistrations() success"); + accountDetails = (await _certifyManager.GetAccountRegistrations()).Find(a => a.Email == contactRegEmail); + Assert.IsNotNull(accountDetails, $"Expected one of the accounts returned by CertifyManager.GetAccountRegistrations() to be for {contactRegEmail}"); + Assert.AreNotEqual(firstAccountKey, accountDetails.AccountKey, $"Expected account key for {contactRegEmail} to have changed after successful CertifyManager.ChangeAccountKey()"); + + // Cleanup account + await _certifyManager.RemoveAccount(accountDetails.StorageKey, true); + } + + [TestMethod, Description("Happy path test for using CertifyManager.ChangeAccountKey() with no passed in new account key")] + public async Task TestCertifyManagerChangeAccountKeyNull() + { + // Setup account registration info + var contactRegEmail = "admin." + Guid.NewGuid().ToString().Substring(0, 6) + "@test.com"; + var contactRegistration = new ContactRegistration + { + AgreedToTermsAndConditions = true, + CertificateAuthorityId = "letsencrypt.org", + EmailAddress = contactRegEmail, + ImportedAccountKey = "", + ImportedAccountURI = "", + IsStaging = true + }; + + // Add account + var addAccountRes = await _certifyManager.AddAccount(contactRegistration); + Assert.IsTrue(addAccountRes.IsSuccess, $"Expected account creation to be successful for {contactRegEmail}"); + var accountDetails = (await _certifyManager.GetAccountRegistrations()).Find(a => a.Email == contactRegEmail); + Assert.IsNotNull(accountDetails, $"Expected one of the accounts returned by CertifyManager.GetAccountRegistrations() to be for {contactRegEmail}"); + var firstAccountKey = accountDetails.AccountKey; + + // Update account key + var changeAccountKeyRes = await _certifyManager.ChangeAccountKey(accountDetails.StorageKey); + Assert.IsTrue(changeAccountKeyRes.IsSuccess, $"Expected account creation to be successful for {contactRegEmail}"); + Assert.AreEqual(changeAccountKeyRes.Message, "Completed account key rollover", "Unexpected message for CertifyManager.GetAccountRegistrations() success"); + accountDetails = (await _certifyManager.GetAccountRegistrations()).Find(a => a.Email == contactRegEmail); + Assert.IsNotNull(accountDetails, $"Expected one of the accounts returned by CertifyManager.GetAccountRegistrations() to be for {contactRegEmail}"); + Assert.AreNotEqual(firstAccountKey, accountDetails.AccountKey, $"Expected account key for {contactRegEmail} to have changed after successful CertifyManager.ChangeAccountKey()"); + + // Cleanup account + await _certifyManager.RemoveAccount(accountDetails.StorageKey, true); + } + + [TestMethod, Description("Test for using CertifyManager.ChangeAccountKey() when passed an invalid storage key")] + public async Task TestCertifyManagerChangeAccountKeyBadStorageKey() + { + // Setup account registration info + var contactRegEmail = "admin." + Guid.NewGuid().ToString().Substring(0, 6) + "@test.com"; + var contactRegistration = new ContactRegistration + { + AgreedToTermsAndConditions = true, + CertificateAuthorityId = _customCa.Id, + EmailAddress = contactRegEmail, + ImportedAccountKey = "", + ImportedAccountURI = "", + IsStaging = true + }; + + // Add account + var addAccountRes = await _certifyManager.AddAccount(contactRegistration); + Assert.IsTrue(addAccountRes.IsSuccess, $"Expected account key update to be successful for {contactRegEmail}"); + var accountDetails = (await _certifyManager.GetAccountRegistrations()).Find(a => a.Email == contactRegEmail); + Assert.IsNotNull(accountDetails, $"Expected one of the accounts returned by CertifyManager.GetAccountRegistrations() to be for {contactRegEmail}"); + var firstAccountKey = accountDetails.AccountKey; + + // Attempt to update account key + var newKeyPem = KeyFactory.NewKey(KeyAlgorithm.ES256).ToPem(); + var badStorageKey = Guid.NewGuid().ToString(); + var changeAccountKeyRes = await _certifyManager.ChangeAccountKey(badStorageKey, newKeyPem); + Assert.IsFalse(changeAccountKeyRes.IsSuccess, $"Expected account key update to be unsuccessful for {contactRegEmail}"); + Assert.AreEqual(changeAccountKeyRes.Message, "Failed to match account to known ACME provider", "Unexpected error message for CertifyManager.GetAccountRegistrations() failure"); + accountDetails = (await _certifyManager.GetAccountRegistrations()).Find(a => a.Email == contactRegEmail); + Assert.IsNotNull(accountDetails, $"Expected one of the accounts returned by CertifyManager.GetAccountRegistrations() to be for {contactRegEmail}"); + Assert.AreEqual(firstAccountKey, accountDetails.AccountKey, $"Expected account key for {contactRegEmail} not to have changed after unsuccessful CertifyManager.ChangeAccountKey()"); + + // Cleanup account + await _certifyManager.RemoveAccount(accountDetails.StorageKey, true); + } + + [TestMethod, Description("Test for using CertifyManager.ChangeAccountKey() when passed an invalid new account key")] + public async Task TestCertifyManagerChangeAccountKeyBadAccountKey() + { + // Setup account registration info + var contactRegEmail = "admin." + Guid.NewGuid().ToString().Substring(0, 6) + "@test.com"; + var contactRegistration = new ContactRegistration + { + AgreedToTermsAndConditions = true, + CertificateAuthorityId = _customCa.Id, + EmailAddress = contactRegEmail, + ImportedAccountKey = "", + ImportedAccountURI = "", + IsStaging = true + }; + + // Add account + var addAccountRes = await _certifyManager.AddAccount(contactRegistration); + Assert.IsTrue(addAccountRes.IsSuccess, $"Expected account key update to be successful for {contactRegEmail}"); + var accountDetails = (await _certifyManager.GetAccountRegistrations()).Find(a => a.Email == contactRegEmail); + Assert.IsNotNull(accountDetails, $"Expected one of the accounts returned by CertifyManager.GetAccountRegistrations() to be for {contactRegEmail}"); + var firstAccountKey = accountDetails.AccountKey; + + // Attempt to update account key + var badKeyPem = KeyFactory.NewKey(KeyAlgorithm.ES256).ToPem().Substring(20); + var changeAccountKeyRes = await _certifyManager.ChangeAccountKey(accountDetails.StorageKey, badKeyPem); + Assert.IsFalse(changeAccountKeyRes.IsSuccess, $"Expected account key update to be unsuccessful for {contactRegEmail}"); + Assert.AreEqual(changeAccountKeyRes.Message, "Failed to use provide key for account rollover", "Unexpected error message for CertifyManager.GetAccountRegistrations() failure"); + accountDetails = (await _certifyManager.GetAccountRegistrations()).Find(a => a.Email == contactRegEmail); + Assert.IsNotNull(accountDetails, $"Expected one of the accounts returned by CertifyManager.GetAccountRegistrations() to be for {contactRegEmail}"); + Assert.AreEqual(firstAccountKey, accountDetails.AccountKey, $"Expected account key for {contactRegEmail} not to have changed after unsuccessful CertifyManager.ChangeAccountKey()"); + + // Cleanup account + await _certifyManager.RemoveAccount(accountDetails.StorageKey, true); + } + + [TestMethod, Description("Happy path test for using CertifyManager.UpdateCertificateAuthority() to add a new custom CA")] + public async Task TestCertifyManagerUpdateCertificateAuthorityAdd() + { + CertificateAuthority newCustomCa = null; + try + { + newCustomCa = new CertificateAuthority + { + Id = Guid.NewGuid().ToString(), + Title = "Test Custom CA", + IsCustom = true, + IsEnabled = true, + SupportedFeatures = new List + { + CertAuthoritySupportedRequests.DOMAIN_SINGLE.ToString(), + } + }; + var updateCaRes = await _certifyManager.UpdateCertificateAuthority(newCustomCa); + Assert.IsTrue(updateCaRes.IsSuccess, $"Expected Custom CA creation for CA with ID {newCustomCa.Id} to be successful"); + Assert.AreEqual(updateCaRes.Message, "OK", "Unexpected result message for CertifyManager.UpdateCertificateAuthority() success"); + var certificateAuthorities = await _certifyManager.GetCertificateAuthorities(); + var newCaDetails = certificateAuthorities.Find(c => c.Id == newCustomCa.Id); + Assert.IsNotNull(newCaDetails, $"Expected one of the CAs returned by CertifyManager.GetCertificateAuthorities() to have an ID of {newCustomCa.Id}"); + } + finally + { + if (newCustomCa != null) + { + await _certifyManager.RemoveCertificateAuthority(newCustomCa.Id); + } + } + } + + [TestMethod, Description("Happy path test for using CertifyManager.UpdateCertificateAuthority() to update an existing custom CA")] + public async Task TestCertifyManagerUpdateCertificateAuthorityUpdate() + { + CertificateAuthority newCustomCa = null; + try + { + newCustomCa = new CertificateAuthority + { + Id = Guid.NewGuid().ToString(), + Title = "Test Custom CA", + IsCustom = true, + IsEnabled = true, + AllowInternalHostnames = false, + SupportedFeatures = new List + { + CertAuthoritySupportedRequests.DOMAIN_SINGLE.ToString(), + } + }; + + // Add new CA + var addCaRes = await _certifyManager.UpdateCertificateAuthority(newCustomCa); + Assert.IsTrue(addCaRes.IsSuccess, $"Expected Custom CA creation for CA with ID {newCustomCa.Id} to be successful"); + Assert.AreEqual(addCaRes.Message, "OK", "Unexpected result message for CertifyManager.UpdateCertificateAuthority() success"); + var certificateAuthorities = await _certifyManager.GetCertificateAuthorities(); + var newCaDetails = certificateAuthorities.Find(c => c.Id == newCustomCa.Id); + Assert.IsNotNull(newCaDetails, $"Expected one of the CAs returned by CertifyManager.GetCertificateAuthorities() to have an ID of {newCustomCa.Id}"); + Assert.IsFalse(newCaDetails.AllowInternalHostnames); + + var updatedCustomCa = new CertificateAuthority + { + Id = newCustomCa.Id, + Title = "Test Custom CA", + IsCustom = true, + IsEnabled = true, + AllowInternalHostnames = true, + SupportedFeatures = new List + { + CertAuthoritySupportedRequests.DOMAIN_SINGLE.ToString(), + } + }; + + // Update existing CA + var updateCaRes = await _certifyManager.UpdateCertificateAuthority(updatedCustomCa); + Assert.IsTrue(updateCaRes.IsSuccess, $"Expected Custom CA update for CA with ID {updatedCustomCa.Id} to be successful"); + Assert.AreEqual(updateCaRes.Message, "OK", "Unexpected result message for CertifyManager.UpdateCertificateAuthority() success"); + certificateAuthorities = await _certifyManager.GetCertificateAuthorities(); + newCaDetails = certificateAuthorities.Find(c => c.Id == updatedCustomCa.Id); + Assert.IsNotNull(newCaDetails, $"Expected one of the CAs returned by CertifyManager.GetCertificateAuthorities() to have an ID of {updatedCustomCa.Id}"); + Assert.IsTrue(newCaDetails.AllowInternalHostnames); + } + finally + { + if (newCustomCa != null) + { + await _certifyManager.RemoveCertificateAuthority(newCustomCa.Id); + } + } + } + + [TestMethod, Description("Test for using CertifyManager.UpdateCertificateAuthority() on a default CA")] + public async Task TestCertifyManagerUpdateCertificateAuthorityDefaultCa() + { + var certificateAuthorities = await _certifyManager.GetCertificateAuthorities(); + var defaultCa = certificateAuthorities.First(); + var newCustomCa = new CertificateAuthority + { + Id = defaultCa.Id, + Title = "Test Custom CA", + IsCustom = true, + IsEnabled = true, + AllowInternalHostnames = false, + SupportedFeatures = new List + { + CertAuthoritySupportedRequests.DOMAIN_SINGLE.ToString(), + } + }; + + // Attempt to update default CA + var updateCaRes = await _certifyManager.UpdateCertificateAuthority(newCustomCa); + Assert.IsFalse(updateCaRes.IsSuccess, $"Expected CA update for default CA with ID {defaultCa.Id} to be unsuccessful"); + Assert.AreEqual(updateCaRes.Message, "Default Certificate Authorities cannot be modified.", "Unexpected result message for CertifyManager.UpdateCertificateAuthority() failure"); + } + + [TestMethod, Description("Happy path test for using CertifyManager.RemoveCertificateAuthority()")] + public async Task TestCertifyManagerRemoveCertificateAuthority() + { + var newCustomCa = new CertificateAuthority + { + Id = Guid.NewGuid().ToString(), + Title = "Test Custom CA", + IsCustom = true, + IsEnabled = true, + SupportedFeatures = new List + { + CertAuthoritySupportedRequests.DOMAIN_SINGLE.ToString(), + } + }; + + // Add custom CA + var updateCaRes = await _certifyManager.UpdateCertificateAuthority(newCustomCa); + Assert.IsTrue(updateCaRes.IsSuccess, $"Expected Custom CA creation for CA with ID {newCustomCa.Id} to be successful"); + Assert.AreEqual(updateCaRes.Message, "OK", "Unexpected result message for CertifyManager.UpdateCertificateAuthority() success"); + var certificateAuthorities = await _certifyManager.GetCertificateAuthorities(); + var newCaDetails = certificateAuthorities.Find(c => c.Id == newCustomCa.Id); + Assert.IsNotNull(newCaDetails, $"Expected one of the CAs returned by CertifyManager.GetCertificateAuthorities() to have an ID of {newCustomCa.Id}"); + + // Delete custom CA + var deleteCaRes = await _certifyManager.RemoveCertificateAuthority(newCustomCa.Id); + Assert.IsTrue(deleteCaRes.IsSuccess, $"Expected Custom CA deletion for CA with ID {newCustomCa.Id} to be successful"); + Assert.AreEqual(deleteCaRes.Message, "OK", "Unexpected result message for CertifyManager.RemoveCertificateAuthority() success"); + certificateAuthorities = await _certifyManager.GetCertificateAuthorities(); + newCaDetails = certificateAuthorities.Find(c => c.Id == newCustomCa.Id); + Assert.IsNull(newCaDetails, $"Expected none of the CAs returned by CertifyManager.GetCertificateAuthorities() to have an ID of {newCustomCa.Id}"); + } + + [TestMethod, Description("Test for using CertifyManager.RemoveCertificateAuthority() when passed a bad custom CA ID")] + public async Task TestCertifyManagerRemoveCertificateAuthorityBadId() + { + var badId = Guid.NewGuid().ToString(); + + // Delete custom CA + var deleteCaRes = await _certifyManager.RemoveCertificateAuthority(badId); + Assert.IsFalse(deleteCaRes.IsSuccess, $"Expected Custom CA deletion for CA with ID {badId} to be unsuccessful"); + Assert.AreEqual(deleteCaRes.Message, $"The certificate authority {badId} was not found in the list of custom CAs and could not be removed.", "Unexpected result message for CertifyManager.RemoveCertificateAuthority() failure"); + } + } +} diff --git a/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/CertifyServiceTests.cs b/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/CertifyServiceTests.cs new file mode 100644 index 000000000..d830affa0 --- /dev/null +++ b/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/CertifyServiceTests.cs @@ -0,0 +1,430 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Certify.Models; +using Certify.Models.Config; +using Certify.Shared; +using Medallion.Shell; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Newtonsoft.Json; + +namespace Certify.Core.Tests.Unit +{ +#if NET462 + [TestClass] + public class CertifyServiceTests + { + private HttpClient _httpClient; + private string serviceUri; + + public CertifyServiceTests() + { + var serviceConfig = SharedUtils.ServiceConfigManager.GetAppServiceConfig(); + serviceUri = $"{(serviceConfig.UseHTTPS ? "https" : "http")}://{serviceConfig.Host}:{serviceConfig.Port}"; + var httpHandler = new HttpClientHandler { UseDefaultCredentials = true }; + _httpClient = new HttpClient(httpHandler); + _httpClient.DefaultRequestHeaders.Add("User-Agent", "Certify/App"); + _httpClient.BaseAddress = new Uri(serviceUri + "/api/"); + } + + private async Task StartCertifyService(string args = "") + { + Command certifyService; + if (args == "") + { + certifyService = Command.Run(".\\Certify.Service.exe"); + await Task.Delay(2000); + } + else + { + certifyService = Command.Run(".\\Certify.Service.exe", args); + } + + return certifyService; + } + + private async Task StopCertifyService(Command certifyService) + { + await certifyService.TrySignalAsync(CommandSignal.ControlC); + + var cmdResult = await certifyService.Task; + + Assert.AreEqual(cmdResult.ExitCode, 0, "Unexpected exit code"); + + return cmdResult; + } + + [TestMethod, Description("Validate that Certify.Service.exe does not start with args from CLI")] + public async Task TestProgramMainFailsWithArgsCli() + { + var certifyService = await StartCertifyService("args"); + + var cmdResult = await certifyService.Task; + + Assert.IsTrue(cmdResult.StandardOutput.Contains("Topshelf.HostFactory Error: 0 : An exception occurred creating the host, Topshelf.HostConfigurationException: The service was not properly configured:")); + Assert.IsTrue(cmdResult.StandardOutput.Contains("Topshelf.HostFactory Error: 0 : The service terminated abnormally, Topshelf.HostConfigurationException: The service was not properly configured:")); + + Assert.AreEqual(cmdResult.ExitCode, 1067, "Unexpected exit code"); + } + + [TestMethod, Description("Validate that Certify.Service.exe starts from CLI with no args")] + public async Task TestProgramMainStartsCli() + { + var certifyService = await StartCertifyService(); + + var cmdResult = await StopCertifyService(certifyService); + + Assert.IsTrue(cmdResult.StandardOutput.Contains("[Success] Name Certify.Service")); + Assert.IsTrue(cmdResult.StandardOutput.Contains("[Success] DisplayName Certify Certificate Manager Service (Instance: Debug)")); + Assert.IsTrue(cmdResult.StandardOutput.Contains("[Success] Description Certify Certificate Manager Service")); + Assert.IsTrue(cmdResult.StandardOutput.Contains("[Success] InstanceName Debug")); + Assert.IsTrue(cmdResult.StandardOutput.Contains("[Success] ServiceName Certify.Service$Debug")); + Assert.IsTrue(cmdResult.StandardOutput.Contains("The Certify.Service$Debug service is now running, press Control+C to exit.")); + Assert.IsTrue(cmdResult.StandardOutput.Contains("Control+C detected, attempting to stop service.")); + Assert.IsTrue(cmdResult.StandardOutput.Contains("The Certify.Service$Debug service has stopped.")); + } + + [TestMethod, Description("Validate that Certify.Service.exe returns a valid response on route GET /api/system/appversion")] + public async Task TestCertifyServiceAppVersionRoute() + { + var certifyService = await StartCertifyService(); + + try + { + var versionRawRes = await _httpClient.GetAsync("system/appversion"); + var versionResStr = await versionRawRes.Content.ReadAsStringAsync(); + var versionRes = JsonConvert.DeserializeObject(versionResStr); + + Assert.AreEqual(HttpStatusCode.OK, versionRawRes.StatusCode, $"Unexpected status code from GET {versionRawRes.RequestMessage.RequestUri.AbsoluteUri}"); + StringAssert.Matches(versionRes, new Regex(@"^(\d+\.)?(\d+\.)?(\d+\.)?(\*|\d+)$"), $"Unexpected response from GET {versionRawRes.RequestMessage.RequestUri.AbsoluteUri} : {versionResStr}"); + } + finally + { + await StopCertifyService(certifyService); + } + } + + [TestMethod, Description("Validate that Certify.Service.exe returns a valid respose on route GET /api/system/updatecheck")] + public async Task TestCertifyServiceUpdateCheckRoute() + { + var certifyService = await StartCertifyService(); + + try + { + var updatesRawRes = await _httpClient.GetAsync("system/updatecheck"); + var updateRawResStr = await updatesRawRes.Content.ReadAsStringAsync(); + var updateRes = JsonConvert.DeserializeObject(updateRawResStr); + + Assert.AreEqual(HttpStatusCode.OK, updatesRawRes.StatusCode, $"Unexpected status code from GET {updatesRawRes.RequestMessage.RequestUri.AbsoluteUri}"); + Assert.IsFalse(updateRes.MustUpdate); + Assert.IsFalse(updateRes.IsNewerVersion); + Assert.AreEqual("", updateRes.UpdateFilePath); + } + finally + { + await StopCertifyService(certifyService); + } + } + + [TestMethod, Description("Validate that Certify.Service.exe returns a valid response on route GET /api/system/diagnostics")] + public async Task TestCertifyServiceDiagnosticsRoute() + { + var certifyService = await StartCertifyService(); + + try + { + var diagnosticsRawRes = await _httpClient.GetAsync("system/diagnostics"); + var diagnosticsRawResStr = await diagnosticsRawRes.Content.ReadAsStringAsync(); + var diagnosticsRes = JsonConvert.DeserializeObject>(diagnosticsRawResStr); + + Assert.AreEqual(HttpStatusCode.OK, diagnosticsRawRes.StatusCode, $"Unexpected status code from GET {diagnosticsRawRes.RequestMessage.RequestUri.AbsoluteUri}"); + Assert.AreEqual(4, diagnosticsRes.Count); + + Assert.AreEqual("Created test temp file OK.", diagnosticsRes[0].Message); + Assert.IsTrue(diagnosticsRes[0].IsSuccess); + Assert.IsFalse(diagnosticsRes[0].IsWarning); + Assert.AreEqual(null, diagnosticsRes[0].Result); + + Assert.AreEqual($"Drive {Environment.GetEnvironmentVariable("SystemDrive")} has more than 512MB of disk space free.", diagnosticsRes[1].Message); + Assert.IsTrue(diagnosticsRes[1].IsSuccess); + Assert.IsFalse(diagnosticsRes[1].IsWarning); + Assert.AreEqual(null, diagnosticsRes[1].Result); + + Assert.AreEqual("System time is correct.", diagnosticsRes[2].Message); + Assert.IsTrue(diagnosticsRes[2].IsSuccess); + Assert.IsFalse(diagnosticsRes[2].IsWarning); + Assert.AreEqual(null, diagnosticsRes[2].Result); + + Assert.AreEqual("PowerShell 5.0 or higher is available.", diagnosticsRes[3].Message); + Assert.IsTrue(diagnosticsRes[3].IsSuccess); + Assert.IsFalse(diagnosticsRes[3].IsWarning); + Assert.AreEqual(null, diagnosticsRes[3].Result); + } + finally + { + await StopCertifyService(certifyService); + } + } + + [TestMethod, Description("Validate that Certify.Service.exe returns a valid response on route GET /api/system/datastores/providers")] + public async Task TestCertifyServiceDatastoreProvidersRoute() + { + var certifyService = await StartCertifyService(); + + try + { + var datastoreProvidersRawRes = await _httpClient.GetAsync("system/datastores/providers"); + var datastoreProvidersRawResStr = await datastoreProvidersRawRes.Content.ReadAsStringAsync(); + var datastoreProvidersRes = JsonConvert.DeserializeObject>(datastoreProvidersRawResStr); + + Assert.AreEqual(HttpStatusCode.OK, datastoreProvidersRawRes.StatusCode, $"Unexpected status code from GET {datastoreProvidersRawRes.RequestMessage.RequestUri.AbsoluteUri}"); + } + finally + { + await StopCertifyService(certifyService); + } + } + + [TestMethod, Description("Validate that Certify.Service.exe returns a valid response on route GET /api/system/datastores/")] + public async Task TestCertifyServiceDatastoresRoute() + { + var certifyService = await StartCertifyService(); + + try + { + var datastoreRawRes = await _httpClient.GetAsync("system/datastores/"); + var datastoreRawResStr = await datastoreRawRes.Content.ReadAsStringAsync(); + var datastoreRes = JsonConvert.DeserializeObject>(datastoreRawResStr); + + Assert.AreEqual(HttpStatusCode.OK, datastoreRawRes.StatusCode, $"Unexpected status code from GET {datastoreRawRes.RequestMessage.RequestUri.AbsoluteUri}"); + Assert.IsTrue(datastoreRes.Count >= 1); + } + finally + { + await StopCertifyService(certifyService); + } + } + + [TestMethod, Description("Validate that Certify.Service.exe returns a valid response on route POST /api/system/datastores/test")] + public async Task TestCertifyServiceDatastoresTestRoute() + { + var certifyService = await StartCertifyService(); + + try + { + var datastoreRawRes = await _httpClient.GetAsync("system/datastores/"); + var datastoreRawResStr = await datastoreRawRes.Content.ReadAsStringAsync(); + var datastoreRes = JsonConvert.DeserializeObject>(datastoreRawResStr); + + Assert.AreEqual(HttpStatusCode.OK, datastoreRawRes.StatusCode, $"Unexpected status code from GET {datastoreRawRes.RequestMessage.RequestUri.AbsoluteUri}"); + Assert.IsTrue(datastoreRes.Count >= 1); + + var datastoreTestRawRes = await _httpClient.PostAsJsonAsync("system/datastores/test", datastoreRes[0]); + var datastoreTestRawResStr = await datastoreTestRawRes.Content.ReadAsStringAsync(); + var datastoreTestRes = JsonConvert.DeserializeObject>(datastoreTestRawResStr); + + Assert.AreEqual(HttpStatusCode.OK, datastoreTestRawRes.StatusCode, $"Unexpected status code from POST {datastoreTestRawRes.RequestMessage.RequestUri.AbsoluteUri}"); + Assert.IsTrue(datastoreTestRes.Count >= 1); + } + finally + { + await StopCertifyService(certifyService); + } + } + + [TestMethod, Description("Validate that Certify.Service.exe returns a valid response on route POST /api/system/datastores/update")] + public async Task TestCertifyServiceDatastoresUpdateRoute() + { + var certifyService = await StartCertifyService(); + + try + { + var datastoreRawRes = await _httpClient.GetAsync("system/datastores/"); + var datastoreRawResStr = await datastoreRawRes.Content.ReadAsStringAsync(); + var datastoreRes = JsonConvert.DeserializeObject>(datastoreRawResStr); + + Assert.AreEqual(HttpStatusCode.OK, datastoreRawRes.StatusCode, $"Unexpected status code from GET {datastoreRawRes.RequestMessage.RequestUri.AbsoluteUri}"); + Assert.IsTrue(datastoreRes.Count >= 1); + + var datastoreUpdateRawRes = await _httpClient.PostAsJsonAsync("system/datastores/update", datastoreRes[0]); + var datastoreUpdateRawResStr = await datastoreUpdateRawRes.Content.ReadAsStringAsync(); + var datastoreUpdateRes = JsonConvert.DeserializeObject>(datastoreUpdateRawResStr); + + Assert.AreEqual(HttpStatusCode.OK, datastoreUpdateRawRes.StatusCode, $"Unexpected status code from POST {datastoreUpdateRawRes.RequestMessage.RequestUri.AbsoluteUri}"); + Assert.IsTrue(datastoreUpdateRes.Count >= 1); + } + finally + { + await StopCertifyService(certifyService); + } + } + + [TestMethod, Description("Validate that Certify.Service.exe returns a valid response on route POST /api/system/datastores/setdefault/{dataStoreId}")] + public async Task TestCertifyServiceDatastoresSetDefaultRoute() + { + var certifyService = await StartCertifyService(); + + try + { + var datastoreRawRes = await _httpClient.GetAsync("system/datastores/"); + var datastoreRawResStr = await datastoreRawRes.Content.ReadAsStringAsync(); + var datastoreRes = JsonConvert.DeserializeObject>(datastoreRawResStr); + + Assert.AreEqual(HttpStatusCode.OK, datastoreRawRes.StatusCode, $"Unexpected status code from GET {datastoreRawRes.RequestMessage.RequestUri.AbsoluteUri}"); + Assert.IsTrue(datastoreRes.Count >= 1); + + var datastoreSetDefaultRawRes = await _httpClient.PostAsync($"system/datastores/setdefault/{datastoreRes[0].Id}", new StringContent("")); + var datastoreSetDefaultRawResStr = await datastoreSetDefaultRawRes.Content.ReadAsStringAsync(); + var datastoreSetDefaultRes = JsonConvert.DeserializeObject>(datastoreSetDefaultRawResStr); + + Assert.AreEqual(HttpStatusCode.OK, datastoreSetDefaultRawRes.StatusCode, $"Unexpected status code from POST {datastoreSetDefaultRawRes.RequestMessage.RequestUri.AbsoluteUri}"); + Assert.IsTrue(datastoreSetDefaultRes.Count >= 1); + } + finally + { + await StopCertifyService(certifyService); + } + } + + [TestMethod, Description("Validate that Certify.Service.exe returns a valid response on route POST /api/system/datastores/delete")] + [Ignore] + public async Task TestCertifyServiceDatastoresDeleteRoute() + { + var certifyService = await StartCertifyService(); + + try + { + var datastoreRawRes = await _httpClient.GetAsync("system/datastores/"); + var datastoreRawResStr = await datastoreRawRes.Content.ReadAsStringAsync(); + var datastoreRes = JsonConvert.DeserializeObject>(datastoreRawResStr); + + Assert.AreEqual(HttpStatusCode.OK, datastoreRawRes.StatusCode, $"Unexpected status code from GET {datastoreRawRes.RequestMessage.RequestUri.AbsoluteUri}"); + Assert.IsTrue(datastoreRes.Count >= 1); + + var datastoreDeleteRawRes = await _httpClient.PostAsync("system/datastores/delete", new StringContent(datastoreRes[0].Id)); + var datastoreDeleteRawResStr = await datastoreDeleteRawRes.Content.ReadAsStringAsync(); + var datastoreDeleteRes = JsonConvert.DeserializeObject>(datastoreDeleteRawResStr); + + Assert.AreEqual(HttpStatusCode.OK, datastoreDeleteRawRes.StatusCode, $"Unexpected status code from POST {datastoreDeleteRawRes.RequestMessage.RequestUri.AbsoluteUri}"); + Assert.IsTrue(datastoreDeleteRes.Count >= 1); + + var datastoreUpdateRawRes = await _httpClient.PostAsJsonAsync("system/datastores/update", datastoreRes[0]); + var datastoreUpdateRawResStr = await datastoreUpdateRawRes.Content.ReadAsStringAsync(); + var datastoreUpdateRes = JsonConvert.DeserializeObject>(datastoreUpdateRawResStr); + + Assert.AreEqual(HttpStatusCode.OK, datastoreUpdateRawRes.StatusCode, $"Unexpected status code from POST {datastoreUpdateRawRes.RequestMessage.RequestUri.AbsoluteUri}"); + Assert.IsTrue(datastoreUpdateRes.Count >= 1); + } + finally + { + await StopCertifyService(certifyService); + } + } + + [TestMethod, Description("Validate that Certify.Service.exe returns a valid response on route POST /api/system/datastores/copy/{sourceId}/{destId}")] + [Ignore] + public async Task TestCertifyServiceDatastoresCopyRoute() + { + var certifyService = await StartCertifyService(); + + try + { + var datastoreRawRes = await _httpClient.GetAsync("system/datastores/"); + var datastoreRawResStr = await datastoreRawRes.Content.ReadAsStringAsync(); + var datastoreRes = JsonConvert.DeserializeObject>(datastoreRawResStr); + + Assert.AreEqual(HttpStatusCode.OK, datastoreRawRes.StatusCode, $"Unexpected status code from GET {datastoreRawRes.RequestMessage.RequestUri.AbsoluteUri}"); + Assert.IsTrue(datastoreRes.Count >= 1); + + var newDataStoreId = "default-copy"; + var datastoreCopyRawRes = await _httpClient.PostAsync($"system/datastores/copy/{datastoreRes[0].Id}/{newDataStoreId}", new StringContent("")); + var datastoreCopyRawResStr = await datastoreCopyRawRes.Content.ReadAsStringAsync(); + var datastoreCopyRes = JsonConvert.DeserializeObject>(datastoreCopyRawResStr); + + Assert.AreEqual(HttpStatusCode.OK, datastoreCopyRawRes.StatusCode, $"Unexpected status code from POST {datastoreCopyRawRes.RequestMessage.RequestUri.AbsoluteUri}"); + Assert.IsTrue(datastoreCopyRes.Count >= 1); + + datastoreRawRes = await _httpClient.GetAsync("system/datastores/"); + datastoreRawResStr = await datastoreRawRes.Content.ReadAsStringAsync(); + datastoreRes = JsonConvert.DeserializeObject>(datastoreRawResStr); + + Assert.AreEqual(HttpStatusCode.OK, datastoreRawRes.StatusCode, $"Unexpected status code from GET {datastoreRawRes.RequestMessage.RequestUri.AbsoluteUri}"); + Assert.IsTrue(datastoreRes.Count >= 2); + + var datastoreDeleteRawRes = await _httpClient.PostAsJsonAsync("system/datastores/delete", newDataStoreId); + var datastoreDeleteRawResStr = await datastoreDeleteRawRes.Content.ReadAsStringAsync(); + var datastoreDeleteRes = JsonConvert.DeserializeObject>(datastoreDeleteRawResStr); + + Assert.AreEqual(HttpStatusCode.OK, datastoreDeleteRawRes.StatusCode, $"Unexpected status code from POST {datastoreDeleteRawRes.RequestMessage.RequestUri.AbsoluteUri}"); + Assert.IsTrue(datastoreDeleteRes.Count >= 1); + } + finally + { + await StopCertifyService(certifyService); + } + } + + [TestMethod, Description("Validate that Certify.Service.exe returns a valid response on route GET /api/server/isavailable/{serverType}")] + public async Task TestCertifyServiceServerIsavailableRoute() + { + var certifyService = await StartCertifyService(); + + try + { + var isAvailableRawRes = await _httpClient.GetAsync($"server/isavailable/{StandardServerTypes.IIS}"); + var isAvailableRawResStr = await isAvailableRawRes.Content.ReadAsStringAsync(); + var isAvailableRes = JsonConvert.DeserializeObject(isAvailableRawResStr); + + Assert.AreEqual(HttpStatusCode.OK, isAvailableRawRes.StatusCode, $"Unexpected status code from GET {isAvailableRawRes.RequestMessage.RequestUri.AbsoluteUri}"); + Assert.IsTrue(isAvailableRes); + } + finally + { + await StopCertifyService(certifyService); + } + } + + [TestMethod, Description("Validate that Certify.Service.exe returns a valid response on route GET /api/server/sitelist/{serverType}")] + public async Task TestCertifyServiceServerSitelistRoute() + { + var certifyService = await StartCertifyService(); + + try + { + var sitelistRawRes = await _httpClient.GetAsync($"server/sitelist/{StandardServerTypes.IIS}"); + var sitelistRawResStr = await sitelistRawRes.Content.ReadAsStringAsync(); + var sitelistRes = JsonConvert.DeserializeObject>(sitelistRawResStr); + + Assert.AreEqual(HttpStatusCode.OK, sitelistRawRes.StatusCode, $"Unexpected status code from GET {sitelistRawRes.RequestMessage.RequestUri.AbsoluteUri}"); + } + finally + { + await StopCertifyService(certifyService); + } + } + + [TestMethod, Description("Validate that Certify.Service.exe returns a valid response on route GET /api/server/version/{serverType}")] + public async Task TestCertifyServiceServerVersionRoute() + { + var certifyService = await StartCertifyService(); + + try + { + var versionRawRes = await _httpClient.GetAsync($"server/version/{StandardServerTypes.IIS}"); + var versionRawResStr = await versionRawRes.Content.ReadAsStringAsync(); + var versionRes = JsonConvert.DeserializeObject(versionRawResStr); + + Assert.AreEqual(HttpStatusCode.OK, versionRawRes.StatusCode, $"Unexpected status code from GET {versionRawRes.RequestMessage.RequestUri.AbsoluteUri}"); + StringAssert.Matches(versionRes, new Regex(@"^(\d+\.)?(\*|\d+)$"), $"Unexpected response from GET {versionRawRes.RequestMessage.RequestUri.AbsoluteUri} : {versionRawResStr}"); + } + finally + { + await StopCertifyService(certifyService); + } + } + } +#endif +} diff --git a/src/Certify.Tests/Certify.Core.Tests.Unit/ChallengeConfigMatchTests.cs b/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/ChallengeConfigMatchTests.cs similarity index 95% rename from src/Certify.Tests/Certify.Core.Tests.Unit/ChallengeConfigMatchTests.cs rename to src/Certify.Tests/Certify.Core.Tests.Unit/Tests/ChallengeConfigMatchTests.cs index fd5b5553a..7ef2d122a 100644 --- a/src/Certify.Tests/Certify.Core.Tests.Unit/ChallengeConfigMatchTests.cs +++ b/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/ChallengeConfigMatchTests.cs @@ -102,7 +102,7 @@ public void MultiChallengeConfigMatch() } [TestMethod, Description("Ensure correct challenge config selected based on domain")] - public void ChallengeDelgationRuleTests() + public void ChallengeDelegationRuleTests() { // wildcard rule tests [any subdomain source, any subdomain target] var testRule = "*.test.com:*.auth.test.co.uk"; @@ -140,5 +140,12 @@ public void ChallengeDelgationRuleTests() result = Management.Challenges.DnsChallengeHelper.ApplyChallengeDelegationRule("www.subdomain.example.com", "_acme-challenge.www.subdomain.example.com", testRule); Assert.AreEqual("_acme-challenge.www.subdomain.auth.example.co.uk", result); } + + [TestMethod, Description("Ensure correct challenge config selected when rule is blank")] + public void ChallengeDelegationRuleBlankRule() + { + var result = Management.Challenges.DnsChallengeHelper.ApplyChallengeDelegationRule("test.com", "_acme-challenge.test.com", null); + Assert.AreEqual("_acme-challenge.test.com", result); + } } } diff --git a/src/Certify.Tests/Certify.Core.Tests.Unit/ConnectionCheckTests.cs b/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/ConnectionCheckTests.cs similarity index 75% rename from src/Certify.Tests/Certify.Core.Tests.Unit/ConnectionCheckTests.cs rename to src/Certify.Tests/Certify.Core.Tests.Unit/Tests/ConnectionCheckTests.cs index 7ab2fe3a7..5df117389 100644 --- a/src/Certify.Tests/Certify.Core.Tests.Unit/ConnectionCheckTests.cs +++ b/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/ConnectionCheckTests.cs @@ -1,8 +1,6 @@ using System.Threading.Tasks; -using Certify.Models; using Certify.Shared.Core.Utils; using Microsoft.VisualStudio.TestTools.UnitTesting; -using Serilog; namespace Certify.Core.Tests.Unit { @@ -14,12 +12,6 @@ public async Task TestPortConnection() { var net = new NetworkUtils(enableProxyValidationAPI: true); - var logImp = new LoggerConfiguration() - .WriteTo.Debug() - .CreateLogger(); - - var log = new Loggy(logImp); - var result = await net.CheckServiceConnection("webprofusion.com", 80); Assert.IsTrue(result.IsSuccess, "hostname should connect ok on port 80"); diff --git a/src/Certify.Tests/Certify.Core.Tests.Unit/DnsQueryTests.cs b/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/DnsQueryTests.cs similarity index 87% rename from src/Certify.Tests/Certify.Core.Tests.Unit/DnsQueryTests.cs rename to src/Certify.Tests/Certify.Core.Tests.Unit/Tests/DnsQueryTests.cs index e5673dbde..646c0961d 100644 --- a/src/Certify.Tests/Certify.Core.Tests.Unit/DnsQueryTests.cs +++ b/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/DnsQueryTests.cs @@ -2,9 +2,8 @@ using System.Threading.Tasks; using Certify.Models; using Certify.Shared.Core.Utils; - +using Microsoft.Extensions.Logging; using Microsoft.VisualStudio.TestTools.UnitTesting; -using Serilog; namespace Certify.Core.Tests.Unit { @@ -17,11 +16,7 @@ public async Task TestDNSTests() { var net = new NetworkUtils(enableProxyValidationAPI: true); - var logImp = new LoggerConfiguration() - .WriteTo.Debug() - .CreateLogger(); - - var log = new Loggy(logImp); + var log = new Loggy(LoggerFactory.Create(builder => builder.AddDebug()).CreateLogger()); // check invalid domain var result = await net.CheckDNS(log, "fdlsakdfoweinoijsjdfpsdkfspdf.com"); @@ -51,17 +46,13 @@ public async Task TestDNSTests() Assert.IsFalse(result.All(r => r.IsSuccess), "incorrectly configured DNSSEC record should fail dns check"); } -#if NET6_0_OR_GREATER +#if NET9_0_OR_GREATER [TestMethod, Description("Check for a DNS TXT record")] public async Task TestDNS_CheckTXT() { var net = new NetworkUtils(enableProxyValidationAPI: true); - var logImp = new LoggerConfiguration() - .WriteTo.Debug() - .CreateLogger(); - - var log = new Loggy(logImp); + var log = new Loggy(null); // check invalid domain var result = await net.GetDNSRecordTXT(log, "_acme-challenge-test.cointelligence.io"); diff --git a/src/Certify.Tests/Certify.Core.Tests.Unit/DomainZoneMatchTests.cs b/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/DomainZoneMatchTests.cs similarity index 89% rename from src/Certify.Tests/Certify.Core.Tests.Unit/DomainZoneMatchTests.cs rename to src/Certify.Tests/Certify.Core.Tests.Unit/Tests/DomainZoneMatchTests.cs index 7a82f1011..0b724a6d2 100644 --- a/src/Certify.Tests/Certify.Core.Tests.Unit/DomainZoneMatchTests.cs +++ b/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/DomainZoneMatchTests.cs @@ -18,8 +18,8 @@ public async Task DetermineRootDomainTests() new DnsZone{ Name="test.com", ZoneId="123-test.com"}, new DnsZone{ Name="subdomain.test.com", ZoneId="345-subdomain-test.com"}, new DnsZone{ Name="long-subdomain.test.com", ZoneId="345-subdomain-test.com"}, - new DnsZone{ Name="bar.co.uk", ZoneId="lengthtest-1"}, - new DnsZone{ Name="foobar.co.uk", ZoneId="lengthtest-2"} + new DnsZone{ Name="bar.co.uk", ZoneId="lengthtest-1"}, + new DnsZone{ Name="foobar.co.uk", ZoneId="lengthtest-2"} } ); @@ -32,6 +32,9 @@ public async Task DetermineRootDomainTests() domainRoot = await mockDnsProvider.Object.DetermineZoneDomainRoot("www.test.com", "123-test.com"); Assert.IsTrue(domainRoot.ZoneId == "123-test.com"); + domainRoot = await mockDnsProvider.Object.DetermineZoneDomainRoot("test.com", "bad.domain.com"); + Assert.IsTrue(domainRoot.ZoneId == "123-test.com"); + domainRoot = await mockDnsProvider.Object.DetermineZoneDomainRoot("www.test.com", null); Assert.IsTrue(domainRoot.ZoneId == "123-test.com"); diff --git a/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/GetDnsProviderTests.cs b/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/GetDnsProviderTests.cs new file mode 100644 index 000000000..ad8e4e1cc --- /dev/null +++ b/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/GetDnsProviderTests.cs @@ -0,0 +1,150 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Certify.Core.Management.Challenges; +using Certify.Datastore.SQLite; +using Certify.Management; +using Certify.Models.Config; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Certify.Core.Tests.Unit +{ + [TestClass] + public class GetDnsProviderTests + { + private SQLiteCredentialStore credentialsManager; + private DnsChallengeHelper dnsHelper; + + public GetDnsProviderTests() + { + var pluginManager = new PluginManager(); + pluginManager.LoadPlugins(new List { PluginManager.PLUGINS_DNS_PROVIDERS }); + var TEST_PATH = "Tests\\credentials"; + credentialsManager = new SQLiteCredentialStore(TEST_PATH); + dnsHelper = new DnsChallengeHelper(credentialsManager); + } + + [TestMethod, Description("Test Getting DNS Provider with empty CredentialID")] + public async Task TestGetDnsProvidersEmptyCredentialID() + { + var providerTypeId = "DNS01.Powershell"; + var credentialId = ""; + var result = await dnsHelper.GetDnsProvider(providerTypeId, credentialId, null, credentialsManager); + + // Assert + Assert.AreEqual("DNS Challenge API Provider not set or could not load.", result.Result.Message); + Assert.IsFalse(result.Result.IsSuccess); + Assert.IsFalse(result.Result.IsWarning); + } + + [TestMethod, Description("Test Getting DNS Provider with empty ProviderTypeId")] + public async Task TestGetDnsProvidersEmptyProviderTypeId() + { + var providerTypeId = ""; + var secrets = new Dictionary(); + secrets.Add("zoneid", "ABC123"); + secrets.Add("secretid", "thereisnosecret"); + var testCredential = new StoredCredential + { + ProviderType = "DNS01.Manual", + Title = "A test credential", + StorageKey = Guid.NewGuid().ToString(), + Secret = Newtonsoft.Json.JsonConvert.SerializeObject(secrets) + }; + var updateResult = await credentialsManager.Update(testCredential); + + var result = await dnsHelper.GetDnsProvider(providerTypeId, testCredential.StorageKey, null, credentialsManager); + + // Assert + Assert.AreEqual("DNS Challenge API Provider not set or could not load.", result.Result.Message); + Assert.IsFalse(result.Result.IsSuccess); + Assert.IsFalse(result.Result.IsWarning); + + // Cleanup credentials + await credentialsManager.Delete(null, testCredential.StorageKey); + } + + [TestMethod, Description("Test Getting DNS Provider with a bad CredentialId")] + public async Task TestGetDnsProvidersBadCredentialId() + { + var secrets = new Dictionary(); + secrets.Add("zoneid", "ABC123"); + secrets.Add("secretid", "thereisnosecret"); + var testCredential = new StoredCredential + { + ProviderType = "DNS01.Manual", + Title = "A test credential", + StorageKey = Guid.NewGuid().ToString(), + Secret = Newtonsoft.Json.JsonConvert.SerializeObject(secrets) + }; + + var updateResult = await credentialsManager.Update(testCredential); + + var result = await dnsHelper.GetDnsProvider(testCredential.ProviderType, testCredential.StorageKey.Substring(5), null, credentialsManager); + + // Assert + Assert.AreEqual("DNS Challenge API Credentials could not be decrypted or no longer exists. The original user must be used for decryption.", result.Result.Message); + Assert.IsFalse(result.Result.IsSuccess); + Assert.IsFalse(result.Result.IsWarning); + + // Cleanup credentials + await credentialsManager.Delete(null, testCredential.StorageKey); + } + + [TestMethod, Description("Test Getting DNS Provider")] + public async Task TestGetDnsProviders() + { + var secrets = new Dictionary(); + secrets.Add("zoneid", "ABC123"); + secrets.Add("secretid", "thereisnosecret"); + var testCredential = new StoredCredential + { + ProviderType = "DNS01.Manual", + Title = "A test credential", + StorageKey = Guid.NewGuid().ToString(), + Secret = Newtonsoft.Json.JsonConvert.SerializeObject(secrets) + }; + + var updateResult = await credentialsManager.Update(testCredential); + + var result = await dnsHelper.GetDnsProvider(testCredential.ProviderType, testCredential.StorageKey, null, credentialsManager); + + // Assert + Assert.AreEqual("Create Provider Instance", result.Result.Message); + Assert.IsTrue(result.Result.IsSuccess); + Assert.IsFalse(result.Result.IsWarning); + Assert.AreEqual(testCredential.ProviderType, result.Provider.ProviderId); + + // Cleanup credentials + await credentialsManager.Delete(null, testCredential.StorageKey); + } + + [TestMethod, Description("Test Getting Challenge API Providers")] + public async Task TestGetChallengeAPIProviders() + { + var challengeAPIProviders = await ChallengeProviders.GetChallengeAPIProviders(); + + // Assert + Assert.IsNotNull(challengeAPIProviders); + Assert.AreNotEqual(0, challengeAPIProviders.Count); + foreach (object item in challengeAPIProviders) + { + var itemType = item.GetType(); + Assert.IsTrue(itemType.GetProperty("ChallengeType") != null); + Assert.IsTrue(itemType.GetProperty("Config") != null); + Assert.IsTrue(itemType.GetProperty("Description") != null); + Assert.IsTrue(itemType.GetProperty("HandlerType") != null); + Assert.IsTrue(itemType.GetProperty("HasDynamicParameters") != null); + Assert.IsTrue(itemType.GetProperty("HelpUrl") != null); + Assert.IsTrue(itemType.GetProperty("Id") != null); + Assert.IsTrue(itemType.GetProperty("IsEnabled") != null); + Assert.IsTrue(itemType.GetProperty("IsExperimental") != null); + Assert.IsTrue(itemType.GetProperty("IsTestModeSupported") != null); + Assert.IsTrue(itemType.GetProperty("PropagationDelaySeconds") != null); + Assert.IsTrue(itemType.GetProperty("ProviderCategoryId") != null); + Assert.IsTrue(itemType.GetProperty("ProviderParameters") != null); + Assert.IsTrue(itemType.GetProperty("Title") != null); + } + } + } +} diff --git a/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/LoggyTests.cs b/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/LoggyTests.cs new file mode 100644 index 000000000..03f4eed99 --- /dev/null +++ b/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/LoggyTests.cs @@ -0,0 +1,188 @@ +using System; +using System.IO; +using Certify.Models; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Serilog; + +namespace Certify.Core.Tests.Unit +{ + [TestClass] + public class LoggyTests + { + private string testsDataPath; + private string logFilePath; + + [TestInitialize] + public void TestInitialize() + { + testsDataPath = Path.Combine(EnvironmentUtil.CreateAppDataPath(), "Tests"); + logFilePath = Path.Combine(testsDataPath, "test.log"); + + if (!Directory.Exists(testsDataPath)) + { + Directory.CreateDirectory(testsDataPath); + + } + + if (File.Exists(logFilePath)) + { + File.Delete(this.logFilePath); + } + } + + [TestCleanup] + public void TestCleanup() + { + File.Delete(this.logFilePath); + } + + [TestMethod, Description("Test Loggy.Error() Method")] + public void TestLoggyError() + { + // Setup instance of Loggy + var logImp = new LoggerConfiguration() + .WriteTo.File(this.logFilePath) + .CreateLogger(); + + var log = new Loggy(new Serilog.Extensions.Logging.SerilogLoggerFactory(logImp).CreateLogger()); + + // Log an error message using Loggy.Error() + var logMessage = "New Loggy Error"; + log.Error(logMessage); + logImp.Dispose(); + + // Read in logged out error text + var logText = File.ReadAllText(this.logFilePath); + + // Validate logged out error text + Assert.IsTrue(logText.Contains(logMessage), $"Logged error message should contain '{logMessage}'"); + Assert.IsTrue(logText.Contains("[ERR]"), "Logged error message should contain '[ERR]'"); + } + + [TestMethod, Description("Test Loggy.Error() Method (Exception)")] + public void TestLoggyErrorException() + { + // Setup instance of Loggy + var logImp = new LoggerConfiguration() + .WriteTo.File(this.logFilePath) + .CreateLogger(); + var log = new Loggy(new Serilog.Extensions.Logging.SerilogLoggerFactory(logImp).CreateLogger()); + + // Trigger an exception error and log it using Loggy.Error() + var logMessage = "New Loggy Exception Error"; + var badFilePath = Path.Combine(EnvironmentUtil.CreateAppDataPath(), "Tests", "test1.log"); + + var exceptionError = $"System.IO.FileNotFoundException: Could not find file '{badFilePath}'."; + try + { + var nullObject = File.ReadAllBytes(badFilePath); + } + catch (Exception e) + { + log.Error(e, logMessage); + } + + logImp.Dispose(); + + // Read in logged out exception error text + var logText = File.ReadAllText(this.logFilePath); + + // Validate logged out exception error text + Assert.IsTrue(logText.Contains(logMessage), $"Logged error message should contain '{logMessage}'"); + Assert.IsTrue(logText.Contains("[ERR]"), "Logged error message should contain '[ERR]'"); + Assert.IsTrue(logText.Contains(exceptionError), $"Logged error message should contain exception error '{exceptionError}'"); + } + + [TestMethod, Description("Test Loggy.Information() Method")] + public void TestLoggyInformation() + { + // Setup instance of Loggy + var logImp = new LoggerConfiguration() + .WriteTo.File(this.logFilePath) + .CreateLogger(); + var log = new Loggy(new Serilog.Extensions.Logging.SerilogLoggerFactory(logImp).CreateLogger()); + + // Log an info message using Loggy.Information() + var logMessage = "New Loggy Information"; + log.Information(logMessage); + logImp.Dispose(); + + // Read in logged out info text + var logText = File.ReadAllText(this.logFilePath); + + // Validate logged out info text + Assert.IsTrue(logText.Contains(logMessage), $"Logged info message should contain '{logMessage}'"); + Assert.IsTrue(logText.Contains("[INF]"), "Logged info message should contain '[INF]'"); + } + + [TestMethod, Description("Test Loggy.Debug() Method")] + public void TestLoggyDebug() + { + // Setup instance of Loggy + var logImp = new LoggerConfiguration() + .MinimumLevel.Debug() + .WriteTo.File(this.logFilePath) + .CreateLogger(); + var log = new Loggy(new Serilog.Extensions.Logging.SerilogLoggerFactory(logImp).CreateLogger()); + + // Log a debug message using Loggy.Debug() + var logMessage = "New Loggy Debug"; + log.Debug(logMessage); + logImp.Dispose(); + + // Read in logged out debug text + var logText = File.ReadAllText(this.logFilePath); + + // Validate logged out debug text + Assert.IsTrue(logText.Contains(logMessage), $"Logged debug message should contain '{logMessage}'"); + Assert.IsTrue(logText.Contains("[DBG]"), "Logged debug message should contain '[DBG]'"); + } + + [TestMethod, Description("Test Loggy.Verbose() Method")] + public void TestLoggyVerbose() + { + // Setup instance of Loggy + var logImp = new LoggerConfiguration() + .MinimumLevel.Verbose() + .WriteTo.File(this.logFilePath) + .CreateLogger(); + var log = new Loggy(new Serilog.Extensions.Logging.SerilogLoggerFactory(logImp).CreateLogger()); + + // Log a verbose message using Loggy.Verbose() + var logMessage = "New Loggy Verbose"; + log.Verbose(logMessage); + logImp.Dispose(); + + // Read in logged out verbose text + var logText = File.ReadAllText(this.logFilePath); + + // Validate logged out verbose text + Assert.IsTrue(logText.Contains(logMessage), $"Logged verbose message should contain '{logMessage}'"); + Assert.IsTrue(logText.Contains("[VRB]"), "Logged verbose message should contain '[VRB]'"); + } + + [TestMethod, Description("Test Loggy.Warning() Method")] + public void TestLoggyWarning() + { + // Setup instance of Loggy + var logImp = new LoggerConfiguration() + .WriteTo.File(this.logFilePath) + .CreateLogger(); + + var log = new Loggy(new Serilog.Extensions.Logging.SerilogLoggerFactory(logImp).CreateLogger()); + + // Log a warning message using Loggy.Warning() + var logMessage = "New Loggy Warning"; + log.Warning(logMessage); + logImp.Dispose(); + + // Read in logged out warning text + var logText = File.ReadAllText(this.logFilePath); + + // Validate logged out warning text + Assert.IsTrue(logText.Contains(logMessage), $"Logged warning message should contain '{logMessage}'"); + Assert.IsTrue(logText.Contains("[WRN]"), "Logged warning message should contain '[WRN]'"); + } + } +} diff --git a/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/MiscAcmeTests.cs b/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/MiscAcmeTests.cs new file mode 100644 index 000000000..7cec545f7 --- /dev/null +++ b/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/MiscAcmeTests.cs @@ -0,0 +1,222 @@ +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Certify.ACME.Anvil; +using Certify.ACME.Anvil.Acme; +using Certify.Models; +using Certify.Providers.ACME.Anvil; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using Moq.Protected; + +namespace Certify.Core.Tests.Unit +{ + [TestClass] + public class MiscAcmeTests + { + + [TestMethod, Description("Test Directory Query")] + public async Task TestAcmeDirectory() + { + + var directoryJson = """ + + { + "newNonce": "https://acme.dev.certifytheweb.com/v2/newNonce", + "newAccount": "https://acme.dev.certifytheweb.com/v2/newAccount", + "newOrder": "https://acme.dev.certifytheweb.com/v2/newOrder", + "revokeCert": "https://acme.dev.certifytheweb.com/v2/revokeCert", + "keyChange": "https://acme.dev.certifytheweb.com/v2/keyChange", + "meta": { + "termsOfService": "https://acme.dev.certifytheweb.com/v2/tc.pdf", + "website": "https://certifytheweb.com", + "caaIdentities": ["certifytheweb.com"], + "externalAccountRequired": false + } + } + + """; + + var mockMessageHandler = new Mock(); + + mockMessageHandler + .Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent(directoryJson.Trim(), Encoding.UTF8, "application/json") + }); + + using ILoggerFactory factory = LoggerFactory.Create(builder => builder.AddDebug()); + var logger = factory.CreateLogger(nameof(MiscTests)); + + var loggingHandler = new LoggingHandler(mockMessageHandler.Object, new Loggy(logger), maxRequestsPerSecond: 2); + var customHttpClient = new System.Net.Http.HttpClient(loggingHandler); + + var acmeHttpClient = new AcmeHttpClient(WellKnownServers.LetsEncryptStaging, customHttpClient); + + var acmeContext = new AcmeContext(WellKnownServers.LetsEncryptStagingV2, http: acmeHttpClient); + + var dir = await acmeContext.GetDirectory(throwOnError: true); + + Assert.IsNotNull(dir); + } + + [TestMethod, Description("Test Directory Query Rate Limit 429")] + public async Task TestAcmeDirectoryRateLimit() + { + // Some CAs have different type of rate limit, occasionally it's at the server or traffic manager level + // and is not aware of ACME problem responses etc. This example matches ZeroSSLs rate limit behaviour (if it encounters more than 7 requests per second) + + var directoryResponseRateLimited = """ + + + 429 Too Many Requests + +

429 Too Many Requests

+
nginx
+ + + + """; + + // test message gets disposed after being consumed so we generate a new one for every call + var rateLimitedResponseMessageFactory = () => + { + var msg = new HttpResponseMessage + { + Content = new StringContent(directoryResponseRateLimited.Trim(), Encoding.UTF8, "text/html"), + StatusCode = (HttpStatusCode)429 + }; + msg.Headers.Add("Retry-After", "5"); + return msg; + }; + + var mockMessageHandler = new Mock(); + + mockMessageHandler + .Protected() + .SetupSequence>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync(rateLimitedResponseMessageFactory()) + .ReturnsAsync(rateLimitedResponseMessageFactory()) + .ReturnsAsync(rateLimitedResponseMessageFactory()); + + using ILoggerFactory factory = LoggerFactory.Create(builder => builder.AddDebug()); + var logger = factory.CreateLogger(nameof(MiscTests)); + + var loggingHandler = new LoggingHandler(mockMessageHandler.Object, new Loggy(logger), maxRequestsPerSecond: 2); + var customHttpClient = new System.Net.Http.HttpClient(loggingHandler); + + var acmeHttpClient = new AcmeHttpClient(WellKnownServers.LetsEncryptStaging, customHttpClient); + + var acmeContext = new AcmeContext(WellKnownServers.LetsEncryptStagingV2, http: acmeHttpClient); + acmeContext.AutoRetryAttempts = 2; + + try + { + await acmeContext.GetDirectory(throwOnError: true); + } + catch (AcmeRequestException ex) + { + Assert.AreEqual("urn:ietf:params:acme:error:rateLimited", ex.Error.Type); + } + } + + [TestMethod, Description("Test Directory Query Rate Limit With Auto Retry")] + public async Task TestAcmeDirectoryRateLimitWithRetry() + { + // Some CAs have different type of rate limit, occasionally it's at the server or traffic manager level + // and is not aware of ACME problem responses etc. This example matches ZeroSSLs rate limit behaviour (if it encounters more than 7 requests per second) + + var directoryResponseRateLimited = """ + + + 429 Too Many Requests + +

429 Too Many Requests

+
nginx
+ + + + """; + + var directoryJson = """ + + { + "newNonce": "https://acme.dev.certifytheweb.com/v2/newNonce", + "newAccount": "https://acme.dev.certifytheweb.com/v2/newAccount", + "newOrder": "https://acme.dev.certifytheweb.com/v2/newOrder", + "revokeCert": "https://acme.dev.certifytheweb.com/v2/revokeCert", + "keyChange": "https://acme.dev.certifytheweb.com/v2/keyChange", + "meta": { + "termsOfService": "https://acme.dev.certifytheweb.com/v2/tc.pdf", + "website": "https://certifytheweb.com", + "caaIdentities": ["certifytheweb.com"], + "externalAccountRequired": false + } + } + + """; + + // test message gets disposed after being consumed so we generate a new one for every call + var rateLimitedResponseMessageFactory = (int retryAfter) => + { + var msg = new HttpResponseMessage + { + Content = new StringContent(directoryResponseRateLimited.Trim(), Encoding.UTF8, "text/html"), + StatusCode = (HttpStatusCode)429 + }; + + // optionally include retry-after header + if (retryAfter > 0) + { + msg.Headers.Add("Retry-After", "5"); + } + + return msg; + }; + + var directoryResponseMessage = new HttpResponseMessage + { + Content = new StringContent(directoryJson.Trim(), Encoding.UTF8, "application/json"), + StatusCode = (HttpStatusCode)200 + }; + + var mockMessageHandler = new Mock(); + + mockMessageHandler.Protected() + .SetupSequence>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync(rateLimitedResponseMessageFactory(5)) + .ReturnsAsync(rateLimitedResponseMessageFactory(0)) + .ReturnsAsync(directoryResponseMessage); + + using ILoggerFactory factory = LoggerFactory.Create(builder => builder.AddDebug()); + + var logger = factory.CreateLogger(nameof(MiscTests)); + + var loggingHandler = new LoggingHandler(mockMessageHandler.Object, new Loggy(logger), maxRequestsPerSecond: 2); + var customHttpClient = new System.Net.Http.HttpClient(loggingHandler); + + var acmeHttpClient = new AcmeHttpClient(WellKnownServers.LetsEncryptStaging, customHttpClient); + + var acmeContext = new AcmeContext(WellKnownServers.LetsEncryptStagingV2, http: acmeHttpClient); + + ACME.Anvil.Acme.Resource.Directory dir = default; + try + { + dir = await acmeContext.GetDirectory(throwOnError: false); + } + catch (AcmeRequestException ex) + { + Assert.AreEqual("urn:ietf:params:acme:error:rateLimited", ex.Error.Type); + } + + Assert.IsNotNull(dir); + Assert.IsNotNull(dir.NewOrder); + } + } +} diff --git a/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/MiscTests.cs b/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/MiscTests.cs new file mode 100644 index 000000000..461fceb3e --- /dev/null +++ b/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/MiscTests.cs @@ -0,0 +1,135 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Certify.Models.Hub; +using Certify.Shared.Core.Utils; +using Certify.Shared.Core.Utils.PKI; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Org.BouncyCastle.X509; + +namespace Certify.Core.Tests.Unit +{ + [TestClass] + public class MiscTests + { + [TestMethod, Description("Test null/blank coalesce of string")] + public void TestNullOrBlankCoalesce() + { + string testValue = null; + + var result = testValue.WithDefault("ok"); + Assert.AreEqual(result, "ok"); + + testValue = "test"; + result = testValue.WithDefault("ok"); + Assert.AreEqual(result, "test"); + + var ca = new Models.CertificateAuthority(); + ca.Description = null; + result = ca.Description.WithDefault("default"); + Assert.AreEqual(result, "default"); + + ca = null; + result = ca?.Description.WithDefault("default"); + Assert.AreEqual(result, null); + } + + [TestMethod, Description("Test log parser using array of strings")] + public void TestLogParser() + { + var testLog = new string[] + { + "2023-06-14 13:00:30.480 +08:00 [WRN] ARI Update Renewal Info Failed[MGAwDQYJYIZIAWUDBAIBBQAEIDfbgj - 5Rkkn0NG7u0eFv_M1omHdEwY_mIQn6QxbuJ68BCA9ROYZMeqCkxyMzaMePORi17Gc9xSbp8XkoE1Ub0IPrwILBm8t23CUKQnarrc] Fail to load resource from 'https://acme-staging-v02.api.letsencrypt.org/draft-ietf-acme-ari-01/renewalInfo/'." , + "urn:ietf:params:acme: error: malformed: Certificate not found" , + "2023-06-14 13:01:11.139 +08:00 [INF] Performing Certificate Request: SporkDemo[zerossl][2390d803 - e036 - 4bf5 - 8fa5 - 590497392c35: 7]" + }; + + var items = LogParser.Parse(testLog); + + Assert.AreEqual(2, items.Length); + + Assert.AreEqual("WRN", items[0].LogLevel); + Assert.AreEqual("INF", items[1].LogLevel); + + } + + [TestMethod, Description("Test ntp check")] + public async Task TestNtp() + { + var check = await Certify.Management.Util.CheckTimeServer(); + + var timeDiff = check - DateTimeOffset.UtcNow; + + if (Math.Abs(timeDiff.Value.TotalSeconds) > 50) + { + Assert.Fail("NTP Time Difference Failed"); + } + } + +#if NET8_0_OR_GREATER + [TestMethod, Description("Test ARI CertID encoding example")] + public void TestARICertIDEncoding() + { + // https://letsencrypt.org/2024/04/25/guide-to-integrating-ari-into-existing-acme-clients + var certAKIbytes = Convert.FromHexString("69:88:5B:6B:87:46:40:41:E1:B3:7B:84:7B:A0:AE:2C:DE:01:C8:D4".Replace(":", "")); + var certSerialBytes = Convert.FromHexString("00:87:65:43:21".Replace(":", "")); + + var certId = Certify.Management.Util.ToUrlSafeBase64String(certAKIbytes) + + "." + + Certify.Management.Util.ToUrlSafeBase64String(certSerialBytes); + + Assert.AreEqual("aYhba4dGQEHhs3uEe6CuLN4ByNQ.AIdlQyE", certId); + } + + [TestMethod, Description("Test ARI CertID encoding example 2")] + public void TestARICertIDEncodingWithTestCert() + { + // https://letsencrypt.org/2024/04/25/guide-to-integrating-ari-into-existing-acme-clients + + // https://www.ietf.org/archive/id/draft-ietf-acme-ari-03.html#name-appendix-a-example-certific + + var testCertPem = @"-----BEGIN CERTIFICATE----- +MIIBQzCB66ADAgECAgUAh2VDITAKBggqhkjOPQQDAjAVMRMwEQYDVQQDEwpFeGFt +cGxlIENBMCIYDzAwMDEwMTAxMDAwMDAwWhgPMDAwMTAxMDEwMDAwMDBaMBYxFDAS +BgNVBAMTC2V4YW1wbGUuY29tMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEeBZu +7cbpAYNXZLbbh8rNIzuOoqOOtmxA1v7cRm//AwyMwWxyHz4zfwmBhcSrf47NUAFf +qzLQ2PPQxdTXREYEnKMjMCEwHwYDVR0jBBgwFoAUaYhba4dGQEHhs3uEe6CuLN4B +yNQwCgYIKoZIzj0EAwIDRwAwRAIge09+S5TZAlw5tgtiVvuERV6cT4mfutXIlwTb ++FYN/8oCIClDsqBklhB9KAelFiYt9+6FDj3z4KGVelYM5MdsO3pK +-----END CERTIFICATE----- + +"; + var cert = new X509CertificateParser().ReadCertificate(ASCIIEncoding.ASCII.GetBytes(testCertPem)); + + var certId = CertUtils.GetARICertIdBase64(cert); + + Assert.AreEqual("aYhba4dGQEHhs3uEe6CuLN4ByNQ.AIdlQyE", certId); + } +#endif + [TestMethod, Description("Test Demo Managed Cert Generation")] + public void TestDemoDataGeneration() + { + var items = DemoDataGenerator.GenerateDemoItems(); + + Assert.IsTrue(items.Any()); + } + + [TestMethod, Description("Source gen test")] + public void TestSourceGen() + { + var typeName = SourceGenerators.ApiMethods.GetFormattedTypeName(typeof(string)); + + Assert.AreEqual("System.String", typeName); + + typeName = SourceGenerators.ApiMethods.GetFormattedTypeName(typeof(Certify.Models.CertificateAuthority)); + + Assert.AreEqual("Certify.Models.CertificateAuthority", typeName); + + typeName = SourceGenerators.ApiMethods.GetFormattedTypeName(typeof(ICollection)); + + Assert.AreEqual("System.Collections.Generic.ICollection", typeName); + } + } +} diff --git a/src/Certify.Tests/Certify.Core.Tests.Unit/RdapTests.cs b/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/RdapTests.cs similarity index 96% rename from src/Certify.Tests/Certify.Core.Tests.Unit/RdapTests.cs rename to src/Certify.Tests/Certify.Core.Tests.Unit/Tests/RdapTests.cs index 6e4d50931..cb6c25d63 100644 --- a/src/Certify.Tests/Certify.Core.Tests.Unit/RdapTests.cs +++ b/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/RdapTests.cs @@ -7,12 +7,6 @@ namespace Certify.Core.Tests.Unit [TestClass] public class RdapTests { - - public RdapTests() - { - - } - [TestMethod, Description("Test domain TLD check")] [DataTestMethod] [DataRow("example.com", "com")] diff --git a/src/Certify.Tests/Certify.Core.Tests.Unit/RenewalRequiredTests.cs b/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/RenewalRequiredTests.cs similarity index 91% rename from src/Certify.Tests/Certify.Core.Tests.Unit/RenewalRequiredTests.cs rename to src/Certify.Tests/Certify.Core.Tests.Unit/Tests/RenewalRequiredTests.cs index bf8514764..03a91b3e7 100644 --- a/src/Certify.Tests/Certify.Core.Tests.Unit/RenewalRequiredTests.cs +++ b/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/RenewalRequiredTests.cs @@ -63,7 +63,7 @@ public void TestCheckAutoRenewalPeriodRequiredWithFailuresHold() DateExpiry = DateTimeOffset.UtcNow.AddDays(60), DateLastRenewalAttempt = DateTimeOffset.UtcNow.AddHours(-12), LastRenewalStatus = RequestState.Error, - RenewalFailureCount = 2000, // high number of failures + RenewalFailureCount = 100, // high number of failures DateNextScheduledRenewalAttempt = DateTimeOffset.UtcNow.AddHours(-0.1) // scheduled renewal set to become due }; @@ -77,12 +77,51 @@ var renewalDueCheck Assert.AreEqual(renewalDueCheck.HoldHrs, 48, "Hold should be for 48 Hrs"); managedCertificate.DateLastRenewalAttempt = DateTimeOffset.UtcNow.AddHours(-49); + + // perform check as if last attempt was over 48rs ago, item should require renewal and not be on hold + renewalDueCheck = ManagedCertificate.CalculateNextRenewalAttempt(managedCertificate, renewalPeriodDays, renewalIntervalMode, true); + + // assert result + Assert.IsTrue(renewalDueCheck.IsRenewalDue, "Renewal should be required"); + Assert.IsFalse(renewalDueCheck.IsRenewalOnHold, "Renewal should not be on hold"); + } + + [TestMethod, Description("Ensure renewal hold when item has failed more than 100 times")] + public void TestCheckAutoRenewalWithTooManyFailuresHold() + { + // setup + var renewalPeriodDays = 14; + var renewalIntervalMode = RenewalIntervalModes.DaysAfterLastRenewal; + + var managedCertificate = new ManagedCertificate + { + IncludeInAutoRenew = true, + DateRenewed = DateTimeOffset.UtcNow.AddDays(-15), + DateStart = DateTimeOffset.UtcNow.AddDays(-15), + DateExpiry = DateTimeOffset.UtcNow.AddDays(60), + DateLastRenewalAttempt = DateTimeOffset.UtcNow.AddHours(-12), + LastRenewalStatus = RequestState.Error, + RenewalFailureCount = 1001, // too many failures + DateNextScheduledRenewalAttempt = DateTimeOffset.UtcNow.AddHours(-0.1) // scheduled renewal set to become due + }; + // perform check + var renewalDueCheck + = ManagedCertificate.CalculateNextRenewalAttempt(managedCertificate, renewalPeriodDays, renewalIntervalMode, true); + + // assert result + Assert.IsTrue(renewalDueCheck.IsRenewalDue, "Renewal should be required"); + Assert.IsTrue(renewalDueCheck.IsRenewalOnHold, "Renewal should be on hold"); + Assert.AreEqual(renewalDueCheck.HoldHrs, 48, "Hold should be for 48 Hrs"); + + managedCertificate.DateLastRenewalAttempt = DateTimeOffset.UtcNow.AddHours(-49); + + // perform check as if last attempt was over 48rs ago, item should require renewal and not be on hold renewalDueCheck = ManagedCertificate.CalculateNextRenewalAttempt(managedCertificate, renewalPeriodDays, renewalIntervalMode, true); // assert result Assert.IsTrue(renewalDueCheck.IsRenewalDue, "Renewal should be required"); - Assert.IsFalse(renewalDueCheck.IsRenewalOnHold, "Renewal should be required"); + Assert.IsTrue(renewalDueCheck.IsRenewalOnHold, "Renewal should permanently be on hol, too many failures."); } [TestMethod, Description("Ensure a site which should be renewed correctly requires renewal")] diff --git a/src/Certify.Tests/Certify.Core.Tests.Unit/UpdateCheckTest.cs b/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/UpdateCheckTest.cs similarity index 93% rename from src/Certify.Tests/Certify.Core.Tests.Unit/UpdateCheckTest.cs rename to src/Certify.Tests/Certify.Core.Tests.Unit/Tests/UpdateCheckTest.cs index 5cc91ed9b..172741c2b 100644 --- a/src/Certify.Tests/Certify.Core.Tests.Unit/UpdateCheckTest.cs +++ b/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/UpdateCheckTest.cs @@ -15,12 +15,14 @@ public void TestUpdateCheck() // current version is older than newer version Assert.IsTrue(result.IsNewerVersion); - result = updateChecker.CheckForUpdates("6.1.1").Result; + result = updateChecker.CheckForUpdates("10.1.1").Result; // current version is newer than update version Assert.IsFalse(result.IsNewerVersion); - result = updateChecker.CheckForUpdates("6.1.1").Result; + result = updateChecker.CheckForUpdates("10.1.1").Result; + + Assert.IsNotNull(result, "Update check result should not be null"); // current version is newer than update version Assert.IsFalse(result.IsNewerVersion); diff --git a/src/Certify.Tests/Certify.Core.Tests.Unit/certify-core-tests-unit-4_6_2-win.dockerfile b/src/Certify.Tests/Certify.Core.Tests.Unit/certify-core-tests-unit-4_6_2-win.dockerfile new file mode 100644 index 000000000..9a0b7d4c3 --- /dev/null +++ b/src/Certify.Tests/Certify.Core.Tests.Unit/certify-core-tests-unit-4_6_2-win.dockerfile @@ -0,0 +1,29 @@ +FROM mcr.microsoft.com/dotnet/sdk:9.0-preview-windowsservercore-ltsc2022 AS base +WORKDIR /app +EXPOSE 80 +EXPOSE 443 +EXPOSE 9696 + +# define build and copy required source files +FROM mcr.microsoft.com/dotnet/sdk:9.0-preview-windowsservercore-ltsc2022 AS build +WORKDIR /src +COPY ./certify/src ./certify/src +COPY ./certify-plugins/src ./certify-plugins/src +COPY ./certify-internal/src/Certify.Plugins ./certify-internal/src/Certify.Plugins +COPY ./libs/anvil ./libs/anvil +RUN dotnet build ./certify/src/Certify.Tests/Certify.Core.Tests.Unit/Certify.Core.Tests.Unit.csproj -f net462 -c Debug -o /app/build + +# build and publish (as Debug mode) to /app/publish +FROM build AS publish +COPY --from=build /app/build/x64/SQLite.Interop.dll /app/publish/x64/ +RUN dotnet publish ./certify/src/Certify.Tests/Certify.Core.Tests.Unit/Certify.Core.Tests.Unit.csproj -f net462 -c Debug -o /app/publish +RUN dotnet publish ./certify-internal/src/Certify.Plugins/Plugins.All/Plugins.All.csproj -f net462 -c Debug -o /app/publish/Plugins +COPY ./libs/Posh-ACME/Posh-ACME /app/publish/Scripts/DNS/PoshACME + +# copy build from /app/publish in sdk image to final image +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . + +# run the service, alternatively we could runs tests etc +ENTRYPOINT ["dotnet", "test", "Certify.Core.Tests.Unit.dll", "-f", "net462"] diff --git a/src/Certify.Tests/Certify.Core.Tests.Unit/certify-core-tests-unit-9_0-linux.dockerfile b/src/Certify.Tests/Certify.Core.Tests.Unit/certify-core-tests-unit-9_0-linux.dockerfile new file mode 100644 index 000000000..abb4010d7 --- /dev/null +++ b/src/Certify.Tests/Certify.Core.Tests.Unit/certify-core-tests-unit-9_0-linux.dockerfile @@ -0,0 +1,28 @@ +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS base +WORKDIR /app +EXPOSE 80 +EXPOSE 443 +EXPOSE 9696 +RUN wget https://dl.smallstep.com/gh-release/cli/docs-cli-install/v0.23.0/step-cli_0.23.0_amd64.deb && dpkg -i step-cli_0.23.0_amd64.deb + +# define build and copy required source files +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build +WORKDIR /src +COPY ./certify/src ./certify/src +COPY ./certify-plugins/src ./certify-plugins/src +COPY ./certify-internal/src/Certify.Plugins ./certify-internal/src/Certify.Plugins +COPY ./libs/anvil ./libs/anvil + +# build and publish (as Release mode) to /app/publish +FROM build AS publish +RUN dotnet publish ./certify/src/Certify.Tests/Certify.Core.Tests.Unit/Certify.Core.Tests.Unit.csproj -f net9.0 -c Debug -o /app/publish +RUN dotnet publish ./certify-internal/src/Certify.Plugins/Plugins.All/Plugins.All.csproj -f net9.0 -c Debug -o /app/publish/plugins +COPY ./libs/Posh-ACME/Posh-ACME /app/publish/Scripts/DNS/PoshACME + +# copy build from /app/publish in sdk image to final image +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . + +# run the service, alternatively we could runs tests etc +ENTRYPOINT ["dotnet", "test", "Certify.Core.Tests.Unit.dll", "-f", "net9.0"] diff --git a/src/Certify.Tests/Certify.Core.Tests.Unit/certify-core-tests-unit-9_0-win.dockerfile b/src/Certify.Tests/Certify.Core.Tests.Unit/certify-core-tests-unit-9_0-win.dockerfile new file mode 100644 index 000000000..62599e8f0 --- /dev/null +++ b/src/Certify.Tests/Certify.Core.Tests.Unit/certify-core-tests-unit-9_0-win.dockerfile @@ -0,0 +1,31 @@ +FROM mcr.microsoft.com/dotnet/sdk:9.0-preview-windowsservercore-ltsc2022 AS base +WORKDIR /app +EXPOSE 80 +EXPOSE 443 +EXPOSE 9696 +RUN mkdir C:\temp && pwsh -Command "Invoke-WebRequest -Method 'GET' -uri 'https://dl.smallstep.com/gh-release/cli/docs-cli-install/v0.24.4/step_windows_0.24.4_amd64.zip' -Outfile 'C:\temp\step_windows_0.24.4_amd64.zip'" && tar -oxzf C:\temp\step_windows_0.24.4_amd64.zip -C "C:\Program Files" && rmdir /s /q C:\temp +USER ContainerAdministrator +RUN setx /M PATH "%PATH%;C:\Program Files\step_0.24.4\bin" +USER ContainerUser + +# define build and copy required source files +FROM mcr.microsoft.com/dotnet/sdk:9.0-preview-windowsservercore-ltsc2022 AS build +WORKDIR /src +COPY ./certify/src ./certify/src +COPY ./certify-plugins/src ./certify-plugins/src +COPY ./certify-internal/src/Certify.Plugins ./certify-internal/src/Certify.Plugins +COPY ./libs/anvil ./libs/anvil + +# build and publish (as Debug mode) to /app/publish +FROM build AS publish +RUN dotnet publish ./certify/src/Certify.Tests/Certify.Core.Tests.Unit/Certify.Core.Tests.Unit.csproj -f net9.0 -c Debug -o /app/publish +RUN dotnet publish ./certify-internal/src/Certify.Plugins/Plugins.All/Plugins.All.csproj -f net9.0 -c Debug -o /app/publish/plugins +COPY ./libs/Posh-ACME/Posh-ACME /app/publish/Scripts/DNS/PoshACME + +# copy build from /app/publish in sdk image to final image +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . + +# run the service, alternatively we could runs tests etc +ENTRYPOINT ["dotnet", "test", "Certify.Core.Tests.Unit.dll", "-f", "net9.0"] diff --git a/src/Certify.Tests/Certify.Core.Tests.Unit/linux_compose.yaml b/src/Certify.Tests/Certify.Core.Tests.Unit/linux_compose.yaml new file mode 100644 index 000000000..2485a9d0b --- /dev/null +++ b/src/Certify.Tests/Certify.Core.Tests.Unit/linux_compose.yaml @@ -0,0 +1,37 @@ +name: certify-core-tests-unit-linux +services: + + certify-core-tests-unit-9_0: + image: certify-core-tests-unit-9_0-linux:latest + build: + context: ../../../../ + dockerfile: ./certify/src/Certify.Tests/Certify.Core.Tests.Unit/certify-core-tests-unit-9_0-linux.dockerfile + ports: + - 80:80 + - 443:443 + - 9696:9696 + # environment: + # VSTEST_HOST_DEBUG: 1 + volumes: + - step:/mnt/step_share + # entrypoint: "dotnet test Certify.Core.Tests.Unit.dll -f net9.0 --filter 'ClassName=Certify.Core.Tests.Unit.CertifyManagerAccountTests'" + # entrypoint: "dotnet test Certify.Core.Tests.Unit.dll -f net9.0 --filter 'Name=TestCertifyManagerGetAccountDetails'" + depends_on: + step-ca: + condition: service_healthy + + step-ca: + image: smallstep/step-ca:latest + hostname: step-ca + ports: + - 9000:9000 + environment: + DOCKER_STEPCA_INIT_NAME: Smallstep + DOCKER_STEPCA_INIT_DNS_NAMES: localhost,step-ca + DOCKER_STEPCA_INIT_REMOTE_MANAGEMENT: true + DOCKER_STEPCA_INIT_ACME: true + volumes: + - step:/home/step + +volumes: + step: {} diff --git a/src/Certify.Tests/Certify.Core.Tests.Unit/step-ca-win-build.bat b/src/Certify.Tests/Certify.Core.Tests.Unit/step-ca-win-build.bat new file mode 100644 index 000000000..3e792a7a2 --- /dev/null +++ b/src/Certify.Tests/Certify.Core.Tests.Unit/step-ca-win-build.bat @@ -0,0 +1,5 @@ + mkdir C:\temp +pwsh -Command "Invoke-WebRequest -Method 'GET' -uri 'https://dl.smallstep.com/gh-release/cli/docs-cli-install/v0.24.4/step_windows_0.24.4_amd64.zip' -Outfile 'C:\temp\step_windows_0.24.4_amd64.zip'" && tar -oxzf C:\temp\step_windows_0.24.4_amd64.zip -C "C:\Program Files" +pwsh -Command "Invoke-WebRequest -Method 'GET' -uri 'https://dl.smallstep.com/gh-release/certificates/gh-release-header/v0.24.2/step-ca_windows_0.24.2_amd64.zip' -Outfile 'C:\temp\step-ca_windows_0.24.2_amd64.zip'" && tar -oxzf C:\temp\step-ca_windows_0.24.2_amd64.zip -C "C:\Program Files" +mkdir "C:\Program Files\SDelete" && pwsh -Command "Invoke-WebRequest -Method 'GET' -uri 'https://download.sysinternals.com/files/SDelete.zip' -Outfile 'C:\temp\SDelete.zip'" && tar -oxzf C:\temp\SDelete.zip -C "C:\Program Files\SDelete" +rmdir /s /q C:\temp diff --git a/src/Certify.Tests/Certify.Core.Tests.Unit/step-ca-win-init.bat b/src/Certify.Tests/Certify.Core.Tests.Unit/step-ca-win-init.bat new file mode 100644 index 000000000..56fde2918 --- /dev/null +++ b/src/Certify.Tests/Certify.Core.Tests.Unit/step-ca-win-init.bat @@ -0,0 +1,63 @@ +FOR /F "tokens=* USEBACKQ" %%F IN (`step path`) DO ( + SET STEPPATH=%%F +) + +IF EXIST %STEPPATH%\config\ca.json ( + step-ca --password-file %STEPPATH%\secrets\password %STEPPATH%\config\ca.json + EXIT 0 +) + +IF "%DOCKER_STEPCA_INIT_NAME%"=="" ( + echo "there is no ca.json config file; please run step ca init, or provide config parameters via DOCKER_STEPCA_INIT_ vars" + EXIT 1 +) + +IF "%DOCKER_STEPCA_INIT_DNS_NAMES%"=="" ( + echo "there is no ca.json config file; please run step ca init, or provide config parameters via DOCKER_STEPCA_INIT_ vars" + EXIT 1 +) + +IF "%DOCKER_STEPCA_INIT_PROVISIONER_NAME%"=="" SET DOCKER_STEPCA_INIT_PROVISIONER_NAME=admin +IF "%DOCKER_STEPCA_INIT_ADMIN_SUBJECT%"=="" SET DOCKER_STEPCA_INIT_ADMIN_SUBJECT=step +IF "%DOCKER_STEPCA_INIT_ADDRESS%"=="" SET DOCKER_STEPCA_INIT_ADDRESS=:9000 + +IF NOT "%DOCKER_STEPCA_INIT_PASSWORD%"=="" ( +pwsh -Command "Out-File -FilePath "$Env:STEPPATH\password" -InputObject "$Env:DOCKER_STEPCA_INIT_PASSWORD";"^ + "Out-File -FilePath "$Env:STEPPATH\provisioner_password" -InputObject "$Env:DOCKER_STEPCA_INIT_PASSWORD";" +) ELSE IF NOT "%DOCKER_STEPCA_INIT_PASSWORD_FILE%"=="" ( +pwsh -Command "Out-File -FilePath "$Env:STEPPATH\password" -InputObject "$Env:DOCKER_STEPCA_INIT_PASSWORD_FILE";"^ + "Out-File -FilePath "$Env:STEPPATH\provisioner_password" -InputObject "$Env:DOCKER_STEPCA_INIT_PASSWORD_FILE";" +) ELSE ( +pwsh -Command "$psw = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'.tochararray() | Get-Random -Count 40 | Join-String;"^ + "Out-File -FilePath "$Env:STEPPATH\password" -InputObject $psw;"^ + "Out-File -FilePath "$Env:STEPPATH\provisioner_password" -InputObject $psw;"^ + "Remove-Variable psw" +) + +setlocal + +SET INIT_ARGS=--deployment-type standalone --name %DOCKER_STEPCA_INIT_NAME% --dns %DOCKER_STEPCA_INIT_DNS_NAMES% --provisioner %DOCKER_STEPCA_INIT_PROVISIONER_NAME% --password-file %STEPPATH%\password --provisioner-password-file %STEPPATH%\provisioner_password --address %DOCKER_STEPCA_INIT_ADDRESS% + +IF "%DOCKER_STEPCA_INIT_SSH%"=="true" SET INIT_ARGS=%INIT_ARGS% -ssh +IF "%DOCKER_STEPCA_INIT_REMOTE_MANAGEMENT%"=="true" SET INIT_ARGS=%INIT_ARGS% --remote-management --admin-subject %DOCKER_STEPCA_INIT_ADMIN_SUBJECT% + +step ca init %INIT_ARGS% +SET /p psw=<%STEPPATH%\provisioner_password +echo "👉 Your CA administrative password is: %psw%" +echo "🤫 This will only be displayed once." + +endlocal + +SET HEALTH_URL=https://%DOCKER_STEPCA_INIT_DNS_NAMES%%DOCKER_STEPCA_INIT_ADDRESS%/health + +sdelete64 -accepteula -nobanner -q %STEPPATH%\provisioner_password + +move "%STEPPATH%\password" "%STEPPATH%\secrets\password" + +:: Current error with running this program in Windows Docker Container causes issue reading DB first time, so they must be deleted to be recreated +rmdir /s /q %STEPPATH%\db + +:: Current error with running this program in Windows Docker Container causes ACME not to be set with --acme +IF "%DOCKER_STEPCA_INIT_ACME%"=="true" step ca provisioner add acme --type ACME + +step-ca --password-file %STEPPATH%\secrets\password %STEPPATH%\config\ca.json diff --git a/src/Certify.Tests/Certify.Core.Tests.Unit/step-ca-win.dockerfile b/src/Certify.Tests/Certify.Core.Tests.Unit/step-ca-win.dockerfile new file mode 100644 index 000000000..a69d6aea7 --- /dev/null +++ b/src/Certify.Tests/Certify.Core.Tests.Unit/step-ca-win.dockerfile @@ -0,0 +1,20 @@ +FROM mcr.microsoft.com/dotnet/sdk:8.0-nanoserver-ltsc2022 AS base +WORKDIR /app +EXPOSE 9000 +COPY ./step-ca-win-build.bat . +RUN step-ca-win-build.bat + +USER ContainerAdministrator +RUN setx /M PATH "%PATH%;C:\Program Files\step_0.24.4\bin;C:\Program Files\step-ca_0.24.2;C:\Program Files\SDelete" +USER ContainerUser + +FROM mcr.microsoft.com/dotnet/sdk:8.0-windowsservercore-ltsc2022 AS netapi + +FROM base AS final + +COPY ./step-ca-win-init.bat . +COPY --from=netapi /Windows/System32/netapi32.dll /Windows/System32/netapi32.dll + +HEALTHCHECK CMD curl -Method GET -f %HEALTH_URL% || exit 1 + +CMD step-ca-win-init.bat && cmd diff --git a/src/Certify.Tests/Certify.Core.Tests.Unit/unit-test-linux.runsettings b/src/Certify.Tests/Certify.Core.Tests.Unit/unit-test-linux.runsettings new file mode 100644 index 000000000..0499b1c82 --- /dev/null +++ b/src/Certify.Tests/Certify.Core.Tests.Unit/unit-test-linux.runsettings @@ -0,0 +1,46 @@ + + + + + + $HOME/.nuget/packages/microsoft.codecoveraged/17.8.0/build/netstandard2.0 + ./TestResults-Linux + true + + + + + + + Cobertura + + + + + .*Certify.*$ + .*Plugin.Datastore.*$ + + + .*Certify.Core.Tests.Unit.dll$ + .*Moq.dll$ + .*Microsoft.*$ + + + True + + + + + + + + + + + normal + + + + + + diff --git a/src/Certify.Tests/Certify.Core.Tests.Unit/unit-test.runsettings b/src/Certify.Tests/Certify.Core.Tests.Unit/unit-test.runsettings new file mode 100644 index 000000000..72ea904e6 --- /dev/null +++ b/src/Certify.Tests/Certify.Core.Tests.Unit/unit-test.runsettings @@ -0,0 +1,46 @@ + + + + + + %SystemDrive%\%HOMEPATH%\.nuget\packages\microsoft.codecoverage\17.8.0\build\netstandard2.0 + .\TestResults-windows + true + + + + + + + Cobertura + + + + + .*Certify.*$ + .*Plugin.Datastore.*$ + + + .*Certify.Core.Tests.Unit.dll$ + .*Moq.dll$ + .*Microsoft.*$ + + + True + + + + + + + + + + + normal + + + + + + diff --git a/src/Certify.Tests/Certify.Core.Tests.Unit/windows_compose.yaml b/src/Certify.Tests/Certify.Core.Tests.Unit/windows_compose.yaml new file mode 100644 index 000000000..3c78e9086 --- /dev/null +++ b/src/Certify.Tests/Certify.Core.Tests.Unit/windows_compose.yaml @@ -0,0 +1,62 @@ +name: certify-core-tests-unit-win +services: + + certify-core-tests-unit-9_0: + image: certify-core-tests-unit-9_0-win:latest + build: + context: ../../../../ + dockerfile: ./certify/src/Certify.Tests/Certify.Core.Tests.Unit/certify-core-tests-unit-9_0-win.dockerfile + # environment: + # VSTEST_HOST_DEBUG: 1 + ports: + - 80:80 + - 443:443 + - 9696:9696 + # entrypoint: "dotnet test Certify.Core.Tests.Unit.dll -f net9.0 --filter 'ClassName=Certify.Core.Tests.Unit.CertifyManagerAccountTests'" + # entrypoint: "dotnet test Certify.Core.Tests.Unit.dll -f net9.0 --filter 'Name=TestCertifyManagerGetAccountDetailsDefinedCertificateAuthorityId'" + volumes: + - step:C:\step_share + profiles: ["9_0"] + depends_on: + step-ca: + condition: service_healthy + + certify-core-tests-unit-4_6_2: + image: certify-core-tests-unit-4_6_2-win:latest + build: + context: ../../../../ + dockerfile: ./certify/src/Certify.Tests/Certify.Core.Tests.Unit/certify-core-tests-unit-4_6_2-win.dockerfile + # environment: + # VSTEST_HOST_DEBUG: 1 + ports: + - 80:80 + - 443:443 + - 9696:9696 + # entrypoint: "dotnet test Certify.Core.Tests.Unit.dll -f net462 --filter 'ClassName=Certify.Core.Tests.Unit.CertifyManagerAccountTests'" + # entrypoint: "dotnet test Certify.Core.Tests.Unit.dll -f net462 --filter 'Name=TestCertifyManagerGetAccountDetailsDefinedCertificateAuthorityId'" + volumes: + - step:C:\step_share + profiles: ["4_6_2"] + depends_on: + step-ca: + condition: service_healthy + + step-ca: + image: step-ca-win:latest + build: + context: . + dockerfile: ./step-ca-win.dockerfile + hostname: step-ca + profiles: ["4_6_2", "9_0"] + ports: + - 9000:9000 + environment: + DOCKER_STEPCA_INIT_NAME: Smallstep + DOCKER_STEPCA_INIT_DNS_NAMES: localhost + DOCKER_STEPCA_INIT_REMOTE_MANAGEMENT: true + DOCKER_STEPCA_INIT_ACME: true + volumes: + - step:C:\Users\ContainerUser\.step + +volumes: + step: {} diff --git a/src/Certify.Tests/Certify.Service.Tests.Integration/Certify.Service.Tests.Integration.csproj b/src/Certify.Tests/Certify.Service.Tests.Integration/Certify.Service.Tests.Integration.csproj index c37807565..afa226f31 100644 --- a/src/Certify.Tests/Certify.Service.Tests.Integration/Certify.Service.Tests.Integration.csproj +++ b/src/Certify.Tests/Certify.Service.Tests.Integration/Certify.Service.Tests.Integration.csproj @@ -1,7 +1,8 @@ - net7.0 + net9.0 Debug;Release; + AnyCPU @@ -12,7 +13,7 @@ DEBUG;TRACE prompt 4 - x64 + AnyCPU 1701;1702;NU1701 @@ -23,24 +24,7 @@ prompt 4 - - true - bin\x64\Debug\ - DEBUG;TRACE - full - x64 - prompt - MinimumRecommendedRules.ruleset - - - bin\x64\Release\ - TRACE - true - pdbonly - x64 - prompt - MinimumRecommendedRules.ruleset - + Debug AnyCPU @@ -65,7 +49,7 @@ - x64 + AnyCPU 1701;1702;NU1701 @@ -75,13 +59,13 @@ - + - - - - - + + + + + diff --git a/src/Certify.Tests/Certify.Service.Tests.Integration/ServiceAuthTests.cs b/src/Certify.Tests/Certify.Service.Tests.Integration/ServiceAuthTests.cs index 8380a8096..8880b2258 100644 --- a/src/Certify.Tests/Certify.Service.Tests.Integration/ServiceAuthTests.cs +++ b/src/Certify.Tests/Certify.Service.Tests.Integration/ServiceAuthTests.cs @@ -10,8 +10,10 @@ public class ServiceAuthTests : ServiceTestBase [TestMethod] public async Task TestAuthFlow() { + AuthContext authContext = null; + // use windows auth to acquire initial auth key - var authKey = await _client.GetAuthKeyWindows(); + var authKey = await _client.GetAuthKeyWindows(authContext); Assert.IsNotNull(authKey); // attempt request without jwt auth being set yet @@ -20,21 +22,21 @@ public async Task TestAuthFlow() // check should throw exception await Assert.ThrowsExceptionAsync(async () => { - var noAuthResult = await _client.GetManagedCertificates(new Models.ManagedCertificateFilter { }); + var noAuthResult = await _client.GetManagedCertificates(new Models.ManagedCertificateFilter { }, authContext); Assert.IsNull(noAuthResult); }); // use auth key to get JWT - var jwt = await _client.GetAccessToken(authKey); + var jwt = await _client.GetAccessToken(authKey, authContext); Assert.IsNotNull(jwt); // attempt request with JWT set - var authedResult = await _client.GetManagedCertificates(new Models.ManagedCertificateFilter { }); + var authedResult = await _client.GetManagedCertificates(new Models.ManagedCertificateFilter { }, authContext); Assert.IsNotNull(authedResult); // refresh JWT - var refreshedToken = await _client.RefreshAccessToken(); + var refreshedToken = await _client.RefreshAccessToken(authContext); Assert.IsNotNull(jwt); } diff --git a/src/Certify.Tests/Certify.UI.Tests.Integration/Certify.UI.Tests.Integration.csproj b/src/Certify.Tests/Certify.UI.Tests.Integration/Certify.UI.Tests.Integration.csproj index 94b15d73c..af79b86d4 100644 --- a/src/Certify.Tests/Certify.UI.Tests.Integration/Certify.UI.Tests.Integration.csproj +++ b/src/Certify.Tests/Certify.UI.Tests.Integration/Certify.UI.Tests.Integration.csproj @@ -1,7 +1,8 @@  - net7.0-windows + net9.0-windows Debug;Release; + AnyCPU true @@ -20,25 +21,9 @@ prompt 4 AnyCPU + 1701;1702;NU1701 - - true - bin\x64\Debug\ - DEBUG;TRACE - full - x64 - prompt - MinimumRecommendedRules.ruleset - - - bin\x64\Release\ - TRACE - true - pdbonly - x64 - prompt - MinimumRecommendedRules.ruleset - + Debug AnyCPU @@ -60,7 +45,8 @@ - x64 + AnyCPU + 1701;1702;NU1701 @@ -69,17 +55,13 @@ - - - - - - - - - - + + + + + + diff --git a/src/Certify.Tests/Certify.UI.Tests.Integration/ViewModelTests.cs b/src/Certify.Tests/Certify.UI.Tests.Integration/ViewModelTests.cs index 5c2ff6794..096938bcf 100644 --- a/src/Certify.Tests/Certify.UI.Tests.Integration/ViewModelTests.cs +++ b/src/Certify.Tests/Certify.UI.Tests.Integration/ViewModelTests.cs @@ -1,67 +1,14 @@ -using System; -using System.Collections.Generic; -using System.Threading.Tasks; -using Certify.Client; -using Certify.Models; +using Certify.Models; using Certify.Models.Config; using Certify.Models.Shared.Validation; using Certify.UI.ViewModel; using Microsoft.VisualStudio.TestTools.UnitTesting; -using Moq; namespace Certify.UI.Tests.Integration { [TestClass] public class ViewModelTest { - [TestMethod, Ignore] - public async Task TestViewModelSetup() - { - var mockClient = new Mock(); - - mockClient.Setup(c => c.GetPreferences()).Returns( - Task.FromResult(new Models.Preferences { }) - ); - - mockClient.Setup(c => c.GetManagedCertificates(It.IsAny())) - .Returns( - Task.FromResult(new List { - new ManagedCertificate{ - Id= Guid.NewGuid().ToString(), - Name="Test Managed Certificate" - } - }) - ); - - mockClient.Setup(c => c.GetAccounts()) - .Returns( - Task.FromResult( - new List { - new AccountDetails { - Email = "test@example.com", - IsStagingAccount = true, - CertificateAuthorityId = StandardCertAuthorities.LETS_ENCRYPT - } - }) - ); - - mockClient.Setup(c => c.GetCredentials()) - .Returns( - Task.FromResult(new List { }) - ); - - var appModel = new AppViewModel(mockClient.Object); - - await appModel.LoadSettingsAsync(); - - Assert.IsTrue(appModel.ManagedCertificates.Count > 0, "Should have managed sites"); - - Assert.IsTrue(appModel.HasRegisteredContacts, "Should have a registered contact"); - - await appModel.RefreshStoredCredentialsList(); - - appModel.RenewAll(new RenewalSettings { }); - } [TestMethod] public void TestManagedCertViewModelValidationWithDomains() @@ -231,6 +178,12 @@ public void TestManagedCertViewModelValidationWithDomains() Assert.IsFalse(result.IsValid, result.Message); Assert.AreEqual(ValidationErrorCodes.SAN_LIMIT.ToString(), result.ErrorCode); + model.SelectedItem = null; + result = model.Validate(applyAutoConfiguration: true); + + Assert.IsFalse(result.IsValid, result.Message); + Assert.AreEqual(ValidationErrorCodes.ITEM_NOT_FOUND.ToString(), result.ErrorCode); + } [TestMethod] diff --git a/src/Certify.UI.Desktop/App.xaml b/src/Certify.UI.Desktop/App.xaml index efbd87683..644a1a01f 100644 --- a/src/Certify.UI.Desktop/App.xaml +++ b/src/Certify.UI.Desktop/App.xaml @@ -1,8 +1,8 @@ - @@ -53,7 +53,7 @@ - @@ -71,8 +71,8 @@ - - + + diff --git a/src/Certify.UI.Desktop/AssemblyInfo.cs b/src/Certify.UI.Desktop/AssemblyInfo.cs index 8b5504ecf..f60efc019 100644 --- a/src/Certify.UI.Desktop/AssemblyInfo.cs +++ b/src/Certify.UI.Desktop/AssemblyInfo.cs @@ -1,4 +1,4 @@ -using System.Windows; +using System.Windows; [assembly: ThemeInfo( ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located diff --git a/src/Certify.UI.Desktop/Certify.UI.Desktop.csproj b/src/Certify.UI.Desktop/Certify.UI.Desktop.csproj index bc5b55f80..684e2f372 100644 --- a/src/Certify.UI.Desktop/Certify.UI.Desktop.csproj +++ b/src/Certify.UI.Desktop/Certify.UI.Desktop.csproj @@ -1,22 +1,16 @@ - - - WinExe - net7.0-windows - true - true - icon.ico - Certify.UI.App - AnyCPU;x64 - True - - - - - - - - - - - + + WinExe + net9.0-windows + true + true + icon.ico + Certify.UI.App + AnyCPU + True + NU1701 + + + + + \ No newline at end of file diff --git a/src/Certify.UI.Shared/AssemblyInfo.cs b/src/Certify.UI.Shared/AssemblyInfo.cs index 8b5504ecf..f60efc019 100644 --- a/src/Certify.UI.Shared/AssemblyInfo.cs +++ b/src/Certify.UI.Shared/AssemblyInfo.cs @@ -1,4 +1,4 @@ -using System.Windows; +using System.Windows; [assembly: ThemeInfo( ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located diff --git a/src/Certify.UI.Shared/Certify.UI.Shared.csproj b/src/Certify.UI.Shared/Certify.UI.Shared.csproj index a8973f02b..293ad3a88 100644 --- a/src/Certify.UI.Shared/Certify.UI.Shared.csproj +++ b/src/Certify.UI.Shared/Certify.UI.Shared.csproj @@ -1,11 +1,12 @@ - net462;net7.0-windows; + net462;net9.0-windows; true true 6.0.0.* + NU1701 @@ -32,16 +33,19 @@ - - - - + + + + NU1701 + + - + - - - + + + NU1701 + diff --git a/src/Certify.UI.Shared/Controls/AboutControl.xaml b/src/Certify.UI.Shared/Controls/AboutControl.xaml index df6c73b89..72bd281b4 100644 --- a/src/Certify.UI.Shared/Controls/AboutControl.xaml +++ b/src/Certify.UI.Shared/Controls/AboutControl.xaml @@ -81,24 +81,16 @@ Click="UpdateCheck_Click" Content="{x:Static res:SR.AboutControl_CheckForUpdateButton}" DockPanel.Dock="Top" /> - + + + action) + { + Cancel(lastCancellationTokenSource); + + var tokenSrc = lastCancellationTokenSource = new CancellationTokenSource(); + + try + { + await Task.Delay(new TimeSpan(milliseconds), tokenSrc.Token); + if (!tokenSrc.IsCancellationRequested) + { + await Task.Run(action, tokenSrc.Token); + } + } + catch (TaskCanceledException) + { + } + } - if (lvManagedCertificates.SelectedIndex == -1 && _appViewModel.SelectedItem != null) + public void Cancel(CancellationTokenSource source) { - // if the data model's selected item has come into view after filter box text - // changed, select the item in the list - if (defaultView.Filter(_appViewModel.SelectedItem)) + if (source != null) { - lvManagedCertificates.SelectedItem = _appViewModel.SelectedItem; + source.Cancel(); + source.Dispose(); } } + + public void Dispose() + { + Cancel(lastCancellationTokenSource); + } + + ~Debouncer() + { + Dispose(); + } + } + + private Debouncer _filterDebouncer = new Debouncer(); + + private async void TxtFilter_TextChanged(object sender, TextChangedEventArgs e) + { + // refresh db results, then refresh UI view + + _appViewModel.FilterKeyword = txtFilter.Text; + + await _filterDebouncer.Debounce(_appViewModel.RefreshManagedCertificates); + + var defaultView = CollectionViewSource.GetDefaultView(lvManagedCertificates.ItemsSource); + + defaultView.Refresh(); + + /* if (lvManagedCertificates.SelectedIndex == -1 && _appViewModel.SelectedItem != null) + { + // if the data model's selected item has come into view after filter box text + // changed, select the item in the list + if (defaultView.Filter(_appViewModel.SelectedItem)) + { + lvManagedCertificates.SelectedItem = _appViewModel.SelectedItem; + } + }*/ } private async void TxtFilter_PreviewKeyDown(object sender, KeyEventArgs e) @@ -169,6 +228,8 @@ private async void TxtFilter_PreviewKeyDown(object sender, KeyEventArgs e) private void ResetFilter() { + _appViewModel.FilterKeyword = string.Empty; + txtFilter.Text = ""; txtFilter.Focus(); @@ -371,6 +432,16 @@ private void GettingStarted_FilterApplied(string filter) { txtFilter.Text = filter; } + + private async void Prev_Click(object sender, RoutedEventArgs e) + { + await _appViewModel.ManagedCertificatesPrevPage(); + } + + private async void Next_Click(object sender, RoutedEventArgs e) + { + await _appViewModel.ManagedCertificatesNextPage(); + } } public static class StringExtensions diff --git a/src/Certify.UI.Shared/Controls/Settings/CertificateAuthorities.xaml b/src/Certify.UI.Shared/Controls/Settings/CertificateAuthorities.xaml index bc1787558..293bd78a4 100644 --- a/src/Certify.UI.Shared/Controls/Settings/CertificateAuthorities.xaml +++ b/src/Certify.UI.Shared/Controls/Settings/CertificateAuthorities.xaml @@ -15,13 +15,12 @@ - - Certificate Authorities are the organisations who can issue trusted certificates. You need to register an account for each (ACME) Certificate Authority you wish to use. Accounts can either be Production (live, trusted certificates) or Staging (test, non-trusted). - + - - If you register with multiple authorities this may enable you to use automatic Certificate Authority Failover, so if your preferred Certificate Authority can't issue a new certificate an alternative compatible provider can be used automatically. - +
private void Init() { - Log = new Loggy( - new LoggerConfiguration() - .MinimumLevel.Verbose() - .WriteTo.Debug() + + var serilogLog = new Serilog.LoggerConfiguration() + .Enrich.FromLogContext() + .MinimumLevel.Verbose() .WriteTo.File(Path.Combine(EnvironmentUtil.CreateAppDataPath("logs"), "ui.log"), shared: true, flushToDiskInterval: new TimeSpan(0, 0, 10)) - .CreateLogger() - ); + .CreateLogger(); + + Log = new Loggy(new Serilog.Extensions.Logging.SerilogLoggerFactory(serilogLog).CreateLogger()); ProgressResults = new ObservableCollection(); @@ -108,7 +110,7 @@ public void StartProgressCleanupTask() foreach (var item in items.ToList()) { ProgressResults.Remove(item); - }; + } }); } } diff --git a/src/Certify.UI.Shared/Windows/EditAccountDialog.xaml b/src/Certify.UI.Shared/Windows/EditAccountDialog.xaml index 87b4137bc..348009eb4 100644 --- a/src/Certify.UI.Shared/Windows/EditAccountDialog.xaml +++ b/src/Certify.UI.Shared/Windows/EditAccountDialog.xaml @@ -7,9 +7,9 @@ xmlns:local="clr-namespace:Certify.UI.Windows" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:res="clr-namespace:Certify.Locales;assembly=Certify.Locales" - Title="Edit ACME Account" Width="592" Height="466" + Title="{x:Static res:SR.Account_Edit_SectionTitle}" ResizeMode="CanResizeWithGrip" TitleCharacterCasing="Normal" WindowStartupLocation="CenterOwner" @@ -40,7 +40,7 @@ VerticalAlignment="Top" DockPanel.Dock="Top" Style="{StaticResource Instructions}" - TextWrapping="Wrap"> + TextWrapping="Wrap"> + TextWrapping="Wrap"> diff --git a/src/Certify.UI.Shared/Windows/EditAccountDialog.xaml.cs b/src/Certify.UI.Shared/Windows/EditAccountDialog.xaml.cs index 080f736cb..b281ac576 100644 --- a/src/Certify.UI.Shared/Windows/EditAccountDialog.xaml.cs +++ b/src/Certify.UI.Shared/Windows/EditAccountDialog.xaml.cs @@ -8,7 +8,6 @@ using System.Windows.Data; using System.Windows.Input; using Certify.Models; -using Org.BouncyCastle.Asn1.Pkcs; using Org.BouncyCastle.Crypto.EC; using Org.BouncyCastle.Crypto.Parameters; using Org.BouncyCastle.Security; @@ -149,7 +148,7 @@ private async void Save_Click(object sender, RoutedEventArgs e) } else { - MessageBox.Show(Certify.Locales.SR.New_Contact_NeedAgree); + MessageBox.Show(Certify.Locales.SR.Account_Edit_AgreeConditions); } } diff --git a/src/Certify.UI.Shared/Windows/EditCertificateAuthority.xaml b/src/Certify.UI.Shared/Windows/EditCertificateAuthority.xaml index 3fc7669e9..1adac373d 100644 --- a/src/Certify.UI.Shared/Windows/EditCertificateAuthority.xaml +++ b/src/Certify.UI.Shared/Windows/EditCertificateAuthority.xaml @@ -51,7 +51,7 @@ Height="23" HorizontalAlignment="Left" VerticalAlignment="Top" - Controls:TextBoxHelper.Watermark="(Display Name for the Certificate Authority)" + Controls:TextBoxHelper.Watermark="{x:Static res:SR.EditCertificateAuthority_TitleHelp}" Text="{Binding Model.Item.Title}" TextWrapping="Wrap" /> @@ -88,7 +88,7 @@ Height="23" HorizontalAlignment="Left" VerticalAlignment="Top" - Controls:TextBoxHelper.Watermark="(Url for the production directory endpoint)" + Controls:TextBoxHelper.Watermark="{x:Static res:SR.EditCertificateAuthority_ProductionDirectoryHelp}" Text="{Binding Model.Item.ProductionAPIEndpoint}" TextWrapping="Wrap" /> diff --git a/src/Certify.UI.Shared/Windows/EditServerConnection.xaml b/src/Certify.UI.Shared/Windows/EditServerConnection.xaml index 50bc3e64d..baac89cd2 100644 --- a/src/Certify.UI.Shared/Windows/EditServerConnection.xaml +++ b/src/Certify.UI.Shared/Windows/EditServerConnection.xaml @@ -88,7 +88,7 @@ Width="120" Margin="0,0,8,0" VerticalAlignment="Top" - Content="Use https" /> + Content="Use Https" /> diff --git a/src/Certify.UI.Shared/Windows/ImportExport.xaml b/src/Certify.UI.Shared/Windows/ImportExport.xaml index e180c83ee..67b953e83 100644 --- a/src/Certify.UI.Shared/Windows/ImportExport.xaml +++ b/src/Certify.UI.Shared/Windows/ImportExport.xaml @@ -17,7 +17,7 @@ Import/Export Settings - You can create an export file to bundle all of the related settings and file for this instance together. Note: sensitive content is encrypted but you should not share this file with untrusted sources or use unsecured storage. + "{x:Static properties:SR.Settings_Export_Intro}" To import or export, you should specify a password to use for encryption/decryption: - - Export - Export a settings bundle including managed certificate settings, certificate files and encrypted credentials. - - - - Import - Import a settings bundle exported from another instance of the app. - - - + + + + Export a settings bundle including managed certificate settings, certificate files and encrypted credentials. + + + + + + Import a settings bundle exported from another instance of the app. + + + + - - + + + + + + - - + + - + + + - - diff --git a/src/Certify.UI.Shared/Windows/ImportExport.xaml.cs b/src/Certify.UI.Shared/Windows/ImportExport.xaml.cs index e73e58b04..c6c229066 100644 --- a/src/Certify.UI.Shared/Windows/ImportExport.xaml.cs +++ b/src/Certify.UI.Shared/Windows/ImportExport.xaml.cs @@ -3,9 +3,8 @@ using System.Linq; using System.Text; using System.Windows; - -using Certify.Config.Migration; using Certify.Models; +using Certify.Models.Config.Migration; using Certify.UI.Shared; using Microsoft.Win32; using Newtonsoft.Json; @@ -68,6 +67,7 @@ private async void Import_Click(object sender, RoutedEventArgs e) } Model.ImportSettings.OverwriteExisting = (OverwriteExisting.IsChecked == true); + Model.ImportSettings.IncludeDeployment = (IncludeDeployment.IsChecked == true); Model.ImportSettings.EncryptionSecret = txtSecret.Password; Model.InProgress = true; @@ -104,6 +104,10 @@ private async void CompleteImport_Click(object sender, RoutedEventArgs e) if (MessageBox.Show("Are you sure you wish to perform the import as shown in the preview? The import cannot be reverted once complete.", "Perform Import?", MessageBoxButton.YesNoCancel) == MessageBoxResult.Yes) { Model.InProgress = true; + + Model.ImportSettings.OverwriteExisting = (OverwriteExisting.IsChecked == true); + Model.ImportSettings.IncludeDeployment = (IncludeDeployment.IsChecked == true); + var results = await MainViewModel.PerformSettingsImport(Model.Package, Model.ImportSettings, false); PrepareImportSummary(false, results); diff --git a/src/Certify.UI.Shared/Windows/MainWindow.xaml.cs b/src/Certify.UI.Shared/Windows/MainWindow.xaml.cs index ae387491b..384778b2d 100644 --- a/src/Certify.UI.Shared/Windows/MainWindow.xaml.cs +++ b/src/Certify.UI.Shared/Windows/MainWindow.xaml.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.ComponentModel; using System.Diagnostics; diff --git a/src/Certify.UI.sln b/src/Certify.UI.sln index e202963f1..5c542e462 100644 --- a/src/Certify.UI.sln +++ b/src/Certify.UI.sln @@ -17,6 +17,7 @@ EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{12876723-F648-4E76-9242-110F5635A4B1}" ProjectSection(SolutionItems) = preProject .editorconfig = .editorconfig + ..\Directory.Build.props = ..\Directory.Build.props EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Shared", "Shared", "{9A70DEC0-70C4-42A7-B15A-647EC432E7F7}" diff --git a/src/Certify.UI/App.config b/src/Certify.UI/App.config index 6b8d63af8..496e2a49f 100644 --- a/src/Certify.UI/App.config +++ b/src/Certify.UI/App.config @@ -8,7 +8,7 @@ - + diff --git a/src/Certify.UI/App.xaml b/src/Certify.UI/App.xaml index 014fd7976..450fd8628 100644 --- a/src/Certify.UI/App.xaml +++ b/src/Certify.UI/App.xaml @@ -1,8 +1,8 @@ - @@ -53,7 +53,7 @@ - @@ -71,8 +71,8 @@ - - + + diff --git a/src/Certify.UI/Certify.UI.csproj b/src/Certify.UI/Certify.UI.csproj index 5e47bfd30..21be93571 100644 --- a/src/Certify.UI/Certify.UI.csproj +++ b/src/Certify.UI/Certify.UI.csproj @@ -45,6 +45,7 @@ AnyCPU + win-x64;win true bin\Release\ TRACE;ALPHA @@ -61,24 +62,6 @@ app.manifest - - bin\x64\Debug\ - DEBUG;TRACE - false - x64 - prompt - ..\CodeAnalysis.ruleset - true - - - bin\Release\ - TRACE - true - x64 - prompt - MinimumRecommendedRules.ruleset - full - @@ -150,7 +133,7 @@ - 6.8.0 + 6.9.1 runtime; build; native; contentfiles; analyzers all @@ -158,7 +141,7 @@ 4.7.0.9 - 2.4.10 + 3.0.0-alpha0492 0.5.0.1 @@ -176,11 +159,8 @@ 4.1.0 all - - 2.12.0 - - 7.0.2 + 9.0.1 4.3.4