Skip to content

Commit e43db56

Browse files
Merge 3709c4d into d34f7b5
2 parents d34f7b5 + 3709c4d commit e43db56

14 files changed

Lines changed: 308 additions & 73 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,3 +328,4 @@ ASALocalRun/
328328

329329
# MFractors (Xamarin productivity tool) working folder
330330
.mfractor/
331+
/.claude

AcmeCaPlugin/AcmeCaPlugin.cs

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,9 @@ public async Task<EnrollmentResult> Enroll(
262262
// Create order
263263
var order = await acmeClient.CreateOrderAsync(identifiers, null);
264264

265+
_logger.LogInformation("Order created. OrderUrl: {OrderUrl}, Status: {Status}",
266+
order.OrderUrl, order.Payload?.Status);
267+
265268
// Store pending order immediately
266269
var accountId = accountDetails.Kid.Split('/').Last();
267270

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

277+
// Extract order identifier (path only) for database storage
278+
var orderIdentifier = ExtractOrderIdentifier(order.OrderUrl);
279+
274280
// If order is valid immediately, download cert
275281
if (order.Payload?.Status == "valid" && !string.IsNullOrEmpty(order.Payload.Certificate))
276282
{
277283
var certBytes = await acmeClient.GetCertificateAsync(order);
278284
var certPem = EncodeToPem(certBytes, "CERTIFICATE");
279285

286+
_logger.LogInformation("✅ Enrollment completed successfully. OrderUrl: {OrderUrl}, CARequestID: {OrderId}, Status: GENERATED",
287+
order.OrderUrl, orderIdentifier);
288+
280289
return new EnrollmentResult
281290
{
282-
CARequestID = order.Payload.Finalize,
291+
CARequestID = orderIdentifier,
283292
Certificate = certPem,
284293
Status = (int)EndEntityStatus.GENERATED
285294
};
286295
}
287296
else
288297
{
289-
_logger.LogInformation("⏳ Order not valid yet — will be synced later. Status: {Status}", order.Payload?.Status);
298+
_logger.LogInformation("⏳ Order not valid yet — will be synced later. OrderUrl: {OrderUrl}, CARequestID: {OrderId}, Status: {Status}",
299+
order.OrderUrl, orderIdentifier, order.Payload?.Status);
290300
// Order stays saved for next sync
291301
return new EnrollmentResult
292302
{
293-
CARequestID = order.Payload.Finalize,
303+
CARequestID = orderIdentifier,
294304
Status = (int)EndEntityStatus.FAILED,
295305
StatusMessage = "Could not retrieve order in allowed time."
296306
};
@@ -314,6 +324,34 @@ public async Task<EnrollmentResult> Enroll(
314324

315325

316326

327+
/// <summary>
328+
/// Extracts the order path from the full ACME order URL for use as a unique identifier.
329+
/// This removes the scheme, host, and port, keeping only the path portion.
330+
/// </summary>
331+
/// <param name="orderUrl">Full order URL (e.g., https://dv.acme-v02.api.pki.goog/order/ABC123)</param>
332+
/// <returns>Order path without leading slash (e.g., "order/ABC123")</returns>
333+
/// <example>
334+
/// Input: "https://dv.acme-v02.api.pki.goog/order/IlYl06mPl5VcAQpx3pzR6w"
335+
/// Output: "order/IlYl06mPl5VcAQpx3pzR6w"
336+
/// </example>
337+
private static string ExtractOrderIdentifier(string orderUrl)
338+
{
339+
if (string.IsNullOrWhiteSpace(orderUrl))
340+
return orderUrl;
341+
342+
try
343+
{
344+
var uri = new Uri(orderUrl);
345+
// Remove leading slash and return the path
346+
return uri.AbsolutePath.TrimStart('/');
347+
}
348+
catch (Exception)
349+
{
350+
// If URL parsing fails, return the original (shouldn't happen with valid ACME URLs)
351+
return orderUrl;
352+
}
353+
}
354+
317355
/// <summary>
318356
/// Extracts the domain name from X.509 subject string
319357
/// </summary>

AcmeCaPlugin/AcmeCaPlugin.csproj

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<Project Sdk="Microsoft.NET.Sdk">
22
<PropertyGroup>
3-
<TargetFrameworks>net6.0;net8.0</TargetFrameworks>
3+
<TargetFrameworks>net6.0;net8.0;net10.0</TargetFrameworks>
44
<ImplicitUsings>disable</ImplicitUsings>
55
<Nullable>disable</Nullable>
66
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
@@ -9,23 +9,26 @@
99
<AssemblyName>AcmeCaPlugin</AssemblyName>
1010
</PropertyGroup>
1111
<ItemGroup>
12-
<PackageReference Include="ACMESharpCore" Version="2.2.0.148"/>
13-
<PackageReference Include="Autofac" Version="8.3.0"/>
14-
<PackageReference Include="AWSSDK.Route53" Version="4.0.1"/>
15-
<PackageReference Include="Azure.Identity" Version="1.14.0"/>
16-
<PackageReference Include="Azure.ResourceManager.Cdn" Version="1.4.0"/>
17-
<PackageReference Include="Azure.ResourceManager.Dns" Version="1.1.1"/>
18-
<PackageReference Include="DnsClient" Version="1.8.0"/>
19-
<PackageReference Include="ARSoft.Tools.Net" Version="3.6.0"/>
20-
<PackageReference Include="Google.Apis.Dns.v1" Version="1.69.0.3753"/>
21-
<PackageReference Include="Keyfactor.AnyGateway.IAnyCAPlugin" Version="3.0.0"/>
22-
<PackageReference Include="Keyfactor.Logging" Version="1.1.1"/>
23-
<PackageReference Include="Keyfactor.PKI" Version="5.5.0"/>
24-
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.5"/>
25-
<PackageReference Include="Nager.PublicSuffix" Version="3.5.0"/>
26-
<PackageReference Include="Newtonsoft.Json" Version="13.0.3"/>
27-
<PackageReference Include="System.Net.Http.WinHttpHandler" Version="9.0.5"/>
28-
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="9.0.5"/>
12+
<PackageReference Include="ACMESharpCore" Version="2.2.0.148" />
13+
<PackageReference Include="Autofac" Version="8.3.0" />
14+
<PackageReference Include="AWSSDK.Core" Version="4.0.3.10" />
15+
<PackageReference Include="AWSSDK.Route53" Version="4.0.8.8" />
16+
<PackageReference Include="Azure.Identity" Version="1.17.1" />
17+
<PackageReference Include="Azure.ResourceManager.Cdn" Version="1.4.0" />
18+
<PackageReference Include="Azure.ResourceManager.Dns" Version="1.1.1" />
19+
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
20+
<PackageReference Include="DnsClient" Version="1.8.0" />
21+
<PackageReference Include="ARSoft.Tools.Net" Version="3.6.0" />
22+
<PackageReference Include="Google.Apis.Dns.v1" Version="1.69.0.3753" />
23+
<PackageReference Include="Keyfactor.AnyGateway.IAnyCAPlugin" Version="3.2.0" />
24+
<PackageReference Include="Keyfactor.Logging" Version="1.1.1" />
25+
<PackageReference Include="Keyfactor.PKI" Version="5.5.0" />
26+
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.5" />
27+
<PackageReference Include="Nager.PublicSuffix" Version="3.5.0" />
28+
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
29+
<PackageReference Include="System.Drawing.Common" Version="10.0.2" />
30+
<PackageReference Include="System.Net.Http.WinHttpHandler" Version="9.0.5" />
31+
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="9.0.5" />
2932
</ItemGroup>
3033
<ItemGroup>
3134
<None Update="manifest.json">

AcmeCaPlugin/AcmeCaPluginConfig.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,13 @@ public static Dictionary<string, PropertyConfigInfo> GetPluginAnnotations()
6060
DefaultValue = "",
6161
Type = "String"
6262
},
63+
["Google_ServiceAccountKeyJson"] = new PropertyConfigInfo()
64+
{
65+
Comments = "Google Cloud DNS: Service account JSON key content (alternative to file path for containerized deployments)",
66+
Hidden = true,
67+
DefaultValue = "",
68+
Type = "Secret"
69+
},
6370
["Google_ProjectId"] = new PropertyConfigInfo()
6471
{
6572
Comments = "Google Cloud DNS: Project ID only if using Google DNS (Optional)",
@@ -68,6 +75,15 @@ public static Dictionary<string, PropertyConfigInfo> GetPluginAnnotations()
6875
Type = "String"
6976
},
7077

78+
// Container Deployment
79+
["AccountStoragePath"] = new PropertyConfigInfo()
80+
{
81+
Comments = "Path for ACME account storage. Defaults to %APPDATA%\\AcmeAccounts on Windows or ./AcmeAccounts in containers.",
82+
Hidden = false,
83+
DefaultValue = "",
84+
Type = "String"
85+
},
86+
7187
// Cloudflare DNS
7288
["Cloudflare_ApiToken"] = new PropertyConfigInfo()
7389
{

AcmeCaPlugin/AcmeClientConfig.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ public class AcmeClientConfig
1515

1616
// Google Cloud DNS
1717
public string Google_ServiceAccountKeyPath { get; set; } = null;
18+
public string Google_ServiceAccountKeyJson { get; set; } = null;
1819
public string Google_ProjectId { get; set; } = null;
1920

2021
// Cloudflare DNS
@@ -34,6 +35,8 @@ public class AcmeClientConfig
3435
//IBM NS1 DNS Ns1_ApiKey
3536
public string Ns1_ApiKey { get; set; } = null;
3637

38+
// Container Deployment Support
39+
public string AccountStoragePath { get; set; } = null;
3740
// RFC 2136 Dynamic DNS (BIND)
3841
public string Rfc2136_Server { get; set; } = null;
3942
public int Rfc2136_Port { get; set; } = 53;

AcmeCaPlugin/Clients/Acme/AccountManager.cs

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,13 +39,32 @@ class AccountManager
3939

4040
#region Constructor
4141

42-
public AccountManager(ILogger log, string passphrase = null)
42+
public AccountManager(ILogger log, string passphrase = null, string storagePath = null)
4343
{
4444
_log = log;
4545
_passphrase = passphrase;
46-
_basePath = Path.Combine(
47-
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
48-
"AcmeAccounts");
46+
47+
if (!string.IsNullOrWhiteSpace(storagePath))
48+
{
49+
// Use the explicitly configured path
50+
_basePath = storagePath;
51+
}
52+
else
53+
{
54+
// Default: Use platform-appropriate path
55+
var appDataPath = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
56+
if (string.IsNullOrEmpty(appDataPath))
57+
{
58+
// In containers, APPDATA may not be set; use current directory
59+
_basePath = Path.Combine(Directory.GetCurrentDirectory(), "AcmeAccounts");
60+
}
61+
else
62+
{
63+
_basePath = Path.Combine(appDataPath, "AcmeAccounts");
64+
}
65+
}
66+
67+
_log.LogDebug("Account storage path configured: {BasePath}", _basePath);
4968
}
5069

5170
#endregion

AcmeCaPlugin/Clients/Acme/AcmeClientManager.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ public AcmeClientManager(ILogger log, AcmeClientConfig config, HttpClient httpCl
6565
_email = config.Email;
6666
_eabKid = config.EabKid;
6767
_eabHmac = config.EabHmacKey;
68-
_accountManager = new AccountManager(log,config.SignerEncryptionPhrase);
68+
_accountManager = new AccountManager(log, config.SignerEncryptionPhrase, config.AccountStoragePath);
6969

7070
_log.LogDebug("AcmeClientManager initialized for directory: {DirectoryUrl}", _directoryUrl);
7171
}

AcmeCaPlugin/Clients/DNS/CloudflareDnsProvider.cs

Lines changed: 29 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,9 @@ public CloudflareDnsProvider(string apiToken)
3030

3131
public async Task<bool> CreateRecordAsync(string recordName, string txtValue)
3232
{
33-
// 1) Determine apex zone
34-
var zoneName = ExtractZoneFromRecord(recordName);
35-
var zoneId = await GetZoneIdAsync(zoneName);
36-
if (zoneId == null) return false;
33+
var (zoneName, zoneId) = await FindZoneForRecordAsync(recordName);
34+
if (zoneId == null || zoneName == null) return false;
3735

38-
// 2) Get the relative record name for Cloudflare
3936
var relativeName = GetRelativeRecordName(recordName, zoneName);
4037

4138
var payload = new
@@ -59,12 +56,9 @@ public async Task<bool> CreateRecordAsync(string recordName, string txtValue)
5956

6057
public async Task<bool> DeleteRecordAsync(string recordName)
6158
{
62-
// 1) Determine apex zone
63-
var zoneName = ExtractZoneFromRecord(recordName);
64-
var zoneId = await GetZoneIdAsync(zoneName);
65-
if (zoneId == null) return false;
59+
var (zoneName, zoneId) = await FindZoneForRecordAsync(recordName);
60+
if (zoneId == null || zoneName == null) return false;
6661

67-
// 2) Get the relative record name for Cloudflare
6862
var relativeName = GetRelativeRecordName(recordName, zoneName);
6963

7064
var recordsResp = await _httpClient.GetAsync($"zones/{zoneId}/dns_records?type=TXT&name={relativeName}");
@@ -73,8 +67,9 @@ public async Task<bool> DeleteRecordAsync(string recordName)
7367
var json = await recordsResp.Content.ReadAsStringAsync();
7468
var doc = JsonDocument.Parse(json);
7569

76-
var recordId = doc.RootElement.GetProperty("result").EnumerateArray()
77-
.FirstOrDefault().GetProperty("id").GetString();
70+
var resultArray = doc.RootElement.GetProperty("result");
71+
if (resultArray.GetArrayLength() == 0) return false;
72+
var recordId = resultArray[0].GetProperty("id").GetString();
7873

7974
if (recordId == null) return false;
8075

@@ -92,21 +87,35 @@ public async Task<bool> DeleteRecordAsync(string recordName)
9287

9388
var json = await response.Content.ReadAsStringAsync();
9489
var doc = JsonDocument.Parse(json);
95-
return doc.RootElement.GetProperty("result").EnumerateArray()
96-
.FirstOrDefault().GetProperty("id").GetString();
90+
var resultArray = doc.RootElement.GetProperty("result");
91+
if (resultArray.GetArrayLength() == 0) return null;
92+
return resultArray[0].GetProperty("id").GetString();
9793
}
9894

99-
private string ExtractZoneFromRecord(string recordName)
95+
private async Task<(string? zoneName, string? zoneId)> FindZoneForRecordAsync(string recordName)
10096
{
10197
if (string.IsNullOrWhiteSpace(recordName))
102-
return string.Empty;
98+
return (null, null);
10399

104100
var parts = recordName.TrimEnd('.').Split('.');
105-
if (parts.Length < 2)
106-
return recordName;
107101

108-
// Use last two labels as default zone: e.g., "keyfactoracme.com"
109-
return string.Join(".", parts.Skip(parts.Length - 2));
102+
// Try progressively shorter domain parts to find the actual zone
103+
// e.g., for "_acme-challenge.www.keyfactor.ssl4saas.com", try:
104+
// - www.keyfactor.ssl4saas.com
105+
// - keyfactor.ssl4saas.com
106+
// - ssl4saas.com
107+
for (int i = 1; i < parts.Length - 1; i++)
108+
{
109+
var candidateZone = string.Join(".", parts.Skip(i));
110+
var zoneId = await GetZoneIdAsync(candidateZone);
111+
if (zoneId != null)
112+
{
113+
Console.WriteLine($"Found zone: {candidateZone} (id: {zoneId})");
114+
return (candidateZone, zoneId);
115+
}
116+
}
117+
118+
return (null, null);
110119
}
111120

112121
private string GetRelativeRecordName(string recordName, string zoneName)

AcmeCaPlugin/Clients/DNS/DnsProviderFactory.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ public static IDnsProvider Create(AcmeClientConfig config, ILogger logger)
1515
case "google":
1616
return new GoogleDnsProvider(
1717
config.Google_ServiceAccountKeyPath,
18+
config.Google_ServiceAccountKeyJson,
1819
config.Google_ProjectId
1920
);
2021

AcmeCaPlugin/Clients/DNS/GoogleDnsProvider.cs

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
/// <summary>
1111
/// Google Cloud DNS provider implementation for managing DNS TXT records.
12-
/// Supports explicit Service Account key or Workload Identity (Application Default Credentials).
12+
/// Supports explicit Service Account key (file or JSON), or Workload Identity (Application Default Credentials).
1313
/// </summary>
1414
public class GoogleDnsProvider : IDnsProvider
1515
{
@@ -18,19 +18,26 @@ public class GoogleDnsProvider : IDnsProvider
1818

1919
/// <summary>
2020
/// Initializes a new instance of the GoogleDnsProvider class.
21-
/// If serviceAccountKeyPath is null or empty, uses Application Default Credentials.
21+
/// Credential resolution order: JSON key > File path > Application Default Credentials.
2222
/// </summary>
2323
/// <param name="serviceAccountKeyPath">Path to the Service Account JSON key file (optional)</param>
24+
/// <param name="serviceAccountKeyJson">Service Account JSON key as a string (optional, for containerized deployments)</param>
2425
/// <param name="projectId">Google Cloud project ID containing the DNS zones</param>
25-
public GoogleDnsProvider(string? serviceAccountKeyPath, string projectId)
26+
public GoogleDnsProvider(string? serviceAccountKeyPath, string? serviceAccountKeyJson, string projectId)
2627
{
2728
_projectId = projectId;
2829

2930
GoogleCredential credential;
3031

31-
if (!string.IsNullOrWhiteSpace(serviceAccountKeyPath))
32+
if (!string.IsNullOrWhiteSpace(serviceAccountKeyJson))
3233
{
33-
Console.WriteLine("✅ Using explicit Service Account JSON key.");
34+
// JSON key provided directly (for container deployments)
35+
Console.WriteLine("✅ Using Service Account JSON key from configuration.");
36+
credential = GoogleCredential.FromJson(serviceAccountKeyJson);
37+
}
38+
else if (!string.IsNullOrWhiteSpace(serviceAccountKeyPath))
39+
{
40+
Console.WriteLine("✅ Using Service Account JSON key from file.");
3441
credential = GoogleCredential.FromFile(serviceAccountKeyPath);
3542
}
3643
else

0 commit comments

Comments
 (0)