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
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
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
41 changes: 22 additions & 19 deletions AcmeCaPlugin/AcmeCaPlugin.csproj
Original file line number Diff line number Diff line change
@@ -1,31 +1,34 @@
<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>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<GenerateAssemblyInfo>true</GenerateAssemblyInfo>
<RootNamespace>Keyfactor.Extensions.CAPlugin.Acme</RootNamespace>
<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.17.1" />
<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.1.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.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
49 changes: 29 additions & 20 deletions AcmeCaPlugin/Clients/DNS/CloudflareDnsProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,9 @@

public async Task<bool> 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
Expand All @@ -59,12 +56,9 @@

public async Task<bool> 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}");
Expand All @@ -73,8 +67,9 @@
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;

Expand All @@ -85,28 +80,42 @@
return deleteResp.IsSuccessStatusCode;
}

private async Task<string?> GetZoneIdAsync(string zoneName)

Check warning on line 83 in AcmeCaPlugin/Clients/DNS/CloudflareDnsProvider.cs

View workflow job for this annotation

GitHub Actions / call-starter-workflow / call-dotnet-build-and-release-workflow / dotnet-build-and-release

The annotation for nullable reference types should only be used in code within a '#nullable' annotations context.

Check warning on line 83 in AcmeCaPlugin/Clients/DNS/CloudflareDnsProvider.cs

View workflow job for this annotation

GitHub Actions / call-starter-workflow / call-dotnet-build-and-release-workflow / dotnet-build-and-release

The annotation for nullable reference types should only be used in code within a '#nullable' annotations context.
{
var response = await _httpClient.GetAsync($"zones?name={zoneName}");
if (!response.IsSuccessStatusCode) return null;

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)

Check warning on line 95 in AcmeCaPlugin/Clients/DNS/CloudflareDnsProvider.cs

View workflow job for this annotation

GitHub Actions / call-starter-workflow / call-dotnet-build-and-release-workflow / dotnet-build-and-release

The annotation for nullable reference types should only be used in code within a '#nullable' annotations context.

Check warning on line 95 in AcmeCaPlugin/Clients/DNS/CloudflareDnsProvider.cs

View workflow job for this annotation

GitHub Actions / call-starter-workflow / call-dotnet-build-and-release-workflow / dotnet-build-and-release

The annotation for nullable reference types should only be used in code within a '#nullable' annotations context.
{
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)
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 @@

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

Check warning on line 26 in AcmeCaPlugin/Clients/DNS/GoogleDnsProvider.cs

View workflow job for this annotation

GitHub Actions / call-starter-workflow / call-generate-readme-workflow / Use private doctool action in public repository

The annotation for nullable reference types should only be used in code within a '#nullable' annotations context.

Check warning on line 26 in AcmeCaPlugin/Clients/DNS/GoogleDnsProvider.cs

View workflow job for this annotation

GitHub Actions / call-starter-workflow / call-generate-readme-workflow / Use private doctool action in public repository

The annotation for nullable reference types should only be used in code within a '#nullable' annotations context.

Check warning on line 26 in AcmeCaPlugin/Clients/DNS/GoogleDnsProvider.cs

View workflow job for this annotation

GitHub Actions / call-starter-workflow / call-dotnet-build-and-release-workflow / dotnet-build-and-release

The annotation for nullable reference types should only be used in code within a '#nullable' annotations context.

Check warning on line 26 in AcmeCaPlugin/Clients/DNS/GoogleDnsProvider.cs

View workflow job for this annotation

GitHub Actions / call-starter-workflow / call-dotnet-build-and-release-workflow / dotnet-build-and-release

The annotation for nullable reference types should only be used in code within a '#nullable' annotations context.

Check warning on line 26 in AcmeCaPlugin/Clients/DNS/GoogleDnsProvider.cs

View workflow job for this annotation

GitHub Actions / call-starter-workflow / call-dotnet-build-and-release-workflow / dotnet-build-and-release

The annotation for nullable reference types should only be used in code within a '#nullable' annotations context.

Check warning on line 26 in AcmeCaPlugin/Clients/DNS/GoogleDnsProvider.cs

View workflow job for this annotation

GitHub Actions / call-starter-workflow / call-dotnet-build-and-release-workflow / dotnet-build-and-release

The annotation for nullable reference types should only be used in code within a '#nullable' annotations context.
{
_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 Expand Up @@ -156,7 +163,7 @@
/// <summary>
/// Finds the appropriate DNS zone for a given record name.
/// </summary>
private async Task<ManagedZone?> GetZone(string recordName)

Check warning on line 166 in AcmeCaPlugin/Clients/DNS/GoogleDnsProvider.cs

View workflow job for this annotation

GitHub Actions / call-starter-workflow / call-generate-readme-workflow / Use private doctool action in public repository

The annotation for nullable reference types should only be used in code within a '#nullable' annotations context.

Check warning on line 166 in AcmeCaPlugin/Clients/DNS/GoogleDnsProvider.cs

View workflow job for this annotation

GitHub Actions / call-starter-workflow / call-dotnet-build-and-release-workflow / dotnet-build-and-release

The annotation for nullable reference types should only be used in code within a '#nullable' annotations context.

Check warning on line 166 in AcmeCaPlugin/Clients/DNS/GoogleDnsProvider.cs

View workflow job for this annotation

GitHub Actions / call-starter-workflow / call-dotnet-build-and-release-workflow / dotnet-build-and-release

The annotation for nullable reference types should only be used in code within a '#nullable' annotations context.
{
try
{
Expand Down
Loading
Loading