Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -328,3 +328,4 @@ ASALocalRun/

# MFractors (Xamarin productivity tool) working folder
.mfractor/
/.claude
44 changes: 41 additions & 3 deletions AcmeCaPlugin/AcmeCaPlugin.cs
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,9 @@ public async Task<EnrollmentResult> Enroll(
// Create order
var order = await acmeClient.CreateOrderAsync(identifiers, null);

_logger.LogInformation("Order created. OrderUrl: {OrderUrl}, Status: {Status}",
order.OrderUrl, order.Payload?.Status);

// Store pending order immediately
var accountId = accountDetails.Kid.Split('/').Last();

Expand All @@ -271,26 +274,33 @@ public async Task<EnrollmentResult> Enroll(
// Finalize with original CSR bytes
order = await acmeClient.FinalizeOrderAsync(order, csrBytes);

// Extract order identifier (path only) for database storage
var orderIdentifier = ExtractOrderIdentifier(order.OrderUrl);

// If order is valid immediately, download cert
if (order.Payload?.Status == "valid" && !string.IsNullOrEmpty(order.Payload.Certificate))
{
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."
};
Expand All @@ -314,6 +324,34 @@ public async Task<EnrollmentResult> Enroll(



/// <summary>
/// Extracts the order path from the full ACME order URL for use as a unique identifier.
/// This removes the scheme, host, and port, keeping only the path portion.
/// </summary>
/// <param name="orderUrl">Full order URL (e.g., https://dv.acme-v02.api.pki.goog/order/ABC123)</param>
/// <returns>Order path without leading slash (e.g., "order/ABC123")</returns>
/// <example>
/// Input: "https://dv.acme-v02.api.pki.goog/order/IlYl06mPl5VcAQpx3pzR6w"
/// Output: "order/IlYl06mPl5VcAQpx3pzR6w"
/// </example>
private static string ExtractOrderIdentifier(string orderUrl)
{
if (string.IsNullOrWhiteSpace(orderUrl))
return orderUrl;

try
{
var uri = new Uri(orderUrl);
// Remove leading slash and return the path
return uri.AbsolutePath.TrimStart('/');
}
catch (Exception)
{
// If URL parsing fails, return the original (shouldn't happen with valid ACME URLs)
return orderUrl;
}
}

/// <summary>
/// Extracts the domain name from X.509 subject string
/// </summary>
Expand Down
39 changes: 21 additions & 18 deletions AcmeCaPlugin/AcmeCaPlugin.csproj
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net6.0;net8.0</TargetFrameworks>
<TargetFrameworks>net6.0;net8.0;net10.0</TargetFrameworks>
<ImplicitUsings>disable</ImplicitUsings>
<Nullable>disable</Nullable>
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
Expand All @@ -9,23 +9,26 @@
<AssemblyName>AcmeCaPlugin</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ACMESharpCore" Version="2.2.0.148"/>
<PackageReference Include="Autofac" Version="8.3.0"/>
<PackageReference Include="AWSSDK.Route53" Version="4.0.1"/>
<PackageReference Include="Azure.Identity" Version="1.14.0"/>
<PackageReference Include="Azure.ResourceManager.Cdn" Version="1.4.0"/>
<PackageReference Include="Azure.ResourceManager.Dns" Version="1.1.1"/>
<PackageReference Include="DnsClient" Version="1.8.0"/>
<PackageReference Include="ARSoft.Tools.Net" Version="3.6.0"/>
<PackageReference Include="Google.Apis.Dns.v1" Version="1.69.0.3753"/>
<PackageReference Include="Keyfactor.AnyGateway.IAnyCAPlugin" Version="3.0.0"/>
<PackageReference Include="Keyfactor.Logging" Version="1.1.1"/>
<PackageReference Include="Keyfactor.PKI" Version="5.5.0"/>
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.5"/>
<PackageReference Include="Nager.PublicSuffix" Version="3.5.0"/>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3"/>
<PackageReference Include="System.Net.Http.WinHttpHandler" Version="9.0.5"/>
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="9.0.5"/>
<PackageReference Include="ACMESharpCore" Version="2.2.0.148" />
<PackageReference Include="Autofac" Version="8.3.0" />
<PackageReference Include="AWSSDK.Core" Version="4.0.3.10" />
<PackageReference Include="AWSSDK.Route53" Version="4.0.8.8" />
<PackageReference Include="Azure.Identity" Version="1.14.0" />
<PackageReference Include="Azure.ResourceManager.Cdn" Version="1.4.0" />
<PackageReference Include="Azure.ResourceManager.Dns" Version="1.1.1" />
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
<PackageReference Include="DnsClient" Version="1.8.0" />
<PackageReference Include="ARSoft.Tools.Net" Version="3.6.0" />
<PackageReference Include="Google.Apis.Dns.v1" Version="1.69.0.3753" />
<PackageReference Include="Keyfactor.AnyGateway.IAnyCAPlugin" Version="3.3.0-PRERELEASE-78770-979f582005" />
<PackageReference Include="Keyfactor.Logging" Version="1.1.1" />
<PackageReference Include="Keyfactor.PKI" Version="5.5.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.5" />
<PackageReference Include="Nager.PublicSuffix" Version="3.5.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="System.Drawing.Common" Version="10.0.2" />
<PackageReference Include="System.Net.Http.WinHttpHandler" Version="9.0.5" />
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="9.0.5" />
</ItemGroup>
<ItemGroup>
<None Update="manifest.json">
Expand Down
16 changes: 16 additions & 0 deletions AcmeCaPlugin/AcmeCaPluginConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,13 @@ public static Dictionary<string, PropertyConfigInfo> 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)",
Expand All @@ -68,6 +75,15 @@ public static Dictionary<string, PropertyConfigInfo> 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()
{
Expand Down
3 changes: 3 additions & 0 deletions AcmeCaPlugin/AcmeClientConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,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
Expand All @@ -34,6 +35,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;
Expand Down
27 changes: 23 additions & 4 deletions AcmeCaPlugin/Clients/Acme/AccountManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion AcmeCaPlugin/Clients/Acme/AcmeClientManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
1 change: 1 addition & 0 deletions AcmeCaPlugin/Clients/DNS/DnsProviderFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
);

Expand Down
17 changes: 12 additions & 5 deletions AcmeCaPlugin/Clients/DNS/GoogleDnsProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

/// <summary>
/// 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).
/// </summary>
public class GoogleDnsProvider : IDnsProvider
{
Expand All @@ -18,19 +18,26 @@ public class GoogleDnsProvider : IDnsProvider

/// <summary>
/// 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.
/// </summary>
/// <param name="serviceAccountKeyPath">Path to the Service Account JSON key file (optional)</param>
/// <param name="serviceAccountKeyJson">Service Account JSON key as a string (optional, for containerized deployments)</param>
/// <param name="projectId">Google Cloud project ID containing the DNS zones</param>
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
Expand Down
Loading
Loading