From 851a382286ebacca7f7dd5fdf5d80027cb4537b7 Mon Sep 17 00:00:00 2001 From: Brian Hill <76450501+bhillkeyfactor@users.noreply.github.com> Date: Tue, 3 Mar 2026 19:52:02 +0000 Subject: [PATCH 1/2] Release: 1.3.0 --------- Co-authored-by: Keyfactor --- .gitignore | 6 ++ AcmeCaPlugin/AcmeCaPlugin.cs | 75 +++++++++++++++- AcmeCaPlugin/AcmeCaPlugin.csproj | 41 ++++----- AcmeCaPlugin/AcmeCaPluginConfig.cs | 23 +++++ AcmeCaPlugin/AcmeClientConfig.cs | 4 + AcmeCaPlugin/Clients/Acme/AccountManager.cs | 27 +++++- .../Clients/Acme/AcmeClientManager.cs | 2 +- .../Clients/DNS/CloudflareDnsProvider.cs | 49 ++++++----- .../Clients/DNS/DnsProviderFactory.cs | 1 + AcmeCaPlugin/Clients/DNS/GoogleDnsProvider.cs | 17 ++-- CHANGELOG.md | 6 +- README.md | 85 ++++++++++++++++--- TestProgram/TestProgram.csproj | 8 +- docsource/configuration.md | 82 +++++++++++++++--- integration-manifest.json | 12 +++ 15 files changed, 363 insertions(+), 75 deletions(-) diff --git a/.gitignore b/.gitignore index 3e759b7..d84e572 100644 --- a/.gitignore +++ b/.gitignore @@ -328,3 +328,9 @@ ASALocalRun/ # MFractors (Xamarin productivity tool) working folder .mfractor/ +/.claude +MIGRATION-SUMMARY.md +PLUGIN-MIGRATION-GUIDE.md +ReflectFramework.csx +InspectFramework/InspectFramework.csproj +InspectFramework/Program.cs diff --git a/AcmeCaPlugin/AcmeCaPlugin.cs b/AcmeCaPlugin/AcmeCaPlugin.cs index fa1a5cf..231047d 100644 --- a/AcmeCaPlugin/AcmeCaPlugin.cs +++ b/AcmeCaPlugin/AcmeCaPlugin.cs @@ -22,6 +22,7 @@ using Org.BouncyCastle.Asn1.Pkcs; using Org.BouncyCastle.Asn1.X509; using Org.BouncyCastle.Pkcs; +using System.Security.Cryptography; namespace Keyfactor.Extensions.CAPlugin.Acme { @@ -62,6 +63,7 @@ public class AcmeCaPlugin : IAnyCAPlugin { private static readonly ILogger _logger = LogHandler.GetClassLogger(); private IAnyCAPluginConfigProvider Config { get; set; } + private AcmeClientConfig _config; // Constants for better maintainability private const string DEFAULT_PRODUCT_ID = "default"; @@ -76,6 +78,16 @@ public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDa { _logger.MethodEntry(); Config = configProvider ?? throw new ArgumentNullException(nameof(configProvider)); + _config = GetConfig(); + _logger.LogTrace("Enabled: {Enabled}", _config.Enabled); + + if (!_config.Enabled) + { + _logger.LogWarning("The CA is currently in the Disabled state. It must be Enabled to perform operations. Skipping config validation..."); + _logger.MethodExit(); + return; + } + _logger.MethodExit(); } @@ -88,6 +100,12 @@ public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDa public async Task Ping() { _logger.MethodEntry(); + if (!_config.Enabled) + { + _logger.LogWarning("The CA is currently in the Disabled state. It must be Enabled to perform operations. Skipping connectivity test..."); + _logger.MethodExit(); + return; + } HttpClient httpClient = null; try @@ -165,6 +183,13 @@ public Task ValidateCAConnectionInfo(Dictionary connectionInfo) var rawData = JsonConvert.SerializeObject(connectionInfo); var config = JsonConvert.DeserializeObject(rawData); + if (config != null && !config.Enabled) + { + _logger.LogWarning("The CA is currently in the Disabled state. It must be Enabled to perform operations. Skipping config validation..."); + _logger.MethodExit(); + return Task.CompletedTask; + } + // Validate required configuration fields var missingFields = new List(); if (string.IsNullOrWhiteSpace(config?.DirectoryUrl)) @@ -230,6 +255,17 @@ public async Task Enroll( { _logger.MethodEntry(); + if (!_config.Enabled) + { + _logger.LogWarning("The CA is currently in the Disabled state. It must be Enabled to perform operations. Enrollment rejected."); + _logger.MethodExit(); + return new EnrollmentResult + { + Status = (int)EndEntityStatus.FAILED, + StatusMessage = "CA connector is disabled. Enable it in the CA configuration to perform enrollments." + }; + } + if (string.IsNullOrWhiteSpace(csr)) throw new ArgumentException("CSR cannot be null or empty", nameof(csr)); if (string.IsNullOrWhiteSpace(subject)) @@ -262,6 +298,12 @@ public async Task Enroll( // Create order var order = await acmeClient.CreateOrderAsync(identifiers, null); + _logger.LogInformation("Order created. OrderUrl: {OrderUrl}, Status: {Status}", + order.OrderUrl, order.Payload?.Status); + + // Extract order identifier BEFORE finalization to ensure we use the original order URL + var orderIdentifier = ExtractOrderIdentifier(order.OrderUrl); + // Store pending order immediately var accountId = accountDetails.Kid.Split('/').Last(); @@ -277,20 +319,24 @@ public async Task Enroll( var certBytes = await acmeClient.GetCertificateAsync(order); var certPem = EncodeToPem(certBytes, "CERTIFICATE"); + _logger.LogInformation("✅ Enrollment completed successfully. OrderUrl: {OrderUrl}, CARequestID: {OrderId}, Status: GENERATED", + order.OrderUrl, orderIdentifier); + return new EnrollmentResult { - CARequestID = order.Payload.Finalize, + CARequestID = orderIdentifier, Certificate = certPem, Status = (int)EndEntityStatus.GENERATED }; } else { - _logger.LogInformation("⏳ Order not valid yet — will be synced later. Status: {Status}", order.Payload?.Status); + _logger.LogInformation("⏳ Order not valid yet — will be synced later. OrderUrl: {OrderUrl}, CARequestID: {OrderId}, Status: {Status}", + order.OrderUrl, orderIdentifier, order.Payload?.Status); // Order stays saved for next sync return new EnrollmentResult { - CARequestID = order.Payload.Finalize, + CARequestID = orderIdentifier, Status = (int)EndEntityStatus.FAILED, StatusMessage = "Could not retrieve order in allowed time." }; @@ -314,6 +360,29 @@ public async Task Enroll( + /// + /// Generates a fixed-length SHA256 hash of the ACME order URL for database storage. + /// Produces a consistent 40-char hex string regardless of URL length or ACME CA format. + /// The full order URL is logged separately during enrollment for traceability. + /// + private static string ExtractOrderIdentifier(string orderUrl) + { + if (string.IsNullOrWhiteSpace(orderUrl)) + return orderUrl; + + using (var sha256 = SHA256.Create()) + { + var hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(orderUrl)); + // Take first 20 bytes (40 hex chars) — fits in DB column and is collision-safe + var sb = new StringBuilder(40); + for (int i = 0; i < 20; i++) + { + sb.Append(hashBytes[i].ToString("x2")); + } + return sb.ToString(); + } + } + /// /// Extracts the domain name from X.509 subject string /// diff --git a/AcmeCaPlugin/AcmeCaPlugin.csproj b/AcmeCaPlugin/AcmeCaPlugin.csproj index 1adf8a5..9808b55 100644 --- a/AcmeCaPlugin/AcmeCaPlugin.csproj +++ b/AcmeCaPlugin/AcmeCaPlugin.csproj @@ -1,31 +1,34 @@ - net6.0;net8.0 + net6.0;net8.0;net10.0 disable disable true - false + true Keyfactor.Extensions.CAPlugin.Acme AcmeCaPlugin - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + diff --git a/AcmeCaPlugin/AcmeCaPluginConfig.cs b/AcmeCaPlugin/AcmeCaPluginConfig.cs index ec7dffb..b6a0ddb 100644 --- a/AcmeCaPlugin/AcmeCaPluginConfig.cs +++ b/AcmeCaPlugin/AcmeCaPluginConfig.cs @@ -9,6 +9,13 @@ public static Dictionary GetPluginAnnotations() { return new Dictionary() { + ["Enabled"] = new PropertyConfigInfo() + { + Comments = "Enable or disable this CA connector. When disabled, all operations (ping, enroll, sync) are skipped.", + Hidden = false, + DefaultValue = "true", + Type = "Bool" + }, ["DirectoryUrl"] = new PropertyConfigInfo() { Comments = "ACME directory URL (e.g. Let's Encrypt, ZeroSSL, etc.)", @@ -60,6 +67,13 @@ public static Dictionary GetPluginAnnotations() DefaultValue = "", Type = "String" }, + ["Google_ServiceAccountKeyJson"] = new PropertyConfigInfo() + { + Comments = "Google Cloud DNS: Service account JSON key content (alternative to file path for containerized deployments)", + Hidden = true, + DefaultValue = "", + Type = "Secret" + }, ["Google_ProjectId"] = new PropertyConfigInfo() { Comments = "Google Cloud DNS: Project ID only if using Google DNS (Optional)", @@ -68,6 +82,15 @@ public static Dictionary GetPluginAnnotations() Type = "String" }, + // Container Deployment + ["AccountStoragePath"] = new PropertyConfigInfo() + { + Comments = "Path for ACME account storage. Defaults to %APPDATA%\\AcmeAccounts on Windows or ./AcmeAccounts in containers.", + Hidden = false, + DefaultValue = "", + Type = "String" + }, + // Cloudflare DNS ["Cloudflare_ApiToken"] = new PropertyConfigInfo() { diff --git a/AcmeCaPlugin/AcmeClientConfig.cs b/AcmeCaPlugin/AcmeClientConfig.cs index b639572..f659c3f 100644 --- a/AcmeCaPlugin/AcmeClientConfig.cs +++ b/AcmeCaPlugin/AcmeClientConfig.cs @@ -4,6 +4,7 @@ namespace Keyfactor.Extensions.CAPlugin.Acme { public class AcmeClientConfig { + public bool Enabled { get; set; } = true; public string DirectoryUrl { get; set; } = "https://acme-v02.api.letsencrypt.org/directory"; public string Email { get; set; } = string.Empty; public string EabKid { get; set; } = null; @@ -15,6 +16,7 @@ public class AcmeClientConfig // Google Cloud DNS public string Google_ServiceAccountKeyPath { get; set; } = null; + public string Google_ServiceAccountKeyJson { get; set; } = null; public string Google_ProjectId { get; set; } = null; // Cloudflare DNS @@ -34,6 +36,8 @@ public class AcmeClientConfig //IBM NS1 DNS Ns1_ApiKey public string Ns1_ApiKey { get; set; } = null; + // Container Deployment Support + public string AccountStoragePath { get; set; } = null; // RFC 2136 Dynamic DNS (BIND) public string Rfc2136_Server { get; set; } = null; public int Rfc2136_Port { get; set; } = 53; diff --git a/AcmeCaPlugin/Clients/Acme/AccountManager.cs b/AcmeCaPlugin/Clients/Acme/AccountManager.cs index 5345368..07ed3fb 100644 --- a/AcmeCaPlugin/Clients/Acme/AccountManager.cs +++ b/AcmeCaPlugin/Clients/Acme/AccountManager.cs @@ -39,13 +39,32 @@ class AccountManager #region Constructor - public AccountManager(ILogger log, string passphrase = null) + public AccountManager(ILogger log, string passphrase = null, string storagePath = null) { _log = log; _passphrase = passphrase; - _basePath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), - "AcmeAccounts"); + + if (!string.IsNullOrWhiteSpace(storagePath)) + { + // Use the explicitly configured path + _basePath = storagePath; + } + else + { + // Default: Use platform-appropriate path + var appDataPath = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + if (string.IsNullOrEmpty(appDataPath)) + { + // In containers, APPDATA may not be set; use current directory + _basePath = Path.Combine(Directory.GetCurrentDirectory(), "AcmeAccounts"); + } + else + { + _basePath = Path.Combine(appDataPath, "AcmeAccounts"); + } + } + + _log.LogDebug("Account storage path configured: {BasePath}", _basePath); } #endregion diff --git a/AcmeCaPlugin/Clients/Acme/AcmeClientManager.cs b/AcmeCaPlugin/Clients/Acme/AcmeClientManager.cs index 5cbb1b8..e12e6fa 100644 --- a/AcmeCaPlugin/Clients/Acme/AcmeClientManager.cs +++ b/AcmeCaPlugin/Clients/Acme/AcmeClientManager.cs @@ -65,7 +65,7 @@ public AcmeClientManager(ILogger log, AcmeClientConfig config, HttpClient httpCl _email = config.Email; _eabKid = config.EabKid; _eabHmac = config.EabHmacKey; - _accountManager = new AccountManager(log,config.SignerEncryptionPhrase); + _accountManager = new AccountManager(log, config.SignerEncryptionPhrase, config.AccountStoragePath); _log.LogDebug("AcmeClientManager initialized for directory: {DirectoryUrl}", _directoryUrl); } diff --git a/AcmeCaPlugin/Clients/DNS/CloudflareDnsProvider.cs b/AcmeCaPlugin/Clients/DNS/CloudflareDnsProvider.cs index 6e20dd1..d8b6611 100644 --- a/AcmeCaPlugin/Clients/DNS/CloudflareDnsProvider.cs +++ b/AcmeCaPlugin/Clients/DNS/CloudflareDnsProvider.cs @@ -30,12 +30,9 @@ public CloudflareDnsProvider(string apiToken) public async Task CreateRecordAsync(string recordName, string txtValue) { - // 1) Determine apex zone - var zoneName = ExtractZoneFromRecord(recordName); - var zoneId = await GetZoneIdAsync(zoneName); - if (zoneId == null) return false; + var (zoneName, zoneId) = await FindZoneForRecordAsync(recordName); + if (zoneId == null || zoneName == null) return false; - // 2) Get the relative record name for Cloudflare var relativeName = GetRelativeRecordName(recordName, zoneName); var payload = new @@ -59,12 +56,9 @@ public async Task CreateRecordAsync(string recordName, string txtValue) public async Task DeleteRecordAsync(string recordName) { - // 1) Determine apex zone - var zoneName = ExtractZoneFromRecord(recordName); - var zoneId = await GetZoneIdAsync(zoneName); - if (zoneId == null) return false; + var (zoneName, zoneId) = await FindZoneForRecordAsync(recordName); + if (zoneId == null || zoneName == null) return false; - // 2) Get the relative record name for Cloudflare var relativeName = GetRelativeRecordName(recordName, zoneName); var recordsResp = await _httpClient.GetAsync($"zones/{zoneId}/dns_records?type=TXT&name={relativeName}"); @@ -73,8 +67,9 @@ public async Task DeleteRecordAsync(string recordName) var json = await recordsResp.Content.ReadAsStringAsync(); var doc = JsonDocument.Parse(json); - var recordId = doc.RootElement.GetProperty("result").EnumerateArray() - .FirstOrDefault().GetProperty("id").GetString(); + var resultArray = doc.RootElement.GetProperty("result"); + if (resultArray.GetArrayLength() == 0) return false; + var recordId = resultArray[0].GetProperty("id").GetString(); if (recordId == null) return false; @@ -92,21 +87,35 @@ public async Task DeleteRecordAsync(string recordName) var json = await response.Content.ReadAsStringAsync(); var doc = JsonDocument.Parse(json); - return doc.RootElement.GetProperty("result").EnumerateArray() - .FirstOrDefault().GetProperty("id").GetString(); + var resultArray = doc.RootElement.GetProperty("result"); + if (resultArray.GetArrayLength() == 0) return null; + return resultArray[0].GetProperty("id").GetString(); } - private string ExtractZoneFromRecord(string recordName) + private async Task<(string? zoneName, string? zoneId)> FindZoneForRecordAsync(string recordName) { if (string.IsNullOrWhiteSpace(recordName)) - return string.Empty; + return (null, null); var parts = recordName.TrimEnd('.').Split('.'); - if (parts.Length < 2) - return recordName; - // Use last two labels as default zone: e.g., "keyfactoracme.com" - return string.Join(".", parts.Skip(parts.Length - 2)); + // Try progressively shorter domain parts to find the actual zone + // e.g., for "_acme-challenge.www.keyfactor.ssl4saas.com", try: + // - www.keyfactor.ssl4saas.com + // - keyfactor.ssl4saas.com + // - ssl4saas.com + for (int i = 1; i < parts.Length - 1; i++) + { + var candidateZone = string.Join(".", parts.Skip(i)); + var zoneId = await GetZoneIdAsync(candidateZone); + if (zoneId != null) + { + Console.WriteLine($"Found zone: {candidateZone} (id: {zoneId})"); + return (candidateZone, zoneId); + } + } + + return (null, null); } private string GetRelativeRecordName(string recordName, string zoneName) diff --git a/AcmeCaPlugin/Clients/DNS/DnsProviderFactory.cs b/AcmeCaPlugin/Clients/DNS/DnsProviderFactory.cs index b93c616..b3419e4 100644 --- a/AcmeCaPlugin/Clients/DNS/DnsProviderFactory.cs +++ b/AcmeCaPlugin/Clients/DNS/DnsProviderFactory.cs @@ -15,6 +15,7 @@ public static IDnsProvider Create(AcmeClientConfig config, ILogger logger) case "google": return new GoogleDnsProvider( config.Google_ServiceAccountKeyPath, + config.Google_ServiceAccountKeyJson, config.Google_ProjectId ); diff --git a/AcmeCaPlugin/Clients/DNS/GoogleDnsProvider.cs b/AcmeCaPlugin/Clients/DNS/GoogleDnsProvider.cs index c82de75..951630f 100644 --- a/AcmeCaPlugin/Clients/DNS/GoogleDnsProvider.cs +++ b/AcmeCaPlugin/Clients/DNS/GoogleDnsProvider.cs @@ -9,7 +9,7 @@ /// /// Google Cloud DNS provider implementation for managing DNS TXT records. -/// Supports explicit Service Account key or Workload Identity (Application Default Credentials). +/// Supports explicit Service Account key (file or JSON), or Workload Identity (Application Default Credentials). /// public class GoogleDnsProvider : IDnsProvider { @@ -18,19 +18,26 @@ public class GoogleDnsProvider : IDnsProvider /// /// Initializes a new instance of the GoogleDnsProvider class. - /// If serviceAccountKeyPath is null or empty, uses Application Default Credentials. + /// Credential resolution order: JSON key > File path > Application Default Credentials. /// /// Path to the Service Account JSON key file (optional) + /// Service Account JSON key as a string (optional, for containerized deployments) /// Google Cloud project ID containing the DNS zones - public GoogleDnsProvider(string? serviceAccountKeyPath, string projectId) + public GoogleDnsProvider(string? serviceAccountKeyPath, string? serviceAccountKeyJson, string projectId) { _projectId = projectId; GoogleCredential credential; - if (!string.IsNullOrWhiteSpace(serviceAccountKeyPath)) + if (!string.IsNullOrWhiteSpace(serviceAccountKeyJson)) { - Console.WriteLine("✅ Using explicit Service Account JSON key."); + // JSON key provided directly (for container deployments) + Console.WriteLine("✅ Using Service Account JSON key from configuration."); + credential = GoogleCredential.FromJson(serviceAccountKeyJson); + } + else if (!string.IsNullOrWhiteSpace(serviceAccountKeyPath)) + { + Console.WriteLine("✅ Using Service Account JSON key from file."); credential = GoogleCredential.FromFile(serviceAccountKeyPath); } else diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a39b77..97bf92e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,8 @@ -# v1.2.0 +# v1.3.0 +* Containerization Changes for SaaS Environment +* Fixed URL CaId Length issue + + # v1.2.0 * Added RFC 2136 Dynamic DNS Provider Support (BIND with TSIG authentication) * Added Infoblox DNS Provider Support * Added configurable DNS verification server for private/local DNS zones diff --git a/README.md b/README.md index 4ab4d6d..11cfa3a 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,7 @@ This plugin automates DNS-01 challenges using pluggable DNS provider implementat | Provider | Auth Methods Supported | Config Keys Required | |--------------|-----------------------------------------------|--------------------------------------------------------| -| Google DNS | Service Account Key or ADC | `Google_ServiceAccountKeyPath`, `Google_ProjectId` | +| Google DNS | Service Account Key (file or JSON), or ADC | `Google_ServiceAccountKeyPath`, `Google_ServiceAccountKeyJson`, `Google_ProjectId` | | AWS Route 53 | Access Key/Secret or IAM Role | `AwsRoute53_AccessKey`, `AwsRoute53_SecretKey` | | Azure DNS | Client Secret or Managed Identity | `Azure_TenantId`, `Azure_ClientId`, `Azure_ClientSecret`, `Azure_SubscriptionId` | | Cloudflare | API Token | `Cloudflare_ApiToken` | @@ -130,8 +130,9 @@ This logic is handled by the `DnsVerificationHelper` class and ensures a high-co Each provider supports multiple credential strategies: -- **Google DNS**: - - ✅ **Service Account Key** (via `Google_ServiceAccountKeyPath`) +- **Google DNS**: + - ✅ **Service Account Key File** (via `Google_ServiceAccountKeyPath`) + - ✅ **Service Account Key JSON** (via `Google_ServiceAccountKeyJson` - paste JSON directly) - ✅ **Application Default Credentials** (e.g., GCP Workload Identity or developer auth) - **AWS Route 53**: @@ -340,12 +341,17 @@ This ACME Gateway implementation uses a local file-based store to persist ACME a
📁 Account Directory Structure -Each account is saved in its own directory within: +Each account is saved in its own directory within the configured storage path: ``` -%APPDATA%\AcmeAccounts\{host}_{accountId} +{AccountStoragePath}\{host}_{accountId} ``` +**Default paths:** +- **Windows:** `%APPDATA%\AcmeAccounts\{host}_{accountId}` +- **Containers (when APPDATA unavailable):** `./AcmeAccounts\{host}_{accountId}` +- **Custom:** Set `AccountStoragePath` in the Gateway configuration + Where: - `{host}` is the ACME directory host with dots replaced by dashes (e.g., `acme-zerossl-com`) - `{accountId}` is the final segment of the account's KID URL @@ -456,10 +462,10 @@ This section outlines all required ports, file access, permissions, and validati | Path | Purpose | |----------------------------------------------------|----------------------------------------------| -| `%APPDATA%\AcmeAccounts\` | Default base path for ACME account storage | -| `AcmeAccounts\{account_id}\Registration_v2` | Contains serialized ACME account metadata | -| `AcmeAccounts\{account_id}\Signer_v2` | Contains the encrypted private signer key | -| `AcmeAccounts\default_{host}.txt` | Stores the default account pointer for a given directory | +| `%APPDATA%\AcmeAccounts\` or `AccountStoragePath` | Base path for ACME account storage (configurable) | +| `{base}\{account_id}\Registration_v2` | Contains serialized ACME account metadata | +| `{base}\{account_id}\Signer_v2` | Contains the encrypted private signer key | +| `{base}\default_{host}.txt` | Stores the default account pointer for a given directory | #### File Access & Permissions @@ -469,7 +475,8 @@ This section outlines all required ports, file access, permissions, and validati | Account files | Read/Write| `Read`, `Write` | - Files may be optionally encrypted using AES if a passphrase is configured. -- Ensure the service account under which the orchestrator runs has read/write access to `%APPDATA%` or the custom configured base path. +- Ensure the service account under which the orchestrator runs has read/write access to the configured base path. +- For containers, mount a persistent volume to the `AccountStoragePath` to preserve accounts across restarts.
@@ -496,6 +503,61 @@ This section outlines all required ports, file access, permissions, and validati +--- + +### Container Deployment + +This section covers configuration options specific to containerized deployments (Docker, Kubernetes, etc.). + +
+📁 Configurable Account Storage Path + +By default, the plugin stores ACME accounts in `%APPDATA%\AcmeAccounts` on Windows. In containerized environments, use the `AccountStoragePath` configuration option: + +| Environment | Recommended Path | +|-------------|------------------| +| Docker/Kubernetes | `/data/AcmeAccounts` (mounted volume) | +| Windows Container | `C:\AcmeData\AcmeAccounts` | + +If `AccountStoragePath` is not set and `%APPDATA%` is unavailable, the plugin defaults to `./AcmeAccounts` relative to the working directory. + +
+ +
+🌐 Google Cloud DNS in Containers + +For Google Cloud DNS in container environments, you have three authentication options: + +1. **Workload Identity (GKE)**: No explicit credentials needed; uses pod identity. +2. **JSON key in config**: Paste the service account JSON directly into `Google_ServiceAccountKeyJson`. +3. **Mounted JSON file**: Mount the service account key file and set `Google_ServiceAccountKeyPath`. + +
+ +
+☸️ Kubernetes Deployment Considerations + +When deploying in Kubernetes: + +1. **Persistent Storage**: Use a PersistentVolumeClaim for `AccountStoragePath` to preserve ACME accounts across pod restarts. +2. **Cloud Provider Identity**: Leverage Workload Identity (GKE), IAM Roles for Service Accounts (EKS), or Pod Identity (AKS) for DNS provider authentication. + +**Example PersistentVolumeClaim:** +```yaml +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: acme-accounts +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 100Mi +``` + +
+ ## Installation 1. Install the AnyCA Gateway REST per the [official Keyfactor documentation](https://software.keyfactor.com/Guides/AnyCAGatewayREST/Content/AnyCAGatewayREST/InstallIntroduction.htm). @@ -612,6 +674,7 @@ This section outlines all required ports, file access, permissions, and validati Populate using the configuration fields collected in the [requirements](#requirements) section. + * **Enabled** - Enable or disable this CA connector. When disabled, all operations (ping, enroll, sync) are skipped. * **DirectoryUrl** - ACME directory URL (e.g. Let's Encrypt, ZeroSSL, etc.) * **Email** - Email for ACME account registration. * **EabKid** - External Account Binding Key ID (optional) @@ -619,7 +682,9 @@ This section outlines all required ports, file access, permissions, and validati * **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, Rfc2136, Infoblox) * **Google_ServiceAccountKeyPath** - Google Cloud DNS: Path to service account JSON key file only if using Google DNS (Optional) + * **Google_ServiceAccountKeyJson** - Google Cloud DNS: Service account JSON key content (alternative to file path for containerized deployments) * **Google_ProjectId** - Google Cloud DNS: Project ID only if using Google DNS (Optional) + * **AccountStoragePath** - Path for ACME account storage. Defaults to %APPDATA%\AcmeAccounts on Windows or ./AcmeAccounts in containers. * **Cloudflare_ApiToken** - Cloudflare DNS: API Token only if using Cloudflare DNS (Optional) * **Azure_ClientId** - Azure DNS: ClientId only if using Azure DNS and Not Managed Itentity in Azure (Optional) * **Azure_ClientSecret** - Azure DNS: ClientSecret only if using Azure DNS and Not Managed Itentity in Azure (Optional) diff --git a/TestProgram/TestProgram.csproj b/TestProgram/TestProgram.csproj index 127834b..9e8374a 100644 --- a/TestProgram/TestProgram.csproj +++ b/TestProgram/TestProgram.csproj @@ -2,16 +2,20 @@ Exe - net6.0;net8.0 + net6.0;net8.0;net10.0 enable enable - + + + + + diff --git a/docsource/configuration.md b/docsource/configuration.md index 73195d3..d4b9357 100644 --- a/docsource/configuration.md +++ b/docsource/configuration.md @@ -67,7 +67,7 @@ This plugin automates DNS-01 challenges using pluggable DNS provider implementat | Provider | Auth Methods Supported | Config Keys Required | |--------------|-----------------------------------------------|--------------------------------------------------------| -| Google DNS | Service Account Key or ADC | `Google_ServiceAccountKeyPath`, `Google_ProjectId` | +| Google DNS | Service Account Key (file or JSON), or ADC | `Google_ServiceAccountKeyPath`, `Google_ServiceAccountKeyJson`, `Google_ProjectId` | | AWS Route 53 | Access Key/Secret or IAM Role | `AwsRoute53_AccessKey`, `AwsRoute53_SecretKey` | | Azure DNS | Client Secret or Managed Identity | `Azure_TenantId`, `Azure_ClientId`, `Azure_ClientSecret`, `Azure_SubscriptionId` | | Cloudflare | API Token | `Cloudflare_ApiToken` | @@ -91,8 +91,9 @@ This logic is handled by the `DnsVerificationHelper` class and ensures a high-co Each provider supports multiple credential strategies: -- **Google DNS**: - - ✅ **Service Account Key** (via `Google_ServiceAccountKeyPath`) +- **Google DNS**: + - ✅ **Service Account Key File** (via `Google_ServiceAccountKeyPath`) + - ✅ **Service Account Key JSON** (via `Google_ServiceAccountKeyJson` - paste JSON directly) - ✅ **Application Default Credentials** (e.g., GCP Workload Identity or developer auth) - **AWS Route 53**: @@ -302,12 +303,17 @@ This ACME Gateway implementation uses a local file-based store to persist ACME a
📁 Account Directory Structure -Each account is saved in its own directory within: +Each account is saved in its own directory within the configured storage path: ``` -%APPDATA%\AcmeAccounts\{host}_{accountId} +{AccountStoragePath}\{host}_{accountId} ``` +**Default paths:** +- **Windows:** `%APPDATA%\AcmeAccounts\{host}_{accountId}` +- **Containers (when APPDATA unavailable):** `./AcmeAccounts\{host}_{accountId}` +- **Custom:** Set `AccountStoragePath` in the Gateway configuration + Where: - `{host}` is the ACME directory host with dots replaced by dashes (e.g., `acme-zerossl-com`) - `{accountId}` is the final segment of the account's KID URL @@ -418,10 +424,10 @@ This section outlines all required ports, file access, permissions, and validati | Path | Purpose | |----------------------------------------------------|----------------------------------------------| -| `%APPDATA%\AcmeAccounts\` | Default base path for ACME account storage | -| `AcmeAccounts\{account_id}\Registration_v2` | Contains serialized ACME account metadata | -| `AcmeAccounts\{account_id}\Signer_v2` | Contains the encrypted private signer key | -| `AcmeAccounts\default_{host}.txt` | Stores the default account pointer for a given directory | +| `%APPDATA%\AcmeAccounts\` or `AccountStoragePath` | Base path for ACME account storage (configurable) | +| `{base}\{account_id}\Registration_v2` | Contains serialized ACME account metadata | +| `{base}\{account_id}\Signer_v2` | Contains the encrypted private signer key | +| `{base}\default_{host}.txt` | Stores the default account pointer for a given directory | #### File Access & Permissions @@ -431,7 +437,8 @@ This section outlines all required ports, file access, permissions, and validati | Account files | Read/Write| `Read`, `Write` | - Files may be optionally encrypted using AES if a passphrase is configured. -- Ensure the service account under which the orchestrator runs has read/write access to `%APPDATA%` or the custom configured base path. +- Ensure the service account under which the orchestrator runs has read/write access to the configured base path. +- For containers, mount a persistent volume to the `AccountStoragePath` to preserve accounts across restarts.
@@ -458,6 +465,61 @@ This section outlines all required ports, file access, permissions, and validati +--- + +### Container Deployment + +This section covers configuration options specific to containerized deployments (Docker, Kubernetes, etc.). + +
+📁 Configurable Account Storage Path + +By default, the plugin stores ACME accounts in `%APPDATA%\AcmeAccounts` on Windows. In containerized environments, use the `AccountStoragePath` configuration option: + +| Environment | Recommended Path | +|-------------|------------------| +| Docker/Kubernetes | `/data/AcmeAccounts` (mounted volume) | +| Windows Container | `C:\AcmeData\AcmeAccounts` | + +If `AccountStoragePath` is not set and `%APPDATA%` is unavailable, the plugin defaults to `./AcmeAccounts` relative to the working directory. + +
+ +
+🌐 Google Cloud DNS in Containers + +For Google Cloud DNS in container environments, you have three authentication options: + +1. **Workload Identity (GKE)**: No explicit credentials needed; uses pod identity. +2. **JSON key in config**: Paste the service account JSON directly into `Google_ServiceAccountKeyJson`. +3. **Mounted JSON file**: Mount the service account key file and set `Google_ServiceAccountKeyPath`. + +
+ +
+☸️ Kubernetes Deployment Considerations + +When deploying in Kubernetes: + +1. **Persistent Storage**: Use a PersistentVolumeClaim for `AccountStoragePath` to preserve ACME accounts across pod restarts. +2. **Cloud Provider Identity**: Leverage Workload Identity (GKE), IAM Roles for Service Accounts (EKS), or Pod Identity (AKS) for DNS provider authentication. + +**Example PersistentVolumeClaim:** +```yaml +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: acme-accounts +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 100Mi +``` + +
+ ## Gateway Registration diff --git a/integration-manifest.json b/integration-manifest.json index 28b68da..08ba468 100644 --- a/integration-manifest.json +++ b/integration-manifest.json @@ -13,6 +13,10 @@ "about": { "carest": { "ca_plugin_config": [ + { + "name": "Enabled", + "description": "Enable or disable this CA connector. When disabled, all operations (ping, enroll, sync) are skipped." + }, { "name": "DirectoryUrl", "description": "ACME directory URL (e.g. Let's Encrypt, ZeroSSL, etc.)" @@ -41,10 +45,18 @@ "name": "Google_ServiceAccountKeyPath", "description": "Google Cloud DNS: Path to service account JSON key file only if using Google DNS (Optional)" }, + { + "name": "Google_ServiceAccountKeyJson", + "description": "Google Cloud DNS: Service account JSON key content (alternative to file path for containerized deployments)" + }, { "name": "Google_ProjectId", "description": "Google Cloud DNS: Project ID only if using Google DNS (Optional)" }, + { + "name": "AccountStoragePath", + "description": "Path for ACME account storage. Defaults to %APPDATA%\\AcmeAccounts on Windows or ./AcmeAccounts in containers." + }, { "name": "Cloudflare_ApiToken", "description": "Cloudflare DNS: API Token only if using Cloudflare DNS (Optional)" From 74d102233076670ec9f7cdcef52791990ac15f1d Mon Sep 17 00:00:00 2001 From: Brian Hill <76450501+bhillkeyfactor@users.noreply.github.com> Date: Mon, 27 Apr 2026 21:47:08 +0000 Subject: [PATCH 2/2] docs: make Let's Encrypt certificate instructions generic (#15) Removed specific root/intermediate names (ISRG Root X1, R3) that go stale when Let's Encrypt rotates their chain. Users are now directed to the official Let's Encrypt certificates page to identify the currently active root and intermediate certificates. Co-authored-by: Claude Opus 4.7 (1M context) --- README.md | 6 +++--- docsource/configuration.md | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 11cfa3a..0365c9c 100644 --- a/README.md +++ b/README.md @@ -595,12 +595,12 @@ spec: #### Let's Encrypt - - **Root**: ISRG Root X1 - - **Intermediate**: R3 + Let's Encrypt periodically rotates its root and intermediate certificates. Always refer to their official certificates page for the current active chain. **How to Get:** - Browse to: https://letsencrypt.org/certificates/ - - Download both the **ISRG Root X1** and **R3 Intermediate Certificate (PEM format)**. + - Identify the currently active **root** and **intermediate** certificates listed on that page. + - Download both certificates in **PEM format**. #### Google Certificate Authority Service (CAS) diff --git a/docsource/configuration.md b/docsource/configuration.md index d4b9357..186f285 100644 --- a/docsource/configuration.md +++ b/docsource/configuration.md @@ -533,12 +533,12 @@ Here is how to obtain the root and intermediate CA certificates from supported A #### Let's Encrypt -- **Root**: ISRG Root X1 -- **Intermediate**: R3 +Let's Encrypt periodically rotates its root and intermediate certificates. Always refer to their official certificates page for the current active chain. **How to Get:** - Browse to: https://letsencrypt.org/certificates/ -- Download both the **ISRG Root X1** and **R3 Intermediate Certificate (PEM format)**. +- Identify the currently active **root** and **intermediate** certificates listed on that page. +- Download both certificates in **PEM format**. #### Google Certificate Authority Service (CAS)