diff --git a/README.md b/README.md index 2976600..08e4899 100644 --- a/README.md +++ b/README.md @@ -43,9 +43,9 @@ Application and System certs are used by NSX ALB for SSL offloading and require This integration is compatible with Keyfactor Universal Orchestrator version 10.1 and later. ## Support -The VMware NSX Advanced Load Balancer (Avi) Universal Orchestrator extension If you have a support issue, please open a support ticket by either contacting your Keyfactor representative or via the Keyfactor Support Portal at https://support.keyfactor.com. +The VMware NSX Advanced Load Balancer (Avi) Universal Orchestrator extension is supported by Keyfactor. If you require support for any issues or have feature request, please open a support ticket by either contacting your Keyfactor representative or via the Keyfactor Support Portal at https://support.keyfactor.com. -> To report a problem or suggest a new feature, use the **[Issues](../../issues)** tab. If you want to contribute actual bug fixes or proposed enhancements, use the **[Pull requests](../../pulls)** tab. +> If you want to contribute bug fixes or additional enhancements, use the **[Pull requests](../../pulls)** tab. ## Requirements & Prerequisites @@ -173,21 +173,51 @@ the Keyfactor Command Portal ![VMware-NSX Custom Fields Tab](docsource/images/VMware-NSX-custom-fields-store-type-dialog.png) + + ###### Server Username + The username of the user to log on as in VMware NSX ALB. + + + > [!IMPORTANT] + > This field is created by the `Needs Server` on the Basic tab, do not create this field manually. + + + + + ###### Server Password + The password of the user to log on as in VMware NSX ALB. + + + > [!IMPORTANT] + > This field is created by the `Needs Server` on the Basic tab, do not create this field manually. + + + + + ###### X-Avi-Version + The API Version of Avi / NSX to target. A default is set for the version this was originally developed and tested against. + + ![VMware-NSX Custom Field - ApiVersion](docsource/images/VMware-NSX-custom-field-ApiVersion-dialog.png) + ![VMware-NSX Custom Field - ApiVersion](docsource/images/VMware-NSX-custom-field-ApiVersion-validation-options-dialog.png) + + + + + ## Installation 1. **Download the latest VMware NSX Advanced Load Balancer (Avi) Universal Orchestrator extension from GitHub.** - Navigate to the [VMware NSX Advanced Load Balancer (Avi) Universal Orchestrator extension GitHub version page](https://github.com/Keyfactor/vmware-nsx-orchestrator/releases/latest). Refer to the compatibility matrix below to determine whether the `net6.0` or `net8.0` asset should be downloaded. Then, click the corresponding asset to download the zip archive. + Navigate to the [VMware NSX Advanced Load Balancer (Avi) Universal Orchestrator extension GitHub version page](https://github.com/Keyfactor/vmware-nsx-orchestrator/releases/latest). Refer to the compatibility matrix below to determine the asset should be downloaded. Then, click the corresponding asset to download the zip archive. | Universal Orchestrator Version | Latest .NET version installed on the Universal Orchestrator server | `rollForward` condition in `Orchestrator.runtimeconfig.json` | `vmware-nsx-orchestrator` .NET version to download | | --------- | ----------- | ----------- | ----------- | | Older than `11.0.0` | | | `net6.0` | | Between `11.0.0` and `11.5.1` (inclusive) | `net6.0` | | `net6.0` | - | Between `11.0.0` and `11.5.1` (inclusive) | `net8.0` | `Disable` | `net6.0` | - | Between `11.0.0` and `11.5.1` (inclusive) | `net8.0` | `LatestMajor` | `net8.0` | - | `11.6` _and_ newer | `net8.0` | | `net8.0` | + | Between `11.0.0` and `11.5.1` (inclusive) | `net8.0` | `Disable` | `net6.0` || Between `11.0.0` and `11.5.1` (inclusive) | `net8.0` | `LatestMajor` | `net8.0` | + | `11.6` _and_ newer | `net8.0` | | `net8.0` | Unzip the archive containing extension assemblies to a known location. diff --git a/scripts/store_types/bash/curl_create_store_types.sh b/scripts/store_types/bash/curl_create_store_types.sh new file mode 100755 index 0000000..00ad170 --- /dev/null +++ b/scripts/store_types/bash/curl_create_store_types.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash + +# Creates all 1 store types via the Keyfactor Command REST API using curl. +# +# Authentication (first matching method is used): +# OAuth access token: KEYFACTOR_AUTH_ACCESS_TOKEN +# OAuth client creds: KEYFACTOR_AUTH_CLIENT_ID + KEYFACTOR_AUTH_CLIENT_SECRET +# + KEYFACTOR_AUTH_TOKEN_URL +# Basic auth (AD): KEYFACTOR_USERNAME + KEYFACTOR_PASSWORD + KEYFACTOR_DOMAIN +# +# Always required: +# KEYFACTOR_HOSTNAME Command hostname (e.g. my-command.example.com) +# +# Auto-generated by doctool generate-store-type-scripts — do not edit by hand. + +if [ -z "${KEYFACTOR_HOSTNAME}" ]; then + echo "ERROR: KEYFACTOR_HOSTNAME is required" + exit 1 +fi + +BASE_URL="https://${KEYFACTOR_HOSTNAME}/keyfactorapi" + +# --------------------------------------------------------------------------- +# Resolve auth +# --------------------------------------------------------------------------- +if [ -n "${KEYFACTOR_AUTH_ACCESS_TOKEN}" ]; then + BEARER_TOKEN="${KEYFACTOR_AUTH_ACCESS_TOKEN}" +elif [ -n "${KEYFACTOR_AUTH_CLIENT_ID}" ] && [ -n "${KEYFACTOR_AUTH_CLIENT_SECRET}" ] && [ -n "${KEYFACTOR_AUTH_TOKEN_URL}" ]; then + echo "Fetching OAuth token..." + BEARER_TOKEN=$(curl -s -X POST "${KEYFACTOR_AUTH_TOKEN_URL}" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + --data-urlencode "grant_type=client_credentials" \ + --data-urlencode "client_id=${KEYFACTOR_AUTH_CLIENT_ID}" \ + --data-urlencode "client_secret=${KEYFACTOR_AUTH_CLIENT_SECRET}" | jq -r '.access_token') + if [ -z "${BEARER_TOKEN}" ] || [ "${BEARER_TOKEN}" = "null" ]; then + echo "ERROR: Failed to fetch OAuth token from ${KEYFACTOR_AUTH_TOKEN_URL}" + exit 1 + fi +elif [ -n "${KEYFACTOR_USERNAME}" ] && [ -n "${KEYFACTOR_PASSWORD}" ] && [ -n "${KEYFACTOR_DOMAIN}" ]; then + BEARER_TOKEN="" +else + echo "ERROR: Authentication required. Set one of:" + echo " KEYFACTOR_AUTH_ACCESS_TOKEN" + echo " KEYFACTOR_AUTH_CLIENT_ID + KEYFACTOR_AUTH_CLIENT_SECRET + KEYFACTOR_AUTH_TOKEN_URL" + echo " KEYFACTOR_USERNAME + KEYFACTOR_PASSWORD + KEYFACTOR_DOMAIN" + exit 1 +fi + +if [ -n "${BEARER_TOKEN}" ]; then + CURL_AUTH=("-H" "Authorization: Bearer ${BEARER_TOKEN}") +else + CURL_AUTH=("-u" "${KEYFACTOR_USERNAME}@${KEYFACTOR_DOMAIN}:${KEYFACTOR_PASSWORD}") +fi + +create_store_type() { + local name="$1" + local body="$2" + echo "Creating ${name} store type..." + response=$(curl -s -o /dev/null -w "%{http_code}" \ + -X POST "${BASE_URL}/certificatestoretypes" \ + -H "Content-Type: application/json" \ + -H "x-keyfactor-requested-with: APIClient" \ + "${CURL_AUTH[@]}" \ + -d "${body}") + if [ "$response" = "200" ] || [ "$response" = "201" ]; then + echo " OK (HTTP ${response})" + else + echo " FAILED (HTTP ${response})" + fi +} + +# --------------------------------------------------------------------------- +# VMware-NSX — This is the URL for the VMware NSX instance. It also includes an optional tenant in square brackets before the URL. A tenant value is required when the certificates being managed are in a different tenant from the default tenant set for the NSX User specified for the store. This should look like either: [optional-tenant-name]https://my.nsx.url/ OR https://my.nsx.url/ +# --------------------------------------------------------------------------- +create_store_type "VMware-NSX" '{ + "Name": "VMware-NSX", + "ShortName": "VMware-NSX", + "Capability": "VMware-NSX", + "LocalStore": false, + "SupportedOperations": { + "Add": true, + "Create": false, + "Discovery": false, + "Enrollment": false, + "Remove": true + }, + "Properties": [ + { + "Name": "ApiVersion", + "DisplayName": "X-Avi-Version", + "Type": "String", + "DependsOn": "", + "DefaultValue": "20.1.1", + "Required": true, + "IsPAMEligible": false + } + ], + "EntryParameters": [], + "PasswordOptions": { + "EntrySupported": false, + "StoreRequired": false, + "Style": "Default" + }, + "StorePathType": "MultipleChoice", + "StorePathValue": "[\"Application\",\"Controller\",\"CA\"]", + "PrivateKeyAllowed": "Optional", + "JobProperties": [], + "ServerRequired": true, + "PowerShell": false, + "BlueprintAllowed": false, + "CustomAliasAllowed": "Required", + "StorePathDescription": "A selection from the different certificate types supported: Application, Controller, or CA." +}' + + +echo "Completed." diff --git a/scripts/store_types/bash/kfutil_create_store_types.sh b/scripts/store_types/bash/kfutil_create_store_types.sh new file mode 100755 index 0000000..5419a62 --- /dev/null +++ b/scripts/store_types/bash/kfutil_create_store_types.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash + +# Creates all 1 store types using kfutil. +# kfutil reads definitions from the Keyfactor integration catalog. +# +# Auth environment variables (first matching method is used): +# OAuth access token: KEYFACTOR_AUTH_ACCESS_TOKEN +# OAuth client creds: KEYFACTOR_AUTH_CLIENT_ID + KEYFACTOR_AUTH_CLIENT_SECRET +# + KEYFACTOR_AUTH_TOKEN_URL +# Basic auth (AD): KEYFACTOR_HOSTNAME + KEYFACTOR_USERNAME + KEYFACTOR_PASSWORD +# + KEYFACTOR_DOMAIN +# +# Auto-generated by doctool generate-store-type-scripts — do not edit by hand. + +if ! command -v kfutil &> /dev/null; then + echo "kfutil could not be found. Please install kfutil" + echo "See https://github.com/Keyfactor/kfutil#quickstart" + exit 1 +fi + +if [ -z "$KEYFACTOR_HOSTNAME" ]; then + echo "KEYFACTOR_HOSTNAME not set — launching kfutil login" + kfutil login +fi + +kfutil store-types create --name "VMware-NSX" + +echo "Done. All store types created." diff --git a/scripts/store_types/powershell/kfutil_create_store_types.ps1 b/scripts/store_types/powershell/kfutil_create_store_types.ps1 new file mode 100644 index 0000000..7e8d0df --- /dev/null +++ b/scripts/store_types/powershell/kfutil_create_store_types.ps1 @@ -0,0 +1,29 @@ +# Creates all 1 store types using kfutil. +# kfutil reads definitions from the Keyfactor integration catalog. +# +# Auth environment variables (first matching method is used): +# OAuth access token: KEYFACTOR_AUTH_ACCESS_TOKEN +# OAuth client creds: KEYFACTOR_AUTH_CLIENT_ID + KEYFACTOR_AUTH_CLIENT_SECRET +# + KEYFACTOR_AUTH_TOKEN_URL +# Basic auth (AD): KEYFACTOR_HOSTNAME + KEYFACTOR_USERNAME + KEYFACTOR_PASSWORD +# + KEYFACTOR_DOMAIN +# +# Auto-generated by doctool generate-store-type-scripts — do not edit by hand. + +# Uncomment if kfutil is not in your PATH +# Set-Alias -Name kfutil -Value 'C:\Program Files\Keyfactor\kfutil\kfutil.exe' + +if ($null -eq (Get-Command "kfutil" -ErrorAction SilentlyContinue)) { + Write-Host "kfutil could not be found. Please install kfutil" + Write-Host "See https://github.com/Keyfactor/kfutil#quickstart" + exit 1 +} + +if (-not $env:KEYFACTOR_HOSTNAME) { + Write-Host "KEYFACTOR_HOSTNAME not set — launching kfutil login" + & kfutil login +} + +& kfutil store-types create --name "VMware-NSX" + +Write-Host "Done. All store types created." diff --git a/scripts/store_types/powershell/restmethod_create_store_types.ps1 b/scripts/store_types/powershell/restmethod_create_store_types.ps1 new file mode 100644 index 0000000..7082578 --- /dev/null +++ b/scripts/store_types/powershell/restmethod_create_store_types.ps1 @@ -0,0 +1,110 @@ +# Creates all 1 store types via the Keyfactor Command REST API +# using PowerShell Invoke-RestMethod. +# +# Authentication (first matching method is used): +# OAuth access token: KEYFACTOR_AUTH_ACCESS_TOKEN +# OAuth client creds: KEYFACTOR_AUTH_CLIENT_ID + KEYFACTOR_AUTH_CLIENT_SECRET +# + KEYFACTOR_AUTH_TOKEN_URL +# Basic auth (AD): KEYFACTOR_USERNAME + KEYFACTOR_PASSWORD + KEYFACTOR_DOMAIN +# +# Always required: +# KEYFACTOR_HOSTNAME Command hostname (e.g. my-command.example.com) +# +# Auto-generated by doctool generate-store-type-scripts — do not edit by hand. + +if (-not $env:KEYFACTOR_HOSTNAME) { + Write-Error "KEYFACTOR_HOSTNAME is required" + exit 1 +} + +$uri = "https://$($env:KEYFACTOR_HOSTNAME)/keyfactorapi/certificatestoretypes" +$headers = @{ + 'Content-Type' = "application/json" + 'x-keyfactor-requested-with' = "APIClient" +} + +# --------------------------------------------------------------------------- +# Resolve auth +# --------------------------------------------------------------------------- +if ($env:KEYFACTOR_AUTH_ACCESS_TOKEN) { + $headers['Authorization'] = "Bearer $($env:KEYFACTOR_AUTH_ACCESS_TOKEN)" +} elseif ($env:KEYFACTOR_AUTH_CLIENT_ID -and $env:KEYFACTOR_AUTH_CLIENT_SECRET -and $env:KEYFACTOR_AUTH_TOKEN_URL) { + Write-Host "Fetching OAuth token..." + $tokenBody = @{ + grant_type = 'client_credentials' + client_id = $env:KEYFACTOR_AUTH_CLIENT_ID + client_secret = $env:KEYFACTOR_AUTH_CLIENT_SECRET + } + $tokenResp = Invoke-RestMethod -Method Post -Uri $env:KEYFACTOR_AUTH_TOKEN_URL -Body $tokenBody + $headers['Authorization'] = "Bearer $($tokenResp.access_token)" +} elseif ($env:KEYFACTOR_USERNAME -and $env:KEYFACTOR_PASSWORD -and $env:KEYFACTOR_DOMAIN) { + $cred = [System.Convert]::ToBase64String( + [System.Text.Encoding]::ASCII.GetBytes( + "$($env:KEYFACTOR_USERNAME)@$($env:KEYFACTOR_DOMAIN):$($env:KEYFACTOR_PASSWORD)")) + $headers['Authorization'] = "Basic $cred" +} else { + Write-Error ("Authentication required. Set one of:`n" + + " KEYFACTOR_AUTH_ACCESS_TOKEN`n" + + " KEYFACTOR_AUTH_CLIENT_ID + KEYFACTOR_AUTH_CLIENT_SECRET + KEYFACTOR_AUTH_TOKEN_URL`n" + + " KEYFACTOR_USERNAME + KEYFACTOR_PASSWORD + KEYFACTOR_DOMAIN") + exit 1 +} + +function New-StoreType { + param([string]$Name, [string]$Body) + Write-Host "Creating $Name store type..." + try { + Invoke-RestMethod -Method Post -Uri $uri -Headers $headers -Body $Body -ContentType "application/json" | Out-Null + Write-Host " OK" + } catch { + Write-Warning " FAILED: $($_.Exception.Message)" + } +} + +# --------------------------------------------------------------------------- +# VMware-NSX — This is the URL for the VMware NSX instance. It also includes an optional tenant in square brackets before the URL. A tenant value is required when the certificates being managed are in a different tenant from the default tenant set for the NSX User specified for the store. This should look like either: [optional-tenant-name]https://my.nsx.url/ OR https://my.nsx.url/ +# --------------------------------------------------------------------------- +New-StoreType "VMware-NSX" @' +{ + "Name": "VMware-NSX", + "ShortName": "VMware-NSX", + "Capability": "VMware-NSX", + "LocalStore": false, + "SupportedOperations": { + "Add": true, + "Create": false, + "Discovery": false, + "Enrollment": false, + "Remove": true + }, + "Properties": [ + { + "Name": "ApiVersion", + "DisplayName": "X-Avi-Version", + "Type": "String", + "DependsOn": "", + "DefaultValue": "20.1.1", + "Required": true, + "IsPAMEligible": false + } + ], + "EntryParameters": [], + "PasswordOptions": { + "EntrySupported": false, + "StoreRequired": false, + "Style": "Default" + }, + "StorePathType": "MultipleChoice", + "StorePathValue": "[\"Application\",\"Controller\",\"CA\"]", + "PrivateKeyAllowed": "Optional", + "JobProperties": [], + "ServerRequired": true, + "PowerShell": false, + "BlueprintAllowed": false, + "CustomAliasAllowed": "Required", + "StorePathDescription": "A selection from the different certificate types supported: Application, Controller, or CA." +} +'@ + + +Write-Host "Completed." diff --git a/vmware-nsx-orchestrator.Tests/NsxClientTests.cs b/vmware-nsx-orchestrator.Tests/NsxClientTests.cs new file mode 100644 index 0000000..797ce76 --- /dev/null +++ b/vmware-nsx-orchestrator.Tests/NsxClientTests.cs @@ -0,0 +1,299 @@ + +// Copyright 2025 Keyfactor +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. +// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions +// and limitations under the License. + +// NsxClientTests.cs +// Unit tests for NsxClient against a mocked NSX ALB (Avi Vantage) API — no live controller +// needed. Covers login, the certificate CRUD surface, and session teardown (Dispose/Logout). + +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using Keyfactor.Extensions.Orchestrator.Vmware.Nsx.Models; +using Xunit; + +namespace Keyfactor.Extensions.Orchestrator.Vmware.Nsx.Tests +{ + public class NsxClientTests + { + // ------------------------------------------------------------------ + // Login + // ------------------------------------------------------------------ + + [Fact] + public void Constructor_LoginSucceeds_DoesNotThrow() + { + var mock = new NsxHttpMockBuilder().WithLogin(); + + var client = mock.BuildClient(); + + Assert.NotNull(client); + } + + [Fact] + public void Constructor_LoginRejected_ThrowsWithNsxErrorDetails() + { + // Reproduces the exact failure reported against a live NSX ALB Controller: + // a 401 with an "Invalid credentials" body, even though the credentials are correct + // (root cause: leaked, un-logged-out sessions tripping Avi's login/session limits). + var mock = new NsxHttpMockBuilder() + .WithLoginError(HttpStatusCode.Unauthorized, "{\"error\":\"Invalid credentials\"}"); + + var ex = Assert.Throws(() => mock.BuildClient()); + + Assert.Contains("Unauthorized", ex.Message); + Assert.Contains("Invalid credentials", ex.Message); + } + + // ------------------------------------------------------------------ + // GetAllCertificates (pagination) + // ------------------------------------------------------------------ + + [Fact] + public async System.Threading.Tasks.Task GetAllCertificates_SinglePage_ReturnsAllResults() + { + var mock = new NsxHttpMockBuilder() + .WithLogin() + .WithCertPage("SSL_CERTIFICATE_TYPE_VIRTUALSERVICE", 1, 25, new GetCertificateResponse + { + count = 2, + next = null, + results = new List + { + new SSLKeyAndCertificate { name = "cert-a", uuid = "uuid-a" }, + new SSLKeyAndCertificate { name = "cert-b", uuid = "uuid-b" } + } + }); + + var client = mock.BuildClient(); + + var result = await client.GetAllCertificates("SSL_CERTIFICATE_TYPE_VIRTUALSERVICE", 25); + + Assert.Equal(2, result.Count); + Assert.Contains(result, c => c.name == "cert-a"); + Assert.Contains(result, c => c.name == "cert-b"); + } + + [Fact] + public async System.Threading.Tasks.Task GetAllCertificates_MultiPage_AggregatesAcrossPages() + { + var mock = new NsxHttpMockBuilder() + .WithLogin() + .WithCertPage("SSL_CERTIFICATE_TYPE_VIRTUALSERVICE", 1, 2, new GetCertificateResponse + { + count = 3, + next = "https://nsx-alb.example.com/api/sslkeyandcertificate?page=2", + results = new List + { + new SSLKeyAndCertificate { name = "cert-a" }, + new SSLKeyAndCertificate { name = "cert-b" } + } + }) + .WithCertPage("SSL_CERTIFICATE_TYPE_VIRTUALSERVICE", 2, 2, new GetCertificateResponse + { + count = 3, + next = null, + results = new List + { + new SSLKeyAndCertificate { name = "cert-c" } + } + }); + + var client = mock.BuildClient(); + + var result = await client.GetAllCertificates("SSL_CERTIFICATE_TYPE_VIRTUALSERVICE", 2); + + Assert.Equal(3, result.Count); + Assert.Contains(result, c => c.name == "cert-c"); + } + + [Fact] + public async System.Threading.Tasks.Task GetAllCertificates_ServerError_Throws() + { + var mock = new NsxHttpMockBuilder() + .WithLogin() + .WithCertPageError("SSL_CERTIFICATE_TYPE_VIRTUALSERVICE", 1, 25, HttpStatusCode.InternalServerError); + + var client = mock.BuildClient(); + + await Assert.ThrowsAsync(() => client.GetAllCertificates("SSL_CERTIFICATE_TYPE_VIRTUALSERVICE", 25)); + } + + // ------------------------------------------------------------------ + // GetCertificateByName + // ------------------------------------------------------------------ + + [Fact] + public async System.Threading.Tasks.Task GetCertificateByName_Found_ReturnsCert() + { + var mock = new NsxHttpMockBuilder() + .WithLogin() + .WithGetCertByName("my-cert", new GetCertificateResponse + { + count = 1, + results = new List { new SSLKeyAndCertificate { name = "my-cert", uuid = "uuid-123" } } + }); + + var client = mock.BuildClient(); + + var result = await client.GetCertificateByName("my-cert"); + + Assert.Equal("uuid-123", result.uuid); + } + + [Fact] + public async System.Threading.Tasks.Task GetCertificateByName_NoMatch_Throws() + { + var mock = new NsxHttpMockBuilder() + .WithLogin() + .WithGetCertByName("missing-cert", new GetCertificateResponse + { + count = 0, + results = new List() + }); + + var client = mock.BuildClient(); + + // production code calls response.results.Single(), which throws when no match is found + await Assert.ThrowsAsync(() => client.GetCertificateByName("missing-cert")); + } + + [Fact] + public async System.Threading.Tasks.Task GetCertificateByName_ServerError_Throws() + { + var mock = new NsxHttpMockBuilder() + .WithLogin() + .WithGetCertByNameError("my-cert", HttpStatusCode.InternalServerError); + + var client = mock.BuildClient(); + + await Assert.ThrowsAsync(() => client.GetCertificateByName("my-cert")); + } + + // ------------------------------------------------------------------ + // AddCertificate + // ------------------------------------------------------------------ + + [Fact] + public async System.Threading.Tasks.Task AddCertificate_Success_ReturnsCert() + { + var mock = new NsxHttpMockBuilder() + .WithLogin() + .WithAddCertificate(HttpStatusCode.Created, new SSLKeyAndCertificate { name = "new-cert", uuid = "uuid-new" }); + + var client = mock.BuildClient(); + + var result = await client.AddCertificate(new SSLKeyAndCertificate { name = "new-cert" }); + + Assert.Equal("uuid-new", result.uuid); + } + + [Fact] + public async System.Threading.Tasks.Task AddCertificate_ServerError_Throws() + { + var mock = new NsxHttpMockBuilder() + .WithLogin() + .WithAddCertificate(HttpStatusCode.BadRequest); + + var client = mock.BuildClient(); + + await Assert.ThrowsAsync(() => client.AddCertificate(new SSLKeyAndCertificate { name = "new-cert" })); + } + + // ------------------------------------------------------------------ + // UpdateCertificate + // ------------------------------------------------------------------ + + [Fact] + public async System.Threading.Tasks.Task UpdateCertificate_Success_ReturnsCert() + { + var mock = new NsxHttpMockBuilder() + .WithLogin() + .WithUpdateCertificate("uuid-123", HttpStatusCode.OK, new SSLKeyAndCertificate { name = "updated-cert", uuid = "uuid-123" }); + + var client = mock.BuildClient(); + + var result = await client.UpdateCertificate("uuid-123", new SSLKeyAndCertificate { name = "updated-cert" }); + + Assert.Equal("updated-cert", result.name); + } + + [Fact] + public async System.Threading.Tasks.Task UpdateCertificate_ServerError_Throws() + { + var mock = new NsxHttpMockBuilder() + .WithLogin() + .WithUpdateCertificate("uuid-123", HttpStatusCode.InternalServerError); + + var client = mock.BuildClient(); + + await Assert.ThrowsAsync(() => client.UpdateCertificate("uuid-123", new SSLKeyAndCertificate { name = "x" })); + } + + // ------------------------------------------------------------------ + // DeleteCertificate + // ------------------------------------------------------------------ + + [Fact] + public async System.Threading.Tasks.Task DeleteCertificate_Success_ReturnsTrue() + { + var mock = new NsxHttpMockBuilder() + .WithLogin() + .WithDeleteCertificate("uuid-123", HttpStatusCode.OK); + + var client = mock.BuildClient(); + + var result = await client.DeleteCertificate("uuid-123"); + + Assert.True(result); + } + + [Fact] + public async System.Threading.Tasks.Task DeleteCertificate_ServerError_Throws() + { + var mock = new NsxHttpMockBuilder() + .WithLogin() + .WithDeleteCertificate("uuid-123", HttpStatusCode.NotFound); + + var client = mock.BuildClient(); + + await Assert.ThrowsAsync(() => client.DeleteCertificate("uuid-123")); + } + + // ------------------------------------------------------------------ + // Dispose / Logout — the session lifecycle this project's login-failure + // bug fix depends on (see NsxJobDisposalTests for the job-level contract). + // ------------------------------------------------------------------ + + [Fact] + public void Dispose_LogoutSucceeds_DoesNotThrow() + { + var mock = new NsxHttpMockBuilder() + .WithLogin() + .WithLogout(); + + var client = mock.BuildClient(); + + client.Dispose(); + } + + [Fact] + public void Dispose_LogoutRejected_ThrowsLogoutFailedException() + { + var mock = new NsxHttpMockBuilder() + .WithLogin() + .WithLogoutError(HttpStatusCode.InternalServerError); + + var client = mock.BuildClient(); + + var ex = Assert.Throws(() => client.Dispose()); + + Assert.Equal("Logout Failed", ex.Message); + } + } +} diff --git a/vmware-nsx-orchestrator.Tests/NsxHttpMockBuilder.cs b/vmware-nsx-orchestrator.Tests/NsxHttpMockBuilder.cs new file mode 100644 index 0000000..d33c914 --- /dev/null +++ b/vmware-nsx-orchestrator.Tests/NsxHttpMockBuilder.cs @@ -0,0 +1,129 @@ + +// Copyright 2025 Keyfactor +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. +// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions +// and limitations under the License. + +// NsxHttpMockBuilder.cs +// Registers mock responses for the NSX ALB (Avi Vantage) endpoints NsxClient calls, +// and builds an NsxClient wired to them via TestableNsxClient. + +using System.Net; +using System.Net.Http; +using System.Text.Json; +using Keyfactor.Extensions.Orchestrator.Vmware.Nsx.Models; +using Keyfactor.Logging; +using Microsoft.Extensions.Logging; +using RichardSzalay.MockHttp; + +namespace Keyfactor.Extensions.Orchestrator.Vmware.Nsx.Tests +{ + internal class NsxHttpMockBuilder + { + // Mirrors NsxClient's own options: the model classes expose public fields, not + // properties, so IncludeFields is required for System.Text.Json to see them. + private static readonly JsonSerializerOptions SerializerOptions = new JsonSerializerOptions { IncludeFields = true }; + + private readonly MockHttpMessageHandler _handler = new MockHttpMessageHandler(); + private readonly string _baseUrl; + + public NsxHttpMockBuilder(string baseUrl = "https://nsx-alb.example.com/") + { + _baseUrl = baseUrl.EndsWith("/") ? baseUrl : baseUrl + "/"; + } + + public MockHttpMessageHandler Handler => _handler; + + public NsxHttpMockBuilder WithLogin(HttpStatusCode status = HttpStatusCode.OK) + { + _handler.When(HttpMethod.Post, $"{_baseUrl}login") + .Respond(status, "application/json", "{}"); + return this; + } + + public NsxHttpMockBuilder WithLoginError(HttpStatusCode status, string body = "{\"error\":\"Invalid credentials\"}") + { + _handler.When(HttpMethod.Post, $"{_baseUrl}login") + .Respond(status, "application/json", body); + return this; + } + + public NsxHttpMockBuilder WithLogout(HttpStatusCode status = HttpStatusCode.OK) + { + _handler.When(HttpMethod.Post, $"{_baseUrl}logout") + .Respond(status, "application/json", "{}"); + return this; + } + + public NsxHttpMockBuilder WithLogoutError(HttpStatusCode status, string body = "{\"error\":\"session already expired\"}") + { + _handler.When(HttpMethod.Post, $"{_baseUrl}logout") + .Respond(status, "application/json", body); + return this; + } + + public NsxHttpMockBuilder WithCertPage(string certType, int page, int pageSize, GetCertificateResponse response) + { + _handler.When(HttpMethod.Get, $"{_baseUrl}api/sslkeyandcertificate") + .WithQueryString("type", certType) + .WithQueryString("page", page.ToString()) + .WithQueryString("page_size", pageSize.ToString()) + .Respond(HttpStatusCode.OK, "application/json", JsonSerializer.Serialize(response, SerializerOptions)); + return this; + } + + public NsxHttpMockBuilder WithCertPageError(string certType, int page, int pageSize, HttpStatusCode status) + { + _handler.When(HttpMethod.Get, $"{_baseUrl}api/sslkeyandcertificate") + .WithQueryString("type", certType) + .WithQueryString("page", page.ToString()) + .WithQueryString("page_size", pageSize.ToString()) + .Respond(status, "application/json", "{\"error\":\"server error\"}"); + return this; + } + + public NsxHttpMockBuilder WithGetCertByName(string name, GetCertificateResponse response) + { + _handler.When(HttpMethod.Get, $"{_baseUrl}api/sslkeyandcertificate") + .WithQueryString("name", name) + .Respond(HttpStatusCode.OK, "application/json", JsonSerializer.Serialize(response, SerializerOptions)); + return this; + } + + public NsxHttpMockBuilder WithGetCertByNameError(string name, HttpStatusCode status) + { + _handler.When(HttpMethod.Get, $"{_baseUrl}api/sslkeyandcertificate") + .WithQueryString("name", name) + .Respond(status, "application/json", "{\"error\":\"server error\"}"); + return this; + } + + public NsxHttpMockBuilder WithAddCertificate(HttpStatusCode status, SSLKeyAndCertificate returned = null) + { + _handler.When(HttpMethod.Post, $"{_baseUrl}api/sslkeyandcertificate") + .Respond(status, "application/json", JsonSerializer.Serialize(returned ?? new SSLKeyAndCertificate(), SerializerOptions)); + return this; + } + + public NsxHttpMockBuilder WithUpdateCertificate(string uuid, HttpStatusCode status, SSLKeyAndCertificate returned = null) + { + _handler.When(HttpMethod.Put, $"{_baseUrl}api/sslkeyandcertificate/{uuid}") + .Respond(status, "application/json", JsonSerializer.Serialize(returned ?? new SSLKeyAndCertificate(), SerializerOptions)); + return this; + } + + public NsxHttpMockBuilder WithDeleteCertificate(string uuid, HttpStatusCode status) + { + _handler.When(HttpMethod.Delete, $"{_baseUrl}api/sslkeyandcertificate/{uuid}") + .Respond(status, "application/json", "{}"); + return this; + } + + public NsxClient BuildClient(ILogger logger = null, string username = "svc-keyfactor", string password = "password", string tenant = null) + { + return TestableNsxClient.Create(_handler, logger ?? LogHandler.GetClassLogger(), _baseUrl, username, password, tenant); + } + } +} diff --git a/vmware-nsx-orchestrator.Tests/NsxJobDisposalTests.cs b/vmware-nsx-orchestrator.Tests/NsxJobDisposalTests.cs new file mode 100644 index 0000000..20dbcc5 --- /dev/null +++ b/vmware-nsx-orchestrator.Tests/NsxJobDisposalTests.cs @@ -0,0 +1,84 @@ + +// Copyright 2025 Keyfactor +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. +// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions +// and limitations under the License. + +// NsxJobDisposalTests.cs +// Regression tests for the NSX ALB session-leak fix: every job must dispose (and therefore +// log out) its NsxClient, and a failed logout must never propagate and mask the job's result. +// +// ProcessJob's own Initialize() call always attempts a real login before the try/finally that +// now calls DisposeClient(), so it can't be exercised end-to-end without a network seam that +// doesn't exist in production. Instead — matching how the sibling vmware-vcenter-orchestrator +// test suite handles the same constraint — these tests drive NsxJob.DisposeClient() directly +// against a mock-backed client, which is the exact code path the fix added. + +using System.Net; +using Keyfactor.Extensions.Orchestrator.Vmware.Nsx.Jobs; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace Keyfactor.Extensions.Orchestrator.Vmware.Nsx.Tests +{ + public class NsxJobDisposalTests + { + [Fact] + public void DisposeClient_LogoutSucceeds_LogsOutWithoutWarning() + { + var mock = new NsxHttpMockBuilder().WithLogin().WithLogout(); + var logger = new RecordingLogger(); + var client = mock.BuildClient(logger); + + var inventory = new Inventory(null); + ReflectionHelpers.SetField(inventory, typeof(NsxJob), "_logger", logger); + ReflectionHelpers.SetProperty(inventory, typeof(NsxJob), "Client", client); + + ReflectionHelpers.Invoke(inventory, typeof(NsxJob), "DisposeClient"); + + Assert.DoesNotContain(logger.Entries, e => e.Level == LogLevel.Warning); + } + + [Fact] + public void DisposeClient_LogoutRejectedByController_SwallowsExceptionAndLogsWarning() + { + // This is the exact regression the fix guards against: NsxClient.Dispose() re-throws + // when the NSX ALB logout call itself fails (e.g. the session already expired + // server-side). Before the fix, nothing called Dispose()/Logout() at all, so + // sessions leaked and accumulated against the service account until Avi's + // session/login limits started intermittently rejecting logins with valid + // credentials. Now that jobs call DisposeClient() in a finally block, a failed + // logout must never propagate and mask the job's actual result. + var mock = new NsxHttpMockBuilder() + .WithLogin() + .WithLogoutError(HttpStatusCode.InternalServerError); + var logger = new RecordingLogger(); + var client = mock.BuildClient(logger); + + var management = new Management(null); + ReflectionHelpers.SetField(management, typeof(NsxJob), "_logger", logger); + ReflectionHelpers.SetProperty(management, typeof(NsxJob), "Client", client); + + // Should not throw despite the mocked logout call failing. + ReflectionHelpers.Invoke(management, typeof(NsxJob), "DisposeClient"); + + Assert.Contains(logger.Entries, e => e.Level == LogLevel.Warning && e.Message.Contains("Failed to log out")); + } + + [Fact] + public void DisposeClient_ClientNeverInitialized_DoesNotThrow() + { + // Initialize() never assigns Client when login itself fails, so DisposeClient() + // must tolerate a null Client instead of throwing a NullReferenceException. + var logger = new RecordingLogger(); + var inventory = new Inventory(null); + ReflectionHelpers.SetField(inventory, typeof(NsxJob), "_logger", logger); + + ReflectionHelpers.Invoke(inventory, typeof(NsxJob), "DisposeClient"); + + Assert.DoesNotContain(logger.Entries, e => e.Level == LogLevel.Warning); + } + } +} diff --git a/vmware-nsx-orchestrator.Tests/RecordingLogger.cs b/vmware-nsx-orchestrator.Tests/RecordingLogger.cs new file mode 100644 index 0000000..3c2e434 --- /dev/null +++ b/vmware-nsx-orchestrator.Tests/RecordingLogger.cs @@ -0,0 +1,38 @@ + +// Copyright 2025 Keyfactor +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. +// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions +// and limitations under the License. + +// RecordingLogger.cs +// A minimal ILogger that records what was logged, so tests can assert a warning was +// (or wasn't) emitted without depending on a real logging backend. + +using System; +using System.Collections.Generic; +using Microsoft.Extensions.Logging; + +namespace Keyfactor.Extensions.Orchestrator.Vmware.Nsx.Tests +{ + internal class RecordingLogger : ILogger + { + public List<(LogLevel Level, string Message)> Entries { get; } = new List<(LogLevel, string)>(); + + public IDisposable BeginScope(TState state) => NullScope.Instance; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func formatter) + { + Entries.Add((logLevel, formatter(state, exception))); + } + + private class NullScope : IDisposable + { + public static readonly NullScope Instance = new NullScope(); + public void Dispose() { } + } + } +} diff --git a/vmware-nsx-orchestrator.Tests/ReflectionHelpers.cs b/vmware-nsx-orchestrator.Tests/ReflectionHelpers.cs new file mode 100644 index 0000000..d4e160d --- /dev/null +++ b/vmware-nsx-orchestrator.Tests/ReflectionHelpers.cs @@ -0,0 +1,59 @@ + +// Copyright 2025 Keyfactor +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. +// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions +// and limitations under the License. + +// ReflectionHelpers.cs +// NsxClient and NsxJob hard-construct their own HttpClient / cookie state and expose no +// test seams (private fields, get-only auto-properties, private/private-protected methods). +// Rather than change production code just to make it testable, these helpers reach past +// accessibility modifiers the same way the sibling vmware-vcenter-orchestrator test suite does. + +using System; +using System.Reflection; + +namespace Keyfactor.Extensions.Orchestrator.Vmware.Nsx.Tests +{ + internal static class ReflectionHelpers + { + private const BindingFlags InstanceNonPublic = BindingFlags.NonPublic | BindingFlags.Instance; + + public static void SetField(object target, Type declaringType, string fieldName, object value) + { + var field = declaringType.GetField(fieldName, InstanceNonPublic) + ?? throw new InvalidOperationException($"Could not find field '{fieldName}' on {declaringType.Name}."); + field.SetValue(target, value); + } + + public static void SetBackingField(object target, Type declaringType, string autoPropertyName, object value) + { + SetField(target, declaringType, $"<{autoPropertyName}>k__BackingField", value); + } + + public static void SetProperty(object target, Type declaringType, string propertyName, object value) + { + var property = declaringType.GetProperty(propertyName, InstanceNonPublic) + ?? throw new InvalidOperationException($"Could not find property '{propertyName}' on {declaringType.Name}."); + property.SetValue(target, value); + } + + public static object Invoke(object target, Type declaringType, string methodName, params object[] args) + { + var method = declaringType.GetMethod(methodName, InstanceNonPublic) + ?? throw new InvalidOperationException($"Could not find method '{methodName}' on {declaringType.Name}."); + try + { + return method.Invoke(target, args); + } + catch (TargetInvocationException ex) when (ex.InnerException != null) + { + // Unwrap so callers see (and can assert against) the real exception the + // production method threw, not reflection's wrapper. + throw ex.InnerException; + } + } + } +} diff --git a/vmware-nsx-orchestrator.Tests/TestableNsxClient.cs b/vmware-nsx-orchestrator.Tests/TestableNsxClient.cs new file mode 100644 index 0000000..dd3c3b4 --- /dev/null +++ b/vmware-nsx-orchestrator.Tests/TestableNsxClient.cs @@ -0,0 +1,93 @@ + +// Copyright 2025 Keyfactor +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. +// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions +// and limitations under the License. + +// TestableNsxClient.cs +// A factory that produces an NsxClient whose HTTP traffic is routed through a +// MockHttpMessageHandler (RichardSzalay.MockHttp), so tests never touch the network. +// +// NsxClient's constructor hard-constructs its own HttpClientHandler/HttpClient and +// immediately performs a real login POST — there's no injection seam. To intercept that +// without changing production code, this factory: +// 1. Allocates an NsxClient without running its constructor (RuntimeHelpers.GetUninitializedObject). +// 2. Injects a mock-backed HttpClient via the private HttpClient/HttpHandler backing fields. +// 3. Invokes the real private Login(username, password) method via reflection, so the +// actual production auth/error-handling code path (and its exact exception text) runs. +// 4. Seeds the CSRF cookie the real constructor would have captured automatically from the +// login response's Set-Cookie header — our CookieContainer never sees that header +// because the mock handler bypasses HttpClientHandler's cookie processing. + +using System; +using System.Net; +using System.Net.Http; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; + +namespace Keyfactor.Extensions.Orchestrator.Vmware.Nsx.Tests +{ + internal static class TestableNsxClient + { + public static NsxClient Create( + HttpMessageHandler mockHandler, + ILogger logger, + string baseUrl, + string username = "svc-keyfactor", + string password = "password", + string tenant = null, + string apiVersion = null, + string csrfToken = "fake-csrf-token") + { + if (!baseUrl.EndsWith("/", StringComparison.Ordinal)) + { + baseUrl += "/"; + } + + var cookieContainer = new CookieContainer(); + var handler = new HttpClientHandler { CookieContainer = cookieContainer }; + + var httpClient = new HttpClient(mockHandler) { BaseAddress = new Uri(baseUrl) }; + httpClient.DefaultRequestHeaders.Add("X-Avi-Version", apiVersion ?? "20.1.1"); + if (tenant != null) + { + httpClient.DefaultRequestHeaders.Add("X-Avi-Tenant", tenant); + } + + var client = (NsxClient)RuntimeHelpers.GetUninitializedObject(typeof(NsxClient)); + + // GetUninitializedObject skips every constructor, so NsxClient's own field + // initializer for serializerOptions never runs. Without this, it stays null, + // and JsonSerializer treats a null options argument as "use the defaults" — + // silently deserializing every field-based model back to a blank instance. + ReflectionHelpers.SetField(client, typeof(NsxClient), "serializerOptions", new JsonSerializerOptions + { + IncludeFields = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }); + + ReflectionHelpers.SetField(client, typeof(NsxClient), "_logger", logger); + ReflectionHelpers.SetBackingField(client, typeof(NsxClient), "HttpHandler", handler); + ReflectionHelpers.SetBackingField(client, typeof(NsxClient), "HttpClient", httpClient); + ReflectionHelpers.SetBackingField(client, typeof(NsxClient), "BaseUrl", baseUrl); + + // Throws the real production exception (from EnsureSuccessfulResponse) if the + // mocked login endpoint responds with a non-success status. + ReflectionHelpers.Invoke(client, typeof(NsxClient), "Login", username, password); + + var loginUri = new Uri(baseUrl + "login"); + cookieContainer.Add(loginUri, new Cookie("csrftoken", csrfToken)); + var loginCookies = cookieContainer.GetCookies(loginUri); + ReflectionHelpers.SetBackingField(client, typeof(NsxClient), "LoginCookies", loginCookies); + + httpClient.DefaultRequestHeaders.Add("X-CSRFToken", loginCookies["csrftoken"].Value); + httpClient.DefaultRequestHeaders.Add("Referer", httpClient.BaseAddress.OriginalString); + + return client; + } + } +} diff --git a/vmware-nsx-orchestrator.Tests/vmware-nsx-orchestrator.Tests.csproj b/vmware-nsx-orchestrator.Tests/vmware-nsx-orchestrator.Tests.csproj new file mode 100644 index 0000000..3065093 --- /dev/null +++ b/vmware-nsx-orchestrator.Tests/vmware-nsx-orchestrator.Tests.csproj @@ -0,0 +1,40 @@ + + + + net8.0 + Keyfactor.Extensions.Orchestrator.Vmware.Nsx.Tests + disable + disable + false + true + latest + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + + + + diff --git a/vmware-nsx-orchestrator.sln b/vmware-nsx-orchestrator.sln index d9b3350..17396e4 100644 --- a/vmware-nsx-orchestrator.sln +++ b/vmware-nsx-orchestrator.sln @@ -11,16 +11,42 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution vmware-nsx-orchestrator\vmware-nsx-orchestrator.licenseheader = vmware-nsx-orchestrator\vmware-nsx-orchestrator.licenseheader EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "vmware-nsx-orchestrator.Tests", "vmware-nsx-orchestrator.Tests\vmware-nsx-orchestrator.Tests.csproj", "{32BB237D-1FCB-48B8-9E2F-769520BE301F}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {85C0BE86-FB6B-4350-BE7B-949EC0DDCEF0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {85C0BE86-FB6B-4350-BE7B-949EC0DDCEF0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {85C0BE86-FB6B-4350-BE7B-949EC0DDCEF0}.Debug|x64.ActiveCfg = Debug|Any CPU + {85C0BE86-FB6B-4350-BE7B-949EC0DDCEF0}.Debug|x64.Build.0 = Debug|Any CPU + {85C0BE86-FB6B-4350-BE7B-949EC0DDCEF0}.Debug|x86.ActiveCfg = Debug|Any CPU + {85C0BE86-FB6B-4350-BE7B-949EC0DDCEF0}.Debug|x86.Build.0 = Debug|Any CPU {85C0BE86-FB6B-4350-BE7B-949EC0DDCEF0}.Release|Any CPU.ActiveCfg = Release|Any CPU {85C0BE86-FB6B-4350-BE7B-949EC0DDCEF0}.Release|Any CPU.Build.0 = Release|Any CPU + {85C0BE86-FB6B-4350-BE7B-949EC0DDCEF0}.Release|x64.ActiveCfg = Release|Any CPU + {85C0BE86-FB6B-4350-BE7B-949EC0DDCEF0}.Release|x64.Build.0 = Release|Any CPU + {85C0BE86-FB6B-4350-BE7B-949EC0DDCEF0}.Release|x86.ActiveCfg = Release|Any CPU + {85C0BE86-FB6B-4350-BE7B-949EC0DDCEF0}.Release|x86.Build.0 = Release|Any CPU + {32BB237D-1FCB-48B8-9E2F-769520BE301F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {32BB237D-1FCB-48B8-9E2F-769520BE301F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {32BB237D-1FCB-48B8-9E2F-769520BE301F}.Debug|x64.ActiveCfg = Debug|Any CPU + {32BB237D-1FCB-48B8-9E2F-769520BE301F}.Debug|x64.Build.0 = Debug|Any CPU + {32BB237D-1FCB-48B8-9E2F-769520BE301F}.Debug|x86.ActiveCfg = Debug|Any CPU + {32BB237D-1FCB-48B8-9E2F-769520BE301F}.Debug|x86.Build.0 = Debug|Any CPU + {32BB237D-1FCB-48B8-9E2F-769520BE301F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {32BB237D-1FCB-48B8-9E2F-769520BE301F}.Release|Any CPU.Build.0 = Release|Any CPU + {32BB237D-1FCB-48B8-9E2F-769520BE301F}.Release|x64.ActiveCfg = Release|Any CPU + {32BB237D-1FCB-48B8-9E2F-769520BE301F}.Release|x64.Build.0 = Release|Any CPU + {32BB237D-1FCB-48B8-9E2F-769520BE301F}.Release|x86.ActiveCfg = Release|Any CPU + {32BB237D-1FCB-48B8-9E2F-769520BE301F}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/vmware-nsx-orchestrator/Jobs/Inventory.cs b/vmware-nsx-orchestrator/Jobs/Inventory.cs index 47a255d..af7fb12 100644 --- a/vmware-nsx-orchestrator/Jobs/Inventory.cs +++ b/vmware-nsx-orchestrator/Jobs/Inventory.cs @@ -1,149 +1,166 @@ - -// Copyright 2025 Keyfactor -// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. -// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 -// Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions -// and limitations under the License. - -using Keyfactor.Extensions.Orchestrator.Vmware.Nsx.Models; -using Keyfactor.Logging; -using Keyfactor.Orchestrators.Extensions; -using Keyfactor.Orchestrators.Extensions.Interfaces; -using Microsoft.Extensions.Logging; -using Org.BouncyCastle.Utilities.IO.Pem; -using System; -using System.Collections.Generic; -using System.IO; -using PemWriter = Org.BouncyCastle.OpenSsl.PemWriter; - -namespace Keyfactor.Extensions.Orchestrator.Vmware.Nsx.Jobs -{ - public class Inventory : NsxJob, IInventoryJobExtension - { - private const int PAGE_SIZE = 2; - - public Inventory(IPAMSecretResolver pam) - { - _logger = LogHandler.GetClassLogger(); - _pam = pam; - } - - public JobResult ProcessJob(InventoryJobConfiguration config, SubmitInventoryUpdate submitInventory) - { - string clientMachine = ParseClientMachineUrl(config.CertificateStoreDetails.ClientMachine, out string tenant); - - Initialize(clientMachine, config, config.CertificateStoreDetails, tenant); - List allCerts; - List inventory = new List(); - - string certType = GetCertType(config.CertificateStoreDetails.StorePath); - try - { - allCerts = Client.GetAllCertificates(certType, PAGE_SIZE).Result; - } - catch (Exception ex) - { - return ThrowError(ex, "Certificate Retrieval"); - } - - _logger.LogDebug($"Total certificates found of type {certType} - {allCerts.Count}"); - var warningCount = 0; - - foreach (var foundCert in allCerts) - { - _logger.LogTrace($"Found Certificate - {foundCert.name}"); - - // the below check is in place to prevent an error on older versions of the UO framework - // when parsing PEM data with extra text before wrapper tags. - - #region checkPEMformat - - try - { - // try pemtoder - var test = PKI.PEM.PemUtilities.PEMToDER(foundCert.certificate.certificate); - } - catch (Exception ex) - { - // it failed, attempt cleanup. - - _logger.LogWarning("Unable to perform PEM to DER conversion on cert contents."); - - // try cleanup up extra info - - var cleanPEM = CleanPEMString(foundCert.certificate.certificate); - - try - { - var test = PKI.PEM.PemUtilities.PEMToDER(cleanPEM); - // success if no exception. - - inventory.Add(new CurrentInventoryItem() - { - Alias = foundCert.name, - Certificates = new string[] { cleanPEM }, - PrivateKeyEntry = !string.IsNullOrEmpty(foundCert.key), - UseChainLevel = false - }); - - continue; - } - catch - { - _logger.LogWarning($"still failing to parse, skipping this one ({foundCert.name}) and continuing with inventory."); - warningCount++; - continue; - } - } - - #endregion - - inventory.Add(new CurrentInventoryItem() - { - Alias = foundCert.name, - Certificates = new string[] { foundCert.certificate.certificate }, - PrivateKeyEntry = !string.IsNullOrEmpty(foundCert.key), - UseChainLevel = false - }); - } - - var successMessage = $"Successfully processed {inventory.Count} certificates. "; - if (warningCount > 0) successMessage += $"\n{warningCount} certificate(s) could not be processed.\nReview the logs on the orchestrator for more details."; - if (submitInventory.Invoke(inventory)) return Success(successMessage); - return ThrowError(new Exception("Inventory Job Failed. Review the orchestrator logs for more details."), "Inventory"); - } - - /// - /// This method does a preliminary check to circumvent the UO framework error when parsing headers - /// - /// - /// If - /// - string CleanPEMString(string dirtyPEM) - { - _logger.LogWarning("attempting to clean failing PEM string"); - _logger.LogWarning("original cert contents:"); - _logger.LogWarning($"\n{dirtyPEM}"); - - using (var sr = new StringReader(dirtyPEM)) - { - Org.BouncyCastle.OpenSsl.PemReader pemReader = new Org.BouncyCastle.OpenSsl.PemReader(sr); - - var pemObj = pemReader.ReadPemObject(); - - _logger.LogWarning("Unable to perform PEM to DER conversion on cert contents."); - _logger.LogWarning("cert contents:"); - _logger.LogWarning($"\n{dirtyPEM}"); - - PemObject po = new PemObject("CERTIFICATE", pemObj.Content); - _logger.LogTrace("content (without comments): "); - var sw = new StringWriter(); - var pw = new PemWriter(sw); - pw.WriteObject(po); - _logger.LogTrace($"{sw.ToString()}"); - return sw.ToString(); - } - } - } -} + +// Copyright 2025 Keyfactor +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. +// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions +// and limitations under the License. + +using Keyfactor.Extensions.Orchestrator.Vmware.Nsx.Models; +using Keyfactor.Logging; +using Keyfactor.Orchestrators.Extensions; +using Keyfactor.Orchestrators.Extensions.Interfaces; +using Microsoft.Extensions.Logging; +using Org.BouncyCastle.Utilities.IO.Pem; +using System; +using System.Collections.Generic; +using System.IO; +using PemWriter = Org.BouncyCastle.OpenSsl.PemWriter; + +namespace Keyfactor.Extensions.Orchestrator.Vmware.Nsx.Jobs +{ + public class Inventory : NsxJob, IInventoryJobExtension + { + private const int PAGE_SIZE = 2; + + public Inventory(IPAMSecretResolver pam) + { + _logger = LogHandler.GetClassLogger(); + _pam = pam; + } + + public JobResult ProcessJob(InventoryJobConfiguration config, SubmitInventoryUpdate submitInventory) + { + string clientMachine = ParseClientMachineUrl(config.CertificateStoreDetails.ClientMachine, out string tenant); + + Initialize(clientMachine, config, config.CertificateStoreDetails, tenant); + try + { + List allCerts; + List inventory = new List(); + + string certType = GetCertType(config.CertificateStoreDetails.StorePath); + try + { + allCerts = Client.GetAllCertificates(certType, PAGE_SIZE).Result; + } + catch (Exception ex) + { + return ThrowError(ex, "Certificate Retrieval"); + } + + _logger.LogDebug($"Total certificates found of type {certType} - {allCerts.Count}"); + var warningCount = 0; + + foreach (var foundCert in allCerts) + { + _logger.LogTrace($"Found Certificate - {foundCert.name}"); + + // the below check is in place to prevent an error on older versions of the UO framework + // when parsing PEM data with extra text before wrapper tags. + + #region checkPEMformat + var certString = foundCert.certificate?.certificate; + try + { + + // if the contents are empty; log and continue + if (string.IsNullOrEmpty(certString)) + { + _logger.LogWarning($"the contents of {foundCert.name} are empty. The status returned is: {foundCert.status}"); + warningCount++; + } + + // try pemtoder + + var test = PKI.PEM.PemUtilities.PEMToDER(certString); + } + catch (Exception ex) + { + // it failed, attempt cleanup. + + _logger.LogWarning("Unable to perform PEM to DER conversion on cert contents."); + + // try cleanup up extra info + + var cleanPEM = CleanPEMString(foundCert.certificate?.certificate); + + try + { + var test = PKI.PEM.PemUtilities.PEMToDER(cleanPEM); + // success if no exception. + + inventory.Add(new CurrentInventoryItem() + { + Alias = foundCert.name, + Certificates = new string[] { cleanPEM }, + PrivateKeyEntry = !string.IsNullOrEmpty(foundCert.key), + UseChainLevel = false, + }); + + continue; + } + catch + { + _logger.LogWarning($"still failing to parse, skipping this one ({foundCert.name}) and continuing with inventory."); + warningCount++; + continue; + } + } + + #endregion + + inventory.Add(new CurrentInventoryItem() + { + Alias = foundCert.name, + Certificates = new string[] { foundCert.certificate.certificate }, + PrivateKeyEntry = !string.IsNullOrEmpty(foundCert.key), + UseChainLevel = false + }); + } + + var successMessage = $"Successfully processed {inventory.Count} certificates. "; + if (warningCount > 0) successMessage += $"\n{warningCount} certificate(s) could not be processed.\nReview the logs on the orchestrator for more details."; + if (submitInventory.Invoke(inventory)) return Success(successMessage); + return ThrowError(new Exception("Inventory Job Failed. Review the orchestrator logs for more details."), "Inventory"); + } + finally + { + DisposeClient(); + } + } + + /// + /// This method does a preliminary check to circumvent the UO framework error when parsing headers + /// + /// + /// If + /// + string CleanPEMString(string dirtyPEM) + { + _logger.LogWarning("attempting to clean failing PEM string"); + _logger.LogWarning("original cert contents:"); + _logger.LogWarning($"\n{dirtyPEM}"); + + using (var sr = new StringReader(dirtyPEM)) + { + Org.BouncyCastle.OpenSsl.PemReader pemReader = new Org.BouncyCastle.OpenSsl.PemReader(sr); + + var pemObj = pemReader.ReadPemObject(); + + _logger.LogWarning("Unable to perform PEM to DER conversion on cert contents."); + _logger.LogWarning("cert contents:"); + _logger.LogWarning($"\n{dirtyPEM}"); + + PemObject po = new PemObject("CERTIFICATE", pemObj.Content); + + _logger.LogTrace("content (without comments): "); + var sw = new StringWriter(); + var pw = new PemWriter(sw); + pw.WriteObject(po); + _logger.LogTrace($"{sw.ToString()}"); + return sw.ToString(); + } + } + } +} diff --git a/vmware-nsx-orchestrator/Jobs/Management.cs b/vmware-nsx-orchestrator/Jobs/Management.cs index 87d9fe3..afc860d 100644 --- a/vmware-nsx-orchestrator/Jobs/Management.cs +++ b/vmware-nsx-orchestrator/Jobs/Management.cs @@ -31,20 +31,27 @@ public JobResult ProcessJob(ManagementJobConfiguration config) Initialize(clientMachine, config, config.CertificateStoreDetails, tenant); - switch (config.OperationType) + try { - case CertStoreOperationType.Add: - string certType = GetCertType(config.CertificateStoreDetails.StorePath); - return AddCertificateAsync(config.JobCertificate, config.Overwrite, certType).Result; - case CertStoreOperationType.Remove: - return DeleteCertificateAsync(config.JobCertificate.Alias).Result; - default: - return new JobResult() - { - Result = OrchestratorJobStatusJobResult.Failure, - FailureMessage = "Invalid Management Option", - JobHistoryId = config.JobHistoryId - }; + switch (config.OperationType) + { + case CertStoreOperationType.Add: + string certType = GetCertType(config.CertificateStoreDetails.StorePath); + return AddCertificateAsync(config.JobCertificate, config.Overwrite, certType).Result; + case CertStoreOperationType.Remove: + return DeleteCertificateAsync(config.JobCertificate.Alias).Result; + default: + return new JobResult() + { + Result = OrchestratorJobStatusJobResult.Failure, + FailureMessage = "Invalid Management Option", + JobHistoryId = config.JobHistoryId + }; + } + } + finally + { + DisposeClient(); } } diff --git a/vmware-nsx-orchestrator/NsxClient.cs b/vmware-nsx-orchestrator/NsxClient.cs index f1eb732..5257bae 100644 --- a/vmware-nsx-orchestrator/NsxClient.cs +++ b/vmware-nsx-orchestrator/NsxClient.cs @@ -8,7 +8,6 @@ using Keyfactor.Extensions.Orchestrator.Vmware.Nsx.Models; using Microsoft.Extensions.Logging; -using Newtonsoft.Json; using System; using System.Collections.Generic; using System.IO; @@ -17,6 +16,8 @@ using System.Net.Http; using System.Net.Security; using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; using System.Threading.Tasks; namespace Keyfactor.Extensions.Orchestrator.Vmware.Nsx @@ -32,9 +33,12 @@ public class NsxClient : IDisposable private const string LOGIN_ENDPOINT = "login"; private const string LOGOUT_ENDPOINT = "logout"; private const string CERT_ENDPOINT = "api/sslkeyandcertificate"; - private readonly JsonSerializerSettings serializerSettings = new JsonSerializerSettings() + // IncludeFields is required because the NSX ALB model classes expose plain public + // fields (matching the API's JSON keys directly) rather than properties. + private readonly JsonSerializerOptions serializerOptions = new JsonSerializerOptions() { - NullValueHandling = NullValueHandling.Ignore + IncludeFields = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; public NsxClient(ILogger logger, string url, string username, string password, string tenant, string apiVersion) @@ -92,11 +96,12 @@ public NsxClient(ILogger logger, string url, string username, string password, s private void Login(string username, string password) { _logger.LogTrace("Beginning initial Login"); - dynamic loginBody = new { + var loginBody = new + { username = username, password = password }; - StringContent content = new StringContent(JsonConvert.SerializeObject(loginBody), Encoding.UTF8, "application/json"); + StringContent content = new StringContent(JsonSerializer.Serialize(loginBody), Encoding.UTF8, "application/json"); var resp = HttpClient.PostAsync(LOGIN_ENDPOINT, content).Result; _logger.LogTrace("Posted Login request. Reading response."); EnsureSuccessfulResponse(resp); @@ -148,14 +153,14 @@ public async Task GetCertificateByName(string name) public async Task AddCertificate(SSLKeyAndCertificate certToImport) { - StringContent content = new StringContent(JsonConvert.SerializeObject(certToImport, serializerSettings), Encoding.UTF8, "application/json"); + StringContent content = new StringContent(JsonSerializer.Serialize(certToImport, serializerOptions), Encoding.UTF8, "application/json"); SetAuthCookiesForRequest(CERT_ENDPOINT); return await GetResponseAsync(await HttpClient.PostAsync(CERT_ENDPOINT, content)); } public async Task UpdateCertificate(string uuid, SSLKeyAndCertificate certUpdate) { - StringContent content = new StringContent(JsonConvert.SerializeObject(certUpdate, serializerSettings), Encoding.UTF8, "application/json"); + StringContent content = new StringContent(JsonSerializer.Serialize(certUpdate, serializerOptions), Encoding.UTF8, "application/json"); string requestEndpoint = string.Join("/", CERT_ENDPOINT, uuid); SetAuthCookiesForRequest(requestEndpoint); return await GetResponseAsync(await HttpClient.PutAsync(requestEndpoint, content)); @@ -174,7 +179,7 @@ private async Task GetResponseAsync(HttpResponseMessage response) { EnsureSuccessfulResponse(response); string stringResponse = new StreamReader(await response.Content.ReadAsStreamAsync()).ReadToEnd(); - return JsonConvert.DeserializeObject(stringResponse); + return JsonSerializer.Deserialize(stringResponse, serializerOptions); } private void EnsureSuccessfulResponse(HttpResponseMessage response) diff --git a/vmware-nsx-orchestrator/NsxJob.cs b/vmware-nsx-orchestrator/NsxJob.cs index 1bf3fba..c688a62 100644 --- a/vmware-nsx-orchestrator/NsxJob.cs +++ b/vmware-nsx-orchestrator/NsxJob.cs @@ -16,7 +16,7 @@ using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using NsxConstants = Keyfactor.Extensions.Orchestrator.Vmware.Nsx.Models.Constants; -using Newtonsoft.Json; +using System.Text.Json; using System.Collections.Generic; using Keyfactor.Orchestrators.Extensions.Interfaces; @@ -111,7 +111,7 @@ private protected void Initialize(string clientMachine, JobConfiguration config, _jobHistoryId = config.JobHistoryId; // check if store properties has an Api Version set - var storeProps = JsonConvert.DeserializeObject>(store.Properties); + var storeProps = JsonSerializer.Deserialize>(store.Properties); _apiVersion = storeProps.GetValueOrDefault("ApiVersion"); try @@ -129,6 +129,20 @@ private protected void Initialize(string clientMachine, JobConfiguration config, _logger.LogTrace($"Configuration complete for {ExtensionName}."); } + private protected void DisposeClient() + { + try + { + Client?.Dispose(); + } + catch (Exception ex) + { + // Client's HttpClient/HttpHandler are always released inside Dispose() before this could be thrown; + // this only means the NSX ALB logout call itself failed, so just log it rather than masking the job result. + _logger.LogWarning($"Failed to log out of NSX ALB session: {FlattenException(ex)}"); + } + } + private string ResolvePamField(IPAMSecretResolver pam, string key, string fieldName) { _logger.LogTrace($"Attempting to resolve PAM eligible field: '{fieldName}'"); diff --git a/vmware-nsx-orchestrator/vmware-nsx-orchestrator.csproj b/vmware-nsx-orchestrator/vmware-nsx-orchestrator.csproj index bf5b866..2740db4 100644 --- a/vmware-nsx-orchestrator/vmware-nsx-orchestrator.csproj +++ b/vmware-nsx-orchestrator/vmware-nsx-orchestrator.csproj @@ -1,7 +1,7 @@  - net6.0;net8.0 + net6.0;net8.0;net10.0 true true disable