Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
72c4d23
move service attribute
nabsul Jun 27, 2025
9b349dd
Refactor dependency injection logic in Program.cs
nabsul Jun 27, 2025
c746294
Cleanup some config stuff
nabsul Jun 27, 2025
a3f5fbb
More config simplifying
nabsul Jun 27, 2025
20edea6
Remove custom attributes
nabsul Jun 27, 2025
a8e19d1
Simplify helm charts
nabsul Jun 28, 2025
3005194
Cleanup KCert charts
nabsul Jun 28, 2025
ccb5815
Simplify extensions and http challenge route
nabsul Jun 28, 2025
07237ed
Clean up the challenge handlers
nabsul Jun 29, 2025
2537835
Simplify kubernetes client generation, and AllConfig
nabsul Jun 29, 2025
eb2d5e6
Refactor config location and k8s client iteration logic
nabsul Jun 30, 2025
096139f
Tweak
nabsul Jun 30, 2025
717d49a
Cleanup update tls secret
nabsul Jun 30, 2025
d89c899
Refactor AcmeClient to simplify some things
nabsul Jun 30, 2025
0afa326
Cancellation tokens everywhere
nabsul Jul 1, 2025
8e25f24
Replace custom retry class with Polly
nabsul Jul 1, 2025
673b4f3
Make changes to account for dependency injection transient scope
nabsul Jul 1, 2025
2b90cfb
Use Semaphores and cleanup some more code
nabsul Jul 1, 2025
49d978e
Minor fixes to the code
nabsul Sep 16, 2025
726639b
Fix nonce and location header handling
nabsul Sep 16, 2025
e994ec0
fix indentation
nabsul Sep 16, 2025
c778fe2
remove quotes around kubectl service name
nabsul Sep 16, 2025
a784da0
Add small test app for deploying in cluster
nabsul Sep 16, 2025
2463f54
Add files for creating test environment
nabsul Sep 16, 2025
8f02a63
Start moving to gateway api
nabsul Oct 6, 2025
6981a9f
Check in intermediate work
nabsul Dec 31, 2025
f333e45
Add l2 advertisement
nabsul Dec 31, 2025
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: 3 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,6 @@ appsettings.local.json
.vscode
temp
*.tgz

# Jetbrains Rider
.idea
test.yml
testing/kcert-*
values.yaml
22 changes: 4 additions & 18 deletions Challenge/AwsRoute53Provider.cs
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
namespace KCert.Challenge;

using Amazon;
using Amazon.Route53;
using Amazon.Route53.Model;
using KCert.Models;
using KCert.Services;

namespace KCert.Challenge;

[Challenge("route53")]
public class AwsRoute53Provider(KCertConfig cfg, DnsUtils util, ILogger<AwsRoute53Provider> log) : IChallengeProvider
{
public string AcmeChallengeType => "dns-01";
private readonly AmazonRoute53Client _client = GetClient(cfg.Route53AccessKeyId, cfg.Route53SecretAccessKey, cfg.Route53Region);
private record AwsRoute53State(string HostedZoneId, string Domain, string RecordName, string RecordValue);

public async Task<object?> PrepareChallengesAsync(IEnumerable<AcmeAuthzResponse> auths, CancellationToken tok)
Expand Down Expand Up @@ -50,8 +51,6 @@ private static AmazonRoute53Client GetClient(string id, string key, string regio
return new AmazonRoute53Client(awsCredentials, RegionEndpoint.GetBySystemName(region));
}

private readonly AmazonRoute53Client _client = GetClient(cfg.Route53AccessKeyId, cfg.Route53SecretAccessKey, cfg.Route53Region);

private async Task<string> GetHostedZoneIdAsync(string domainName, CancellationToken tok)
{
var zonesResponse = await _client.ListHostedZonesAsync(tok);
Expand Down Expand Up @@ -118,7 +117,7 @@ public async Task DeleteTxtRecordAsync(string hostedZoneId, string recordName, s
{
Name = recordName,
Type = RRType.TXT,
TTL = 60,
TTL = 60,
ResourceRecords = [new ResourceRecord { Value = properlyQuotedValue }]
}
};
Expand All @@ -135,19 +134,6 @@ public async Task DeleteTxtRecordAsync(string hostedZoneId, string recordName, s
var response = await _client.ChangeResourceRecordSetsAsync(request, tok);
log.LogInformation("Successfully sent request to delete TXT record {recordName}. Status: {response.ChangeInfo.Status}, ID: {response.ChangeInfo.Id}", recordName, response.ChangeInfo.Status, response.ChangeInfo.Id);
}
catch (InvalidChangeBatchException ex)
{
// This can happen if the record doesn't exist or doesn't match.
// Check if it's a "tried to delete resource record set that does not exist" error
if (ex.Message.Contains("tried to delete resource record set") && ex.Message.Contains("but it was not found"))
{
log.LogWarning("Attempted to delete TXT record {recordName} but it was not found or already deleted. This may be normal.", recordName);
}
else
{
log.LogError(ex, "Error deleting TXT record {recordName} in zone {hostedZoneId}. InvalidChangeBatchException.", recordName, hostedZoneId);
}
}
catch (Exception ex)
{
throw new Exception($"Error deleting TXT record {recordName} in zone {hostedZoneId}", ex);
Expand Down
7 changes: 0 additions & 7 deletions Challenge/ChallengeAttribute.cs

This file was deleted.

156 changes: 40 additions & 116 deletions Challenge/CloudflareProvider.cs
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
namespace KCert.Challenge;

using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Collections.Concurrent;
using KCert.Services;
using KCert.Models;
using KCert.Services;

namespace KCert.Challenge;

[Challenge("cloudflare")]
public class CloudflareProvider(KCertConfig cfg, DnsUtils util, ILogger<CloudflareProvider> log) : IChallengeProvider
{
public string AcmeChallengeType => "dns-01";
Expand Down Expand Up @@ -45,9 +42,7 @@ public async Task CleanupChallengeAsync(object? state, CancellationToken tok)
}
}


private readonly HttpClient _httpClient = GetHttpClient(cfg);
private static readonly ConcurrentDictionary<string, string> _zoneIdCache = new();
private static readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true };

private static HttpClient GetHttpClient(KCertConfig cfg)
Expand All @@ -71,145 +66,74 @@ private async Task<string> GetZoneIdAsync(string domainName, CancellationToken t
var registrableDomain = domainName; // Default for single-label domains or if extraction fails
if (parts.Length >= 2)
{
registrableDomain = $"{parts[parts.Length - 2]}.{parts[parts.Length - 1]}";
}

if (_zoneIdCache.TryGetValue(registrableDomain, out var cachedZoneId))
{
log.LogDebug("Cache hit for zone ID: {ZoneId} for domain: {Domain}", cachedZoneId, registrableDomain);
return cachedZoneId;
registrableDomain = $"{parts[^2]}.{parts[^1]}";
}

log.LogDebug("Attempting to find zone ID for domain: {Domain}", registrableDomain);
var response = await _httpClient.GetAsync($"zones?name={registrableDomain}", tok);
if (!response.IsSuccessStatusCode)
var resp = await GetAsync<JsonDocument>($"zones?name={registrableDomain}", tok);
try
{
var errorBody = await response.Content.ReadAsStringAsync(tok);
throw new Exception($"Error fetching zone ID for {registrableDomain}. Status: {response.StatusCode}. Body: {errorBody}");
var zoneId = resp!.RootElement.GetProperty("Result")[0].GetProperty("Id").GetString()
?? throw new InvalidOperationException("Zone ID not found in response.");
log.LogInformation("Found zone ID: {ZoneId} for domain: {Domain}", zoneId, registrableDomain);
return zoneId;
}

var content = await response.Content.ReadAsStringAsync(tok);
var cfResponse = JsonSerializer.Deserialize<CloudflareZonesResponse>(content, _jsonOptions);

if (cfResponse?.Result == null || !cfResponse.Result.Any())
catch (Exception ex)
{
throw new Exception($"No zone found for domain: {registrableDomain}");
throw new InvalidOperationException($"No zone found for domain {domainName}. Response: {JsonSerializer.Serialize(resp)}.", ex);
}

// Assuming the first result is the correct one if multiple are returned (shouldn't happen for exact name match)
var zoneId = cfResponse.Result.First().Id ?? throw new InvalidOperationException($"No zone ID found for domain {registrableDomain} in Cloudflare response.");
log.LogInformation("Found zone ID: {ZoneId} for domain: {Domain}", zoneId, registrableDomain);
_zoneIdCache.TryAdd(registrableDomain, zoneId);
return zoneId;
}

public async Task<string> CreateTxtRecordAsync(string domainName, string recordName, string recordValue, CancellationToken tok)
{
var zoneId = await GetZoneIdAsync(domainName, tok);
var payload = new CloudflareDnsRequest
var payload = new
{
Type = "TXT",
Name = recordName, // Cloudflare expects the full record name
Content = recordValue,
Ttl = 120 // Common for ACME challenges, min is 60 or 120 depending on plan
type = "TXT",
name = recordName, // Cloudflare expects the full record name
content = recordValue,
ttl = 120 // Common for ACME challenges, min is 60 or 120 depending on plan
};

var jsonPayload = JsonSerializer.Serialize(payload);
var httpContent = new StringContent(jsonPayload, Encoding.UTF8, "application/json");

log.LogInformation("Attempting to create TXT record: {RecordName} with value: {RecordValue} in zone {ZoneId}", recordName, recordValue, zoneId);
var response = await _httpClient.PostAsync($"zones/{zoneId}/dns_records", httpContent, tok);
var responseBody = await response.Content.ReadAsStringAsync(tok);

if (!response.IsSuccessStatusCode)
{
throw new Exception($"Error creating TXT record {recordName}. Status: {response.StatusCode}. Response: {responseBody}");
}

await PostAsync<JsonDocument>($"zones/{zoneId}/dns_records", payload, tok);
return zoneId;
}

public async Task DeleteTxtRecordAsync(string zoneId, string domainName, string recordName, string recordValue, CancellationToken tok)
{
// Note: Cloudflare API expects the 'name' to be the FQDN of the record.
log.LogDebug("Attempting to find DNS record ID for Name: {RecordName}, Content: {RecordValue} in zone {ZoneId}", recordName, recordValue, zoneId);
var listResponse = await _httpClient.GetAsync($"zones/{zoneId}/dns_records?type=TXT&name={recordName}&content={recordValue}", tok);

if (!listResponse.IsSuccessStatusCode)
{
var errorBody = await listResponse.Content.ReadAsStringAsync(tok);
throw new Exception($"Error listing DNS records to find ID for {recordName}. Status: {listResponse.StatusCode}. Body: {errorBody}");
}

var listContent = await listResponse.Content.ReadAsStringAsync(tok);
var cfListResponse = JsonSerializer.Deserialize<CloudflareDnsListResponse>(listContent, _jsonOptions);

var result = cfListResponse?.Result;
if (result == null || result.Count == 0)
{
throw new Exception($"No TXT record found for Name: {recordName} and Content: {recordValue} in zone {zoneId}. It might have been already deleted.");
}

var recordId = result.First().Id; // Assuming first one is the match
if (string.IsNullOrEmpty(recordId))
{
throw new Exception($"TXT record ID for {recordName} not found, cannot delete.");
}
var resp = await GetAsync<JsonDocument>($"zones/{zoneId}/dns_records?type=TXT&name={recordName}", tok);
var recordId = resp?.RootElement.GetProperty("Result")[0].GetProperty("Id").GetString()
?? throw new InvalidOperationException($"No TXT record found for {recordName} in zone {zoneId}. With response: {JsonSerializer.Serialize(resp)}");

log.LogInformation("Found DNS record ID: {RecordId} for {RecordName}", recordId, recordName);

log.LogInformation("Attempting to delete TXT record ID: {RecordId} ({RecordName}) in zone {ZoneId}", recordId, recordName, zoneId);
var deleteResponse = await _httpClient.DeleteAsync($"zones/{zoneId}/dns_records/{recordId}", tok);
var deleteResponseBody = await deleteResponse.Content.ReadAsStringAsync(tok);

if (!deleteResponse.IsSuccessStatusCode)
{
throw new Exception($"Error deleting TXT record ID: {recordId}. Status: {deleteResponse.StatusCode}. Response: {deleteResponseBody}");
}

log.LogInformation("Successfully deleted TXT record ID: {RecordId}. Response: {ResponseBody}", recordId, deleteResponseBody);
var del = await DeleteAsync<JsonDocument>($"zones/{zoneId}/dns_records/{recordId}", tok);
log.LogInformation("Successfully deleted TXT record ID: {RecordId}. Response: {ResponseBody}", recordId, JsonSerializer.Serialize(del));
}

// Helper classes for JSON deserialization
private async Task<T?> GetAsync<T>(string path, CancellationToken tok) where T : class => await RequestAsync<T>(HttpMethod.Get, path, null, tok);

private class CloudflareZonesResponse
{
public List<CloudflareZone>? Result { get; set; }
public bool Success { get; set; }
// public List<object>? Errors { get; set; } // Can be added for more detailed error handling
// public List<object>? Messages { get; set; }
}
private async Task<T?> PostAsync<T>(string path, object payload, CancellationToken tok) where T : class => await RequestAsync<T>(HttpMethod.Post, path, payload, tok);

private class CloudflareZone
{
public string? Id { get; set; }
public string? Name { get; set; }
}
private Task<T?> DeleteAsync<T>(string path, CancellationToken tok) where T : class => RequestAsync<T>(HttpMethod.Delete, path, null, tok);

private class CloudflareDnsRequest
private async Task<T?> RequestAsync<T>(HttpMethod method, string path, object? payload, CancellationToken tok) where T : class
{
[JsonPropertyName("type")]
public string Type { get; set; } = "TXT";
[JsonPropertyName("name")]
public string? Name { get; set; }
[JsonPropertyName("content")]
public string? Content { get; set; }
[JsonPropertyName("ttl")]
public int Ttl { get; set; } = 120; // Default TTL
// proxied is not applicable for TXT records
}
using var request = new HttpRequestMessage(method, path);
if (payload != null)
{
var jsonPayload = JsonSerializer.Serialize(payload, _jsonOptions);
request.Content = new StringContent(jsonPayload, Encoding.UTF8, "application/json");
}

private class CloudflareDnsListResponse
{
public List<CloudflareDnsRecord>? Result { get; set; }
public bool Success { get; set; }
}
using var response = await _httpClient.SendAsync(request, tok);
if (!response.IsSuccessStatusCode)
{
var errorBody = await response.Content.ReadAsStringAsync(tok);
throw new Exception($"Error {method}ing data to {path}. Status: {response.StatusCode}. Body: {errorBody}");
}

private class CloudflareDnsRecord
{
public string? Id { get; set; }
public string? Name { get; set; }
public string? Content { get; set; }
// other fields like type, zone_id, zone_name etc. can be added if needed
return await JsonSerializer.DeserializeAsync<T>(response.Content.ReadAsStream(tok), _jsonOptions, tok);
}
}
5 changes: 2 additions & 3 deletions Challenge/DnsUtils.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
namespace KCert.Challenge;

using System.Security.Cryptography;
using System.Text;
using KCert.Models;
using KCert.Services;
using Microsoft.AspNetCore.Authentication;

namespace KCert.Challenge;

[Service]
public class DnsUtils(CertClient cert)
{
public string StripWildcard(string domain) => domain.StartsWith("*.") ? domain[2..] : domain;
Expand Down
33 changes: 17 additions & 16 deletions Challenge/HttpChallengeProvider.cs
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
namespace KCert.Challenge;

using k8s.Models;
using KCert.Models;
using KCert.Services;

namespace KCert.Challenge;

[Challenge("http")]
public class HttpChallengeProvider(K8sClient kube, KCertConfig cfg, ILogger<HttpChallengeProvider> log) : IChallengeProvider
public class HttpChallengeProvider(K8sClient kube, KCertConfig cfg, ILogger<HttpChallengeProvider> log, CertClient cert) : IChallengeProvider
{
public string AcmeChallengeType => "http-01";

private record HttpChallengeState(string[] Hosts);

public string HandleChallenge(string token)
{
log.LogInformation("Received ACME Challenge: {token}", token);

Check failure

Code scanning / CodeQL

Log entries created from user input High

This log entry depends on a
user-provided value
.

Copilot Autofix

AI 4 months ago

To fix the issue, sanitize the token string before logging it. Because logs are typically viewed as text, the primary concern is controlling newline and control characters. The simplest approach is to replace all carriage returns (\r) and linefeeds (\n) in the token string with empty strings before logging. This should be done inline in the call to LogInformation in Challenge/HttpChallengeProvider.cs line 15. No additional dependencies are required; use string.Replace methods. Only the line logging the token needs changing. No logic changes are needed, and the return value remains unsanitized (as it is part of the ACME protocol).

Suggested changeset 1
Challenge/HttpChallengeProvider.cs

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/Challenge/HttpChallengeProvider.cs b/Challenge/HttpChallengeProvider.cs
--- a/Challenge/HttpChallengeProvider.cs
+++ b/Challenge/HttpChallengeProvider.cs
@@ -12,7 +12,7 @@
 
     public string HandleChallenge(string token)
     {
-        log.LogInformation("Received ACME Challenge: {token}", token);
+        log.LogInformation("Received ACME Challenge: {token}", token.Replace("\r", "").Replace("\n", ""));
         var thumbprint = cert.GetThumbprint();
         return $"{token}.{thumbprint}";
     }
EOF
@@ -12,7 +12,7 @@

public string HandleChallenge(string token)
{
log.LogInformation("Received ACME Challenge: {token}", token);
log.LogInformation("Received ACME Challenge: {token}", token.Replace("\r", "").Replace("\n", ""));
var thumbprint = cert.GetThumbprint();
return $"{token}.{thumbprint}";
}
Copilot is powered by AI and may make mistakes. Always verify output.
var thumbprint = cert.GetThumbprint();
return $"{token}.{thumbprint}";
}

public async Task<object?> PrepareChallengesAsync(IEnumerable<AcmeAuthzResponse> auths, CancellationToken tok)
{
var hosts = auths.Select(auth => auth.Identifier.Value).ToArray();
Expand Down Expand Up @@ -51,22 +57,15 @@
}
};

if (cfg.UseChallengeIngressClassName)
if (string.IsNullOrWhiteSpace(cfg.ChallengeIngressClassName) is false)
{
kcertIngress.Spec.IngressClassName = cfg.ChallengeIngressClassName;
}

if (cfg.UseChallengeIngressAnnotations)
{
kcertIngress.Metadata.Annotations = cfg.ChallengeIngressAnnotations;
}

if (cfg.UseChallengeIngressLabels)
{
kcertIngress.Metadata.Labels = cfg.ChallengeIngressLabels;
}
kcertIngress.Metadata.Annotations = cfg.ChallengeIngressAnnotations;
kcertIngress.Metadata.Labels = cfg.ChallengeIngressLabels;

await kube.CreateIngressAsync(kcertIngress);
await kube.CreateIngressAsync(kcertIngress, tok);
log.LogInformation("Giving challenge ingress time to propagate");

if (!cfg.SkipIngressPropagationCheck)
Expand All @@ -75,7 +74,9 @@
}
else
{
await Task.Delay(cfg.ChallengeIngressMaxPropagationWaitTime);
var waitTime = cfg.ChallengeIngressMaxPropagationWaitTime;
log.LogInformation("Skipping ingress propagation check and waiting configured time: {waitTime}", waitTime);
await Task.Delay(waitTime, tok);
}
}

Expand Down
4 changes: 2 additions & 2 deletions Challenge/IChallengeProvider.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
using KCert.Models;

namespace KCert.Challenge;

using KCert.Models;

public interface IChallengeProvider
{
string AcmeChallengeType { get; }
Expand Down
Loading
Loading