diff --git a/AcmeCaPlugin/AcmeCaPlugin.cs b/AcmeCaPlugin/AcmeCaPlugin.cs index 1c3d432..060f913 100644 --- a/AcmeCaPlugin/AcmeCaPlugin.cs +++ b/AcmeCaPlugin/AcmeCaPlugin.cs @@ -370,7 +370,7 @@ private async Task ProcessAuthorizations(AcmeClient acmeClient, OrderDetails ord if (validation == null) throw new InvalidOperationException($"Failed to decode {DNS_CHALLENGE_TYPE} challenge validation details"); - // Create DNS record + // Create DNS record (will throw exception with details if it fails) var dnsProvider = DnsProviderFactory.Create(config, _logger); await dnsProvider.CreateRecordAsync(validation.DnsRecordName, validation.DnsRecordValue); @@ -383,22 +383,34 @@ private async Task ProcessAuthorizations(AcmeClient acmeClient, OrderDetails ord // Second pass: Wait for DNS propagation and submit challenges foreach (var (authz, challenge, validation) in pendingChallenges) { - _logger.LogInformation("Waiting for DNS propagation for {Domain}...", authz.Identifier.Value); + // Skip external DNS verification for Infoblox since it cannot ping external DNS providers + bool isInfoblox = config.DnsProvider?.Trim().Equals("infoblox", StringComparison.OrdinalIgnoreCase) ?? false; - // Wait for DNS propagation with verification - var propagated = await dnsVerifier.WaitForDnsPropagationAsync( - validation.DnsRecordName, - validation.DnsRecordValue, - minimumServers: 3 // Require at least 3 DNS servers to confirm - ); - - if (!propagated) + if (isInfoblox) + { + _logger.LogInformation("Skipping external DNS propagation check for Infoblox provider for {Domain}. Adding short delay...", authz.Identifier.Value); + // Add a short delay to allow Infoblox to process the record internally + await Task.Delay(TimeSpan.FromSeconds(5)); + } + else { - _logger.LogWarning("DNS record may not have fully propagated for {Domain}. Proceeding anyway...", - authz.Identifier.Value); + _logger.LogInformation("Waiting for DNS propagation for {Domain}...", authz.Identifier.Value); - // Optional: Add a final delay as fallback - await Task.Delay(TimeSpan.FromSeconds(30)); + // Wait for DNS propagation with verification + var propagated = await dnsVerifier.WaitForDnsPropagationAsync( + validation.DnsRecordName, + validation.DnsRecordValue, + minimumServers: 3 // Require at least 3 DNS servers to confirm + ); + + if (!propagated) + { + _logger.LogWarning("DNS record may not have fully propagated for {Domain}. Proceeding anyway...", + authz.Identifier.Value); + + // Optional: Add a final delay as fallback + await Task.Delay(TimeSpan.FromSeconds(30)); + } } // Submit challenge response diff --git a/AcmeCaPlugin/AcmeCaPluginConfig.cs b/AcmeCaPlugin/AcmeCaPluginConfig.cs index 0118de2..3d9f907 100644 --- a/AcmeCaPlugin/AcmeCaPluginConfig.cs +++ b/AcmeCaPlugin/AcmeCaPluginConfig.cs @@ -46,7 +46,7 @@ public static Dictionary GetPluginAnnotations() }, ["DnsProvider"] = new PropertyConfigInfo() { - Comments = "DNS Provider to use for ACME DNS-01 challenges (options Google, Cloudflare, AwsRoute53, Azure, Ns1)", + Comments = "DNS Provider to use for ACME DNS-01 challenges (options Google, Cloudflare, AwsRoute53, Azure, Ns1, Infoblox)", Hidden = false, DefaultValue = "Google", Type = "String" @@ -130,6 +130,30 @@ public static Dictionary GetPluginAnnotations() Type = "String" } + //Infoblox DNS + , + ["Infoblox_Host"] = new PropertyConfigInfo() + { + Comments = "Infoblox DNS: API URL (e.g., https://infoblox.example.com/wapi/v2.12) only if using Infoblox DNS (Optional)", + Hidden = false, + DefaultValue = "", + Type = "String" + }, + ["Infoblox_Username"] = new PropertyConfigInfo() + { + Comments = "Infoblox DNS: Username for authentication only if using Infoblox DNS (Optional)", + Hidden = false, + DefaultValue = "", + Type = "String" + }, + ["Infoblox_Password"] = new PropertyConfigInfo() + { + Comments = "Infoblox DNS: Password for authentication only if using Infoblox DNS (Optional)", + Hidden = true, + DefaultValue = "", + Type = "Secret" + } + }; } diff --git a/AcmeCaPlugin/AcmeClientConfig.cs b/AcmeCaPlugin/AcmeClientConfig.cs index 93963a8..04607b9 100644 --- a/AcmeCaPlugin/AcmeClientConfig.cs +++ b/AcmeCaPlugin/AcmeClientConfig.cs @@ -34,5 +34,12 @@ public class AcmeClientConfig //IBM NS1 DNS Ns1_ApiKey public string Ns1_ApiKey { get; set; } = null; + // Infoblox DNS + public string Infoblox_Host { get; set; } = null; + public string Infoblox_Username { get; set; } = null; + public string Infoblox_Password { get; set; } = null; + public string Infoblox_WapiVersion { get; set; } = "2.12"; + public bool Infoblox_IgnoreSslErrors { get; set; } = false; + } } diff --git a/AcmeCaPlugin/Clients/DNS/DnsProviderFactory.cs b/AcmeCaPlugin/Clients/DNS/DnsProviderFactory.cs index 011a528..23dfb99 100644 --- a/AcmeCaPlugin/Clients/DNS/DnsProviderFactory.cs +++ b/AcmeCaPlugin/Clients/DNS/DnsProviderFactory.cs @@ -39,6 +39,15 @@ public static IDnsProvider Create(AcmeClientConfig config, ILogger logger) return new Ns1DnsProvider( config.Ns1_ApiKey ); + case "infoblox": + return new InfobloxDnsProvider( + config.Infoblox_Host, + config.Infoblox_Username, + config.Infoblox_Password, + config.Infoblox_WapiVersion, + config.Infoblox_IgnoreSslErrors, + logger + ); default: throw new NotSupportedException($"DNS provider '{config.DnsProvider}' is not supported."); } diff --git a/AcmeCaPlugin/Clients/DNS/InfobloxDnsProvider.cs b/AcmeCaPlugin/Clients/DNS/InfobloxDnsProvider.cs new file mode 100644 index 0000000..73ecfc0 --- /dev/null +++ b/AcmeCaPlugin/Clients/DNS/InfobloxDnsProvider.cs @@ -0,0 +1,352 @@ +using System; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +public class InfobloxDnsProvider : IDnsProvider +{ + private readonly string _host; + private readonly string _username; + private readonly string _password; + private readonly string _wapiVersion; + private readonly HttpClient _httpClient; + private readonly JsonSerializerOptions _jsonOptions; + private readonly ILogger _logger; + + public InfobloxDnsProvider(string host, string username, string password, string wapiVersion = "2.12", bool ignoreSslErrors = false, ILogger logger = null) + { + _host = host?.TrimEnd('/') ?? throw new ArgumentNullException(nameof(host)); + _username = username ?? throw new ArgumentNullException(nameof(username)); + _password = password ?? throw new ArgumentNullException(nameof(password)); + _wapiVersion = wapiVersion ?? "2.12"; + _logger = logger; + + var handler = new HttpClientHandler(); + if (ignoreSslErrors) + { + handler.ServerCertificateCustomValidationCallback = (sender, cert, chain, sslPolicyErrors) => true; + } + + _httpClient = new HttpClient(handler) + { + BaseAddress = new Uri($"{_host}/wapi/v{_wapiVersion}/") + }; + + var authValue = Convert.ToBase64String(Encoding.ASCII.GetBytes($"{_username}:{_password}")); + _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", authValue); + + _jsonOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + } + + public async Task CreateRecordAsync(string recordName, string txtValue) + { + try + { + var cleanName = recordName.TrimEnd('.'); + + // Find the authoritative zone for this record + var zoneName = await FindAuthoritativeZoneAsync(cleanName); + _logger?.LogDebug("[Infoblox] Found authoritative zone: {ZoneName} for record: {RecordName}", zoneName, cleanName); + + // Verify zone exists (already checked in FindAuthoritativeZoneAsync, but verify one more time for safety) + var zoneExists = await VerifyZoneExistsAsync(zoneName); + if (!zoneExists) + { + var errorMsg = $"Infoblox zone '{zoneName}' not found or not accessible. Cannot create DNS record '{cleanName}'. Please verify the zone exists in Infoblox and is configured as an authoritative zone."; + _logger?.LogError("[Infoblox] {ErrorMessage}", errorMsg); + throw new InvalidOperationException(errorMsg); + } + + // Delete any existing records with the same name first to ensure only one record exists + var searchUrl = $"./record:txt?name={Uri.EscapeDataString(cleanName)}"; + _logger?.LogDebug("[Infoblox] Searching for existing records at: {SearchUrl}", searchUrl); + + var searchResponse = await _httpClient.GetAsync(searchUrl); + _logger?.LogDebug("[Infoblox] Search response status: {StatusCode}", searchResponse.StatusCode); + + if (searchResponse.IsSuccessStatusCode) + { + var searchJson = await searchResponse.Content.ReadAsStringAsync(); + using var searchDoc = JsonDocument.Parse(searchJson); + var records = searchDoc.RootElement; + var recordCount = records.GetArrayLength(); + _logger?.LogDebug("[Infoblox] Found {RecordCount} existing records", recordCount); + + // Delete all existing records with this name + foreach (var record in records.EnumerateArray()) + { + if (!record.TryGetProperty("_ref", out var refProperty)) + { + _logger?.LogWarning("[Infoblox] Record does not have _ref property"); + continue; + } + + var recordRef = "./" + refProperty.GetString(); + if (string.IsNullOrEmpty(recordRef)) + { + _logger?.LogWarning("[Infoblox] Record _ref is null or empty"); + continue; + } + + try + { + _logger?.LogDebug("[Infoblox] Attempting to delete record with ref: {RecordRef}", recordRef); + var deleteResponse = await _httpClient.DeleteAsync(recordRef); + var deleteResult = await deleteResponse.Content.ReadAsStringAsync(); + + _logger?.LogDebug("[Infoblox] Delete response: {StatusCode}, Body: {Body}", + deleteResponse.StatusCode, deleteResult); + + if (!deleteResponse.IsSuccessStatusCode) + { + _logger?.LogWarning("[Infoblox] Failed to delete record {RecordRef}: {StatusCode} - {Response}", + recordRef, deleteResponse.StatusCode, deleteResult); + } + } + catch (Exception deleteEx) + { + _logger?.LogError(deleteEx, "[Infoblox] Exception while deleting record {RecordRef}", recordRef); + // Continue anyway - we'll try to create the new record + } + } + } + else + { + var searchErrorBody = await searchResponse.Content.ReadAsStringAsync(); + _logger?.LogWarning("[Infoblox] Search for existing records failed: {StatusCode}, Response: {Response}", + searchResponse.StatusCode, searchErrorBody); + } + + // Create new record (zone is automatically determined by Infoblox from the FQDN) + var payload = new + { + name = cleanName, + text = txtValue, + ttl = 60, + view = "default" + }; + + var json = JsonSerializer.Serialize(payload, _jsonOptions); + _logger?.LogDebug("[Infoblox] Creating new TXT record. Payload: {Payload}", json); + + var request = new HttpRequestMessage(HttpMethod.Post, "./record:txt"); + request.Content = new StringContent(json, Encoding.UTF8, "application/json"); + + _logger?.LogTrace("[Infoblox] Request URI: {RequestUri}", request.RequestUri); + + var response = await _httpClient.SendAsync(request); + var result = await response.Content.ReadAsStringAsync(); + + _logger?.LogDebug("[Infoblox] Status: {StatusCode}", response.StatusCode); + _logger?.LogTrace("[Infoblox] Response: {Response}", result); + + if (!response.IsSuccessStatusCode) + { + // Include detailed error information in the exception + var errorDetails = $"Infoblox API returned {response.StatusCode}. Zone: {zoneName}, Record: {cleanName}, Response: {result}"; + _logger?.LogError("[Infoblox] API Error: {ErrorDetails}", errorDetails); + throw new InvalidOperationException(errorDetails); + } + + // Verify the record was created by searching for it + await Task.Delay(1000); // Brief delay to ensure record is committed + var verifySuccess = await VerifyRecordExists(cleanName, txtValue); + if (verifySuccess) + { + _logger?.LogDebug("[Infoblox] Verified TXT record exists: {RecordName}", cleanName); + } + else + { + _logger?.LogWarning("[Infoblox] Record creation returned success, but verification failed for {RecordName}", cleanName); + throw new InvalidOperationException($"Infoblox record verification failed for {cleanName}. Record was created but could not be found when querying back."); + } + + return true; + } + catch (InvalidOperationException) + { + // Re-throw our specific exceptions with detailed error messages + throw; + } + catch (Exception ex) + { + // Wrap unexpected exceptions with context + _logger?.LogError(ex, "[Infoblox] DNS provider error"); + throw new InvalidOperationException($"Infoblox DNS provider error: {ex.Message}", ex); + } + } + + public async Task DeleteRecordAsync(string recordName) + { + try + { + var cleanName = recordName.TrimEnd('.'); + var searchUrl = $"./record:txt?name={Uri.EscapeDataString(cleanName)}"; + + var searchResponse = await _httpClient.GetAsync(searchUrl); + if (!searchResponse.IsSuccessStatusCode) + { + _logger?.LogDebug("[Infoblox] Failed to search for record: {StatusCode}", searchResponse.StatusCode); + return false; + } + + var searchJson = await searchResponse.Content.ReadAsStringAsync(); + using var searchDoc = JsonDocument.Parse(searchJson); + var records = searchDoc.RootElement; + + if (records.GetArrayLength() == 0) + { + _logger?.LogDebug("[Infoblox] No TXT records found for {RecordName}", cleanName); + return false; + } + + var allDeleted = true; + foreach (var record in records.EnumerateArray()) + { + if (!record.TryGetProperty("_ref", out var refProperty)) + { + _logger?.LogWarning("[Infoblox] Record does not have _ref property"); + allDeleted = false; + continue; + } + + var recordRef = "./" + refProperty.GetString(); + if (string.IsNullOrEmpty(recordRef) || recordRef == "./") + { + _logger?.LogWarning("[Infoblox] Record _ref is null or empty"); + allDeleted = false; + continue; + } + + var deleteResponse = await _httpClient.DeleteAsync(recordRef); + var result = await deleteResponse.Content.ReadAsStringAsync(); + + _logger?.LogDebug("[Infoblox] Delete TXT: {StatusCode} - {Result}", deleteResponse.StatusCode, result); + + if (!deleteResponse.IsSuccessStatusCode) + { + allDeleted = false; + } + } + + return allDeleted; + } + catch (Exception ex) + { + _logger?.LogError(ex, "[Infoblox] Error deleting TXT record"); + return false; + } + } + + private async Task FindAuthoritativeZoneAsync(string recordName) + { + if (string.IsNullOrWhiteSpace(recordName)) + return string.Empty; + + var parts = recordName.TrimEnd('.').Split('.'); + if (parts.Length < 2) + return recordName; + + // Try to find the zone by checking from most specific to least specific + // For "_acme-challenge.hello.keyfactortestb.com", try: + // 1. hello.keyfactortestb.com + // 2. keyfactortestb.com + // Skip the first part (_acme-challenge) as it's the record itself + for (int i = 1; i < parts.Length - 1; i++) + { + var candidateZone = string.Join(".", parts.Skip(i)); + _logger?.LogDebug("[Infoblox] Checking if zone exists: {ZoneName}", candidateZone); + + if (await VerifyZoneExistsAsync(candidateZone)) + { + _logger?.LogDebug("[Infoblox] Found authoritative zone: {ZoneName}", candidateZone); + return candidateZone; + } + } + + // Fallback: use last two labels as default zone + var fallbackZone = string.Join(".", parts.Skip(parts.Length - 2)); + _logger?.LogDebug("[Infoblox] No specific zone found, using fallback: {ZoneName}", fallbackZone); + return fallbackZone; + } + + private async Task VerifyZoneExistsAsync(string zoneName) + { + try + { + var zoneUrl = $"zone_auth?fqdn={Uri.EscapeDataString(zoneName)}"; + var response = await _httpClient.GetAsync(zoneUrl); + + if (!response.IsSuccessStatusCode) + { + _logger?.LogDebug("[Infoblox] Zone lookup failed: {StatusCode}", response.StatusCode); + return false; + } + + var json = await response.Content.ReadAsStringAsync(); + using var zoneDoc = JsonDocument.Parse(json); + var zones = zoneDoc.RootElement; + var zoneExists = zones.GetArrayLength() > 0; + + _logger?.LogDebug("[Infoblox] Zone {ZoneName} exists: {ZoneExists}", zoneName, zoneExists); + return zoneExists; + } + catch (Exception ex) + { + _logger?.LogError(ex, "[Infoblox] Error verifying zone"); + return false; + } + } + + private async Task VerifyRecordExists(string recordName, string expectedValue) + { + try + { + var searchUrl = $"./record:txt?name={Uri.EscapeDataString(recordName)}"; + var response = await _httpClient.GetAsync(searchUrl); + + if (!response.IsSuccessStatusCode) + { + return false; + } + + var json = await response.Content.ReadAsStringAsync(); + using var recordDoc = JsonDocument.Parse(json); + var records = recordDoc.RootElement; + + foreach (var record in records.EnumerateArray()) + { + if (record.TryGetProperty("text", out var textProperty)) + { + var text = textProperty.GetString(); + if (text == expectedValue) + { + return true; + } + } + } + + return false; + } + catch (Exception ex) + { + _logger?.LogError(ex, "[Infoblox] Error verifying record"); + return false; + } + } + + public void Dispose() + { + _httpClient?.Dispose(); + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index ebb90b3..ca7823d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,7 @@ -# v1.0.2 +# v1.1.0 +* Infoblox NIOS DNS Provider Support Added + + # v1.0.2 * Fix DNS issue when fields outside domain are in the subject # v1.0.1 diff --git a/README.md b/README.md index cf34864..eafe5c8 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,7 @@ DNS-01 challenge automation is supported through the following providers: - **Azure DNS** - **Cloudflare** - **NS1** +- **Infoblox** Additional DNS providers can be added by extending the included `IDnsProvider` interface. @@ -109,6 +110,7 @@ This plugin automates DNS-01 challenges using pluggable DNS provider implementat | Azure DNS | Client Secret or Managed Identity | `Azure_TenantId`, `Azure_ClientId`, `Azure_ClientSecret`, `Azure_SubscriptionId` | | Cloudflare | API Token | `Cloudflare_ApiToken` | | NS1 | API Key | `Ns1_ApiKey` | +| Infoblox | Username/Password (Basic Auth) | `Infoblox_Host`, `Infoblox_Username`, `Infoblox_Password` | @@ -141,9 +143,14 @@ Each provider supports multiple credential strategies: - **Cloudflare**: - ✅ **Bearer API Token** for zone-level DNS control -- **NS1**: +- **NS1**: - ✅ **API Key** passed in header `X-NSONE-Key` +- **Infoblox**: + - ✅ **Username/Password** (Basic Auth via WAPI REST API) + - Optional: `Infoblox_WapiVersion` (defaults to `2.12`) + - Optional: `Infoblox_IgnoreSslErrors` for self-signed certificates +
@@ -544,7 +551,7 @@ This section outlines all required ports, file access, permissions, and validati * **EabKid** - External Account Binding Key ID (optional) * **EabHmacKey** - External Account Binding HMAC key (optional) * **SignerEncryptionPhrase** - Used to encrypt singer information when account is saved to disk (optional) - * **DnsProvider** - DNS Provider to use for ACME DNS-01 challenges (options Google, Cloudflare, AwsRoute53, Azure, Ns1) + * **DnsProvider** - DNS Provider to use for ACME DNS-01 challenges (options Google, Cloudflare, AwsRoute53, Azure, Ns1, Infoblox) * **Google_ServiceAccountKeyPath** - Google Cloud DNS: Path to service account JSON key file only if using Google DNS (Optional) * **Google_ProjectId** - Google Cloud DNS: Project ID only if using Google DNS (Optional) * **Cloudflare_ApiToken** - Cloudflare DNS: API Token only if using Cloudflare DNS (Optional) @@ -555,6 +562,9 @@ This section outlines all required ports, file access, permissions, and validati * **AwsRoute53_AccessKey** - Aws DNS: Access Key only if not using AWS DNS and default AWS Chain Creds on AWS (Optional) * **AwsRoute53_SecretKey** - Aws DNS: Secret Key only if using AWS DNS and not using default AWS Chain Creds on AWS (Optional) * **Ns1_ApiKey** - Ns1 DNS: Api Key only if Using Ns1 DNS (Optional) + * **Infoblox_Host** - Infoblox DNS: API URL (e.g., https://infoblox.example.com/wapi/v2.12) only if using Infoblox DNS (Optional) + * **Infoblox_Username** - Infoblox DNS: Username for authentication only if using Infoblox DNS (Optional) + * **Infoblox_Password** - Infoblox DNS: Password for authentication only if using Infoblox DNS (Optional) 2. Define [Certificate Profiles](https://software.keyfactor.com/Guides/AnyCAGatewayREST/Content/AnyCAGatewayREST/AddCP-Gateway.htm) and [Certificate Templates](https://software.keyfactor.com/Guides/AnyCAGatewayREST/Content/AnyCAGatewayREST/AddCA-Gateway.htm) for the Certificate Authority as required. One Certificate Profile must be defined per Certificate Template. It's recommended that each Certificate Profile be named after the Product ID. The Acme plugin supports the following product IDs: diff --git a/docsource/configuration.md b/docsource/configuration.md index 56b652b..0755004 100644 --- a/docsource/configuration.md +++ b/docsource/configuration.md @@ -25,6 +25,7 @@ DNS-01 challenge automation is supported through the following providers: - **Azure DNS** - **Cloudflare** - **NS1** +- **Infoblox** Additional DNS providers can be added by extending the included `IDnsProvider` interface. @@ -70,6 +71,7 @@ This plugin automates DNS-01 challenges using pluggable DNS provider implementat | Azure DNS | Client Secret or Managed Identity | `Azure_TenantId`, `Azure_ClientId`, `Azure_ClientSecret`, `Azure_SubscriptionId` | | Cloudflare | API Token | `Cloudflare_ApiToken` | | NS1 | API Key | `Ns1_ApiKey` | +| Infoblox | Username/Password (Basic Auth) | `Infoblox_Host`, `Infoblox_Username`, `Infoblox_Password` |
@@ -102,9 +104,14 @@ Each provider supports multiple credential strategies: - **Cloudflare**: - ✅ **Bearer API Token** for zone-level DNS control -- **NS1**: +- **NS1**: - ✅ **API Key** passed in header `X-NSONE-Key` +- **Infoblox**: + - ✅ **Username/Password** (Basic Auth via WAPI REST API) + - Optional: `Infoblox_WapiVersion` (defaults to `2.12`) + - Optional: `Infoblox_IgnoreSslErrors` for self-signed certificates +
diff --git a/integration-manifest.json b/integration-manifest.json index 9be0a09..cd747b9 100644 --- a/integration-manifest.json +++ b/integration-manifest.json @@ -35,7 +35,7 @@ }, { "name": "DnsProvider", - "description": "DNS Provider to use for ACME DNS-01 challenges (options Google, Cloudflare, AwsRoute53, Azure, Ns1)" + "description": "DNS Provider to use for ACME DNS-01 challenges (options Google, Cloudflare, AwsRoute53, Azure, Ns1, Infoblox)" }, { "name": "Google_ServiceAccountKeyPath", @@ -76,6 +76,18 @@ { "name": "Ns1_ApiKey", "description": "Ns1 DNS: Api Key only if Using Ns1 DNS (Optional)" + }, + { + "name": "Infoblox_Host", + "description": "Infoblox DNS: API URL (e.g., https://infoblox.example.com/wapi/v2.12) only if using Infoblox DNS (Optional)" + }, + { + "name": "Infoblox_Username", + "description": "Infoblox DNS: Username for authentication only if using Infoblox DNS (Optional)" + }, + { + "name": "Infoblox_Password", + "description": "Infoblox DNS: Password for authentication only if using Infoblox DNS (Optional)" } ], "enrollment_config": [],