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)"