diff --git a/.gitignore b/.gitignore index 5b050f5..b2b5171 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,6 @@ appsettings.local.json .vscode temp *.tgz - -# Jetbrains Rider -.idea \ No newline at end of file +test.yml +testing/kcert-* +values.yaml diff --git a/Challenge/AwsRoute53Provider.cs b/Challenge/AwsRoute53Provider.cs index 82f62e6..2c712e8 100644 --- a/Challenge/AwsRoute53Provider.cs +++ b/Challenge/AwsRoute53Provider.cs @@ -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 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 PrepareChallengesAsync(IEnumerable auths, CancellationToken tok) @@ -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 GetHostedZoneIdAsync(string domainName, CancellationToken tok) { var zonesResponse = await _client.ListHostedZonesAsync(tok); @@ -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 }] } }; @@ -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); diff --git a/Challenge/ChallengeAttribute.cs b/Challenge/ChallengeAttribute.cs deleted file mode 100644 index 376d13e..0000000 --- a/Challenge/ChallengeAttribute.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace KCert.Challenge; - -[AttributeUsage(AttributeTargets.Class)] -public class ChallengeAttribute(string type) : Attribute -{ - public string ChallengeType { get; } = type; -} diff --git a/Challenge/CloudflareProvider.cs b/Challenge/CloudflareProvider.cs index 767c16c..c6a323b 100644 --- a/Challenge/CloudflareProvider.cs +++ b/Challenge/CloudflareProvider.cs @@ -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 log) : IChallengeProvider { public string AcmeChallengeType => "dns-01"; @@ -45,9 +42,7 @@ public async Task CleanupChallengeAsync(object? state, CancellationToken tok) } } - private readonly HttpClient _httpClient = GetHttpClient(cfg); - private static readonly ConcurrentDictionary _zoneIdCache = new(); private static readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true }; private static HttpClient GetHttpClient(KCertConfig cfg) @@ -71,145 +66,74 @@ private async Task 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($"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(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 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($"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(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($"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($"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 GetAsync(string path, CancellationToken tok) where T : class => await RequestAsync(HttpMethod.Get, path, null, tok); - private class CloudflareZonesResponse - { - public List? Result { get; set; } - public bool Success { get; set; } - // public List? Errors { get; set; } // Can be added for more detailed error handling - // public List? Messages { get; set; } - } + private async Task PostAsync(string path, object payload, CancellationToken tok) where T : class => await RequestAsync(HttpMethod.Post, path, payload, tok); - private class CloudflareZone - { - public string? Id { get; set; } - public string? Name { get; set; } - } + private Task DeleteAsync(string path, CancellationToken tok) where T : class => RequestAsync(HttpMethod.Delete, path, null, tok); - private class CloudflareDnsRequest + private async Task RequestAsync(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? 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(response.Content.ReadAsStream(tok), _jsonOptions, tok); } } diff --git a/Challenge/DnsUtils.cs b/Challenge/DnsUtils.cs index 03a2b7b..a58c820 100644 --- a/Challenge/DnsUtils.cs +++ b/Challenge/DnsUtils.cs @@ -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; diff --git a/Challenge/HttpChallengeProvider.cs b/Challenge/HttpChallengeProvider.cs index 0fa6d60..d16c740 100644 --- a/Challenge/HttpChallengeProvider.cs +++ b/Challenge/HttpChallengeProvider.cs @@ -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 log) : IChallengeProvider +public class HttpChallengeProvider(K8sClient kube, KCertConfig cfg, ILogger 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); + var thumbprint = cert.GetThumbprint(); + return $"{token}.{thumbprint}"; + } + public async Task PrepareChallengesAsync(IEnumerable auths, CancellationToken tok) { var hosts = auths.Select(auth => auth.Identifier.Value).ToArray(); @@ -51,22 +57,15 @@ private async Task AddChallengeHostsAsync(IEnumerable hosts, Cancellatio } }; - 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) @@ -75,7 +74,9 @@ private async Task AddChallengeHostsAsync(IEnumerable hosts, Cancellatio } 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); } } diff --git a/Challenge/IChallengeProvider.cs b/Challenge/IChallengeProvider.cs index 8e69169..c0c3921 100644 --- a/Challenge/IChallengeProvider.cs +++ b/Challenge/IChallengeProvider.cs @@ -1,7 +1,7 @@ -using KCert.Models; - namespace KCert.Challenge; +using KCert.Models; + public interface IChallengeProvider { string AcmeChallengeType { get; } diff --git a/Controllers/HomeController.cs b/Controllers/HomeController.cs index b76e6b0..e2b2822 100644 --- a/Controllers/HomeController.cs +++ b/Controllers/HomeController.cs @@ -5,22 +5,20 @@ namespace KCert.Controllers; [Route("")] -public class HomeController(KCertClient kcert, K8sClient kube, KCertConfig cfg, EmailClient email, AcmeClient acme, CertClient cert) : Controller +public class HomeController(KCertClient kcert, K8sClient kube, KCertConfig cfg, EmailClient email, CertClient cert) : Controller { - private static string? TermsOfServiceUrl = null; - [HttpGet("")] - public async Task HomeAsync() + public async Task HomeAsync(CancellationToken tok) { - var secrets = await kube.GetManagedSecretsAsync().ToListAsync(); + var secrets = await kube.GetManagedSecretsAsync(tok).ToListAsync(); return View(secrets); } [HttpGet("ingresses")] - public async Task IngressesAsync() + public async Task IngressesAsync(CancellationToken tok) { var ingresses = new List(); - await foreach (var i in kube.GetAllIngressesAsync()) + await foreach (var i in kube.GetAllIngressesAsync(tok)) { ingresses.Add(i); } @@ -36,24 +34,23 @@ public async Task ChallengeAsync(CancellationToken tok) } [HttpGet("configuration")] - public async Task ConfigurationAsync() + public IActionResult Configuration() { - TermsOfServiceUrl ??= await acme.GetTermsOfServiceUrlAsync(); - ViewBag.TermsOfService = TermsOfServiceUrl; + ViewBag.TermsOfService = AcmeClient.Dir.Meta.TermsOfService; return View(); } [HttpGet("test-email")] - public async Task TestEmailAsync() + public async Task TestEmailAsync(CancellationToken tok) { - await email.SendTestEmailAsync(); + await email.SendTestEmailAsync(tok); return RedirectToAction("Configuration"); } [HttpGet("renew/{ns}/{name}")] - public async Task RenewAsync(string ns, string name) + public async Task RenewAsync(string ns, string name, CancellationToken tok) { - var secret = await kube.GetSecretAsync(ns, name); + var secret = await kube.GetSecretAsync(ns, name, tok); if (secret == null) { return NotFound(); @@ -62,7 +59,7 @@ public async Task RenewAsync(string ns, string name) var certVal = cert.GetCert(secret); var hosts = cert.GetHosts(certVal).ToArray(); - await kcert.StartRenewalProcessAsync(ns, name, hosts, CancellationToken.None); + await kcert.StartRenewalProcessAsync(ns, name, hosts, tok); return RedirectToAction("Home"); } } diff --git a/Controllers/HttpChallengeController.cs b/Controllers/HttpChallengeController.cs index f18d771..e69de29 100644 --- a/Controllers/HttpChallengeController.cs +++ b/Controllers/HttpChallengeController.cs @@ -1,17 +0,0 @@ -using KCert.Services; -using Microsoft.AspNetCore.Mvc; - -namespace KCert.Controllers; - -[Route(".well-known/acme-challenge")] -public class HttpChallengeController(ILogger log, CertClient cert) : Controller -{ - - [HttpGet("{token}")] - public IActionResult GetChallengeResults(string token) - { - log.LogInformation("Received ACME Challenge: {token}", token); - var thumb = cert.GetThumbprint(); - return Ok($"{token}.{thumb}"); - } -} diff --git a/Extensions.cs b/Extensions.cs new file mode 100644 index 0000000..e5a3ff0 --- /dev/null +++ b/Extensions.cs @@ -0,0 +1,44 @@ +using k8s; +using KCert.Challenge; +using KCert.Services; + +namespace KCert; + +public static class Extensions +{ + private static readonly Dictionary ChallengeTypes = new() + { + { "http", typeof(HttpChallengeProvider) }, + { "route53", typeof(AwsRoute53Provider) }, + { "cloudflare", typeof(CloudflareProvider) } + }; + + public static IServiceCollection AddKCertServices(this IServiceCollection services, KCertConfig cfg) + { + if (!ChallengeTypes.TryGetValue(cfg.ChallengeType, out var challengeType)) + { + throw new NotSupportedException($"Challenge type '{cfg.ChallengeType}' is not supported."); + } + + return services + .AddSingleton() + .AddSingleton(challengeType) + .AddSingleton(typeof(IChallengeProvider), s => s.GetRequiredService(challengeType)) + .AddSingleton(cfg) + .AddSingleton(new Kubernetes(KubernetesClientConfiguration.BuildDefaultConfig())) + .AddTransient() + .AddTransient() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddHostedService() + .AddHostedService() + .AddHostedService(); + } +} diff --git a/KCert.csproj b/KCert.csproj index c022295..b615dfd 100644 --- a/KCert.csproj +++ b/KCert.csproj @@ -8,9 +8,15 @@ - + + + - + + + + + diff --git a/Models/AcmeResponse.cs b/Models/AcmeResponse.cs index 30c90c3..ff69502 100644 --- a/Models/AcmeResponse.cs +++ b/Models/AcmeResponse.cs @@ -4,9 +4,6 @@ namespace KCert.Models; public class AcmeResponse { - [JsonIgnore] - public string Nonce { get; set; } = default!; - [JsonIgnore] public string Location { get; set; } = default!; } diff --git a/Program.cs b/Program.cs index 8f009b5..5436119 100644 --- a/Program.cs +++ b/Program.cs @@ -1,7 +1,6 @@ using KCert; using KCert.Challenge; using KCert.Services; -using System.Reflection; // command line option for manually generating an ECDSA key if (args.Length > 0 && args[^1] == "generate-key") @@ -13,46 +12,45 @@ } var builder = WebApplication.CreateBuilder(args); +Console.WriteLine($"KCert starting with environment {builder.Environment.EnvironmentName}..."); +var cfg = new KCertConfig(builder.Configuration); -// Add all services marked with the [Service] attribute -foreach (var t in Assembly.GetExecutingAssembly().GetTypes()) -{ - if (t.GetCustomAttribute() is not null) - { - builder.Services.AddSingleton(t); - } - else if (t.GetCustomAttribute() is ChallengeAttribute attr && attr.ChallengeType != builder.Configuration.GetValue("KCert:ChallengeType")) - { - builder.Services.AddSingleton(typeof(IChallengeProvider), t); - } -} - -builder.Services.AddSingleton(s => s.GetRequiredService().GetClient()); builder.Services.AddConnections(); builder.Services.AddControllersWithViews(); +builder.Services.AddKCertServices(cfg); +var useHttpChallenge = cfg.ChallengeType == "http"; builder.WebHost.ConfigureKestrel(opt => { - opt.ListenAnyIP(80); + if (useHttpChallenge) + { + opt.ListenAnyIP(80); + } opt.ListenAnyIP(8080); }); -// add background services -builder.Services.AddHostedService(); -builder.Services.AddHostedService(); -builder.Services.AddHostedService(); - var app = builder.Build(); +await AcmeClient.ReadDirectoryAsync(cfg, app.Lifetime.ApplicationStopped); + +// Port 8080: Full admin interface with static files and all controllers app.MapWhen(c => c.Connection.LocalPort == 8080, b => { b.UseStaticFiles(); b.UseRouting().UseEndpoints(e => e.MapControllers()); }); -app.MapWhen(c => c.Connection.LocalPort == 80 && c.Request.Path.HasValue && c.Request.Path.Value.StartsWith("/.well-known/acme-challenge"), b => +if (useHttpChallenge) { - b.UseRouting().UseEndpoints(e => e.MapControllers()); -}); + // Port 80: Simple ACME challenge handler + app.MapWhen(c => c.Connection.LocalPort == 80, b => + { + b.UseRouting(); + b.UseEndpoints(endpoints => + { + endpoints.MapGet("/.well-known/acme-challenge/{token}", (string token, HttpChallengeProvider c) => c.HandleChallenge(token)); + }); + }); +} app.Run(); diff --git a/ServiceAttribute.cs b/ServiceAttribute.cs deleted file mode 100644 index c121cb2..0000000 --- a/ServiceAttribute.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace KCert; - -[AttributeUsage(AttributeTargets.Class)] -public class ServiceAttribute : Attribute -{ -} diff --git a/Services/AcmeClient.cs b/Services/AcmeClient.cs index 9cbdb9b..b171d7d 100644 --- a/Services/AcmeClient.cs +++ b/Services/AcmeClient.cs @@ -7,8 +7,7 @@ namespace KCert.Services; -[Service] -public class AcmeClient(CertClient cert, KCertConfig cfg) +public class AcmeClient(CertClient cert, KCertConfig cfg, ILogger log) { private const string HeaderReplayNonce = "Replay-Nonce"; private const string HeaderLocation = "Location"; @@ -19,45 +18,52 @@ public class AcmeClient(CertClient cert, KCertConfig cfg) private readonly HttpClient _http = new(); - private AcmeDirectoryResponse? _dir = null; + private static AcmeDirectoryResponse? _dir; + public static AcmeDirectoryResponse Dir => _dir ?? throw new Exception("ACME directory not initialized."); - public Task DeactivateAccountAsync(string key, string kid, string nonce) => PostAsync(key, new Uri(kid), new { status = "deactivated" }, kid, nonce); - public Task GetOrderAsync(string key, Uri uri, string kid, string nonce) => PostAsync(key, uri, null, kid, nonce); - public Task GetAuthzAsync(string key, Uri authzUri, string kid, string nonce) => PostAsync(key, authzUri, null, kid, nonce); - public Task TriggerChallengeAsync(string key, Uri challengeUri, string kid, string nonce) => PostAsync(key, challengeUri, new { }, kid, nonce); + private string _nonce = string.Empty; - public async Task ReadDirectoryAsync(Uri dirUri) + private void SaveNonce(HttpResponseMessage resp) { - using var resp = await _http.GetAsync(dirUri); + var nonce = resp.Headers.GetValues(HeaderReplayNonce).FirstOrDefault(); + if (nonce != null) + { + _nonce = nonce; + } + } + + public Task DeactivateAccountAsync(string key, string kid, CancellationToken tok) => PostAsync(key, new Uri(kid), new { status = "deactivated" }, kid, tok); + public Task GetOrderAsync(string key, Uri uri, string kid, CancellationToken tok) => PostAsync(key, uri, null, kid, tok); + public Task GetAuthzAsync(string key, Uri authzUri, string kid, CancellationToken tok) => PostAsync(key, authzUri, null, kid, tok); + public Task TriggerChallengeAsync(string key, Uri challengeUri, string kid, CancellationToken tok) => PostAsync(key, challengeUri, new { }, kid, tok); + + public static async Task ReadDirectoryAsync(KCertConfig cfg, CancellationToken tok) + { + using var http = new HttpClient(); + using var resp = await http.GetAsync(cfg.AcmeDir, tok); if (!resp.IsSuccessStatusCode) { - var result = await resp.Content.ReadAsStringAsync(); + var result = await resp.Content.ReadAsStringAsync(tok); var message = $"Failed to read ACME directory with error response code {resp.StatusCode} and message: {result}"; throw new Exception(message); } - using var stream = await resp.Content.ReadAsStreamAsync(); - _dir = await JsonSerializer.DeserializeAsync(stream, options); - } - - public async Task GetTermsOfServiceUrlAsync() - { - await ReadDirectoryAsync(new Uri("https://acme-v02.api.letsencrypt.org/directory")); - return _dir?.Meta?.TermsOfService ?? throw new Exception("_dir should be defined here"); + using var stream = await resp.Content.ReadAsStreamAsync(tok); + _dir = await JsonSerializer.DeserializeAsync(stream, options, tok); } - public async Task CreateAccountAsync(string nonce) + public async Task CreateAccountAsync(CancellationToken tok) { - var uri = new Uri(_dir?.NewAccount ?? throw new Exception("_dir should be defined here")); + var uri = new Uri(Dir.NewAccount); var payload = GetAccountRequestPayload(uri); - return await PostAsync(cfg.AcmeKey, uri, payload, nonce); + return await PostAsync(cfg.AcmeKey, uri, payload, tok); } private object GetAccountRequestPayload(Uri uri) { var contact = new[] { $"mailto:{cfg.AcmeEmail}" }; - if (cfg.AcmeEabKeyId == null || cfg.AcmeHmacKey == null) + if (!cfg.UseEabKey) { return new { contact, termsOfServiceAgreed = cfg.AcmeAccepted }; } @@ -99,44 +105,47 @@ private static string GetSignatureUsingHMAC(string text, string key) return Base64UrlTextEncoder.Encode(sig); } - public async Task CreateOrderAsync(string key, string kid, IEnumerable hosts, string nonce) + public async Task CreateOrderAsync(string key, string kid, IEnumerable hosts, CancellationToken tok) { var identifiers = hosts.Select(h => new { type = "dns", value = h }).ToArray(); var payload = new { identifiers }; - var uri = new Uri(_dir?.NewOrder ?? throw new Exception("_dir should be defined here")); - return await PostAsync(key, uri, payload, kid, nonce); + var uri = new Uri(Dir.NewOrder); + return await PostAsync(key, uri, payload, kid, tok); } - public async Task GetCertAsync(string key, Uri certUri, string kid, string nonce) + public async Task GetCertAsync(string key, Uri certUri, string kid, CancellationToken tok) { - var protectedObject = new { alg = Alg, kid, nonce, url = certUri.AbsoluteUri }; - using var resp = await PostAsync(key, certUri, null, protectedObject); - await CheckResponseStatusAsync(resp); - return await resp.Content.ReadAsStringAsync(); + var protectedObject = new { alg = Alg, kid, nonce = _nonce, url = certUri.AbsoluteUri }; + using var resp = await PostAsync(key, certUri, null, protectedObject, tok); + return await GetContentAsync(resp, tok); } - public async Task FinalizeOrderAsync(string key, Uri uri, IEnumerable hosts, string kid, string nonce) + public async Task FinalizeOrderAsync(string key, Uri uri, IEnumerable hosts, string kid, CancellationToken tok) { var csr = cert.GetCsr(hosts); - return await PostAsync(key, uri, new { csr }, kid, nonce); + return await PostAsync(key, uri, new { csr }, kid, tok); } - private async Task PostAsync(string key, Uri uri, object payloadObject, string nonce) where T : AcmeResponse + private async Task PostAsync(string key, Uri uri, object payloadObject, CancellationToken tok) where T : AcmeResponse { var sign = cert.GetSigner(key); - var protectedObject = new { alg = Alg, jwk = cert.GetJwk(sign), nonce, url = uri.AbsoluteUri }; - using var resp = await PostAsync(key, uri, payloadObject, protectedObject); - return await ParseJsonAsync(resp); + var protectedObject = new { alg = Alg, jwk = cert.GetJwk(sign), nonce = _nonce, url = uri.AbsoluteUri }; + return await PostAsync(key, uri, payloadObject, protectedObject, tok); } - private async Task PostAsync(string key, Uri uri, object? payloadObject, string kid, string nonce) where T : AcmeResponse + private async Task PostAsync(string key, Uri uri, object? payloadObject, string kid, CancellationToken tok) where T : AcmeResponse { - var protectedObject = new { alg = Alg, kid, nonce, url = uri.AbsoluteUri }; - using var resp = await PostAsync(key, uri, payloadObject, protectedObject); - return await ParseJsonAsync(resp); + var protectedObject = new { alg = Alg, kid, nonce = _nonce, url = uri.AbsoluteUri }; + return await PostAsync(key, uri, payloadObject, protectedObject, tok); } - private async Task PostAsync(string key, Uri uri, object? payloadObject, object protectedObject) + private async Task PostAsync(string key, Uri uri, object? payloadObject, object protectedObject, CancellationToken tok) where T : AcmeResponse + { + using var resp = await PostAsync(key, uri, payloadObject, protectedObject, tok); + return await ParseJsonAsync(resp, tok); + } + + private async Task PostAsync(string key, Uri uri, object? payloadObject, object protectedObject, CancellationToken tok) { var payloadJson = payloadObject != null ? JsonSerializer.Serialize(payloadObject) : ""; var payload = Base64UrlTextEncoder.Encode(Encoding.UTF8.GetBytes(payloadJson)); @@ -154,45 +163,43 @@ private async Task PostAsync(string key, Uri uri, object? p var content = new StringContent(bodyJson, Encoding.UTF8); content.Headers.ContentType = new MediaTypeHeaderValue(ContentType); - return await _http.PostAsync(uri, content); + return await _http.PostAsync(uri, content, tok); } - private static async Task ParseJsonAsync(HttpResponseMessage resp) where T : AcmeResponse + private async Task ParseJsonAsync(HttpResponseMessage resp, CancellationToken tok) where T : AcmeResponse { - var content = await GetContentAsync(resp); + var content = await GetContentAsync(resp, tok); var result = JsonSerializer.Deserialize(content, options) ?? throw new Exception($"Invalid content: {content}"); - result.Nonce = resp.Headers.GetValues(HeaderReplayNonce).First(); - result.Location = resp.Headers.GetValues(HeaderLocation).First(); + SaveNonce(resp); + if (resp.Headers.TryGetValues(HeaderLocation, out var values) && values.FirstOrDefault() is string loc) + { + result.Location = loc; + } return result; } - public async Task GetNonceAsync() + public async Task InitAsync(CancellationToken tok) { - var uri = new Uri(_dir?.NewNonce ?? throw new Exception("_dir should be defined here")); + var uri = new Uri(Dir.NewNonce); using var message = new HttpRequestMessage(HttpMethod.Head, uri); - using var resp = await _http.SendAsync(message); + using var resp = await _http.SendAsync(message, tok); if (!resp.IsSuccessStatusCode) { - var content = await resp.Content.ReadAsStringAsync(); + var content = await resp.Content.ReadAsStringAsync(tok); throw new Exception($"Unexpected response to get-nonce with status {resp.StatusCode} and content: {content}"); } - return message.Headers.GetValues(HeaderReplayNonce).First(); - } - - private static async Task GetContentAsync(HttpResponseMessage resp) - { - await CheckResponseStatusAsync(resp); - return await resp.Content.ReadAsStringAsync(); + SaveNonce(resp); } - private static async Task CheckResponseStatusAsync(HttpResponseMessage resp) + private static async Task GetContentAsync(HttpResponseMessage resp, CancellationToken tok) { + var content = await resp.Content.ReadAsStringAsync(tok); if (!resp.IsSuccessStatusCode) { - var content = await resp.Content.ReadAsStringAsync(); throw new Exception($"Request failed with status {resp.StatusCode} and content: {content}"); } + return content; } } diff --git a/Services/CertChangeService.cs b/Services/CertChangeService.cs index b04af5a..e987764 100644 --- a/Services/CertChangeService.cs +++ b/Services/CertChangeService.cs @@ -1,8 +1,8 @@ +using System.Runtime.CompilerServices; using k8s.Models; namespace KCert.Services; -[Service] public class CertChangeService(ILogger log, K8sClient k8s, KCertClient kcert) { private DateTime _lastRun = DateTime.MinValue; @@ -14,17 +14,17 @@ public class CertChangeService(ILogger log, K8sClient k8s, KC // - If no check is currently running, it will kick off a check // - If a check is already running, it will queue up another one to run after the current one completes // - However, it will not queue up multiple checks - public void RunCheck() + public void RunCheck(CancellationToken tok) { - _ = CheckForChangesAsync(); + _ = CheckForChangesAsync(tok); } - private async Task CheckForChangesAsync() + private async Task CheckForChangesAsync(CancellationToken tok) { var start = DateTime.UtcNow; log.LogInformation("CheckForChangesAsync: Waiting for semaphore."); - await _sem.WaitAsync(); + await _sem.WaitAsync(tok); if (_lastRun > start) { log.LogInformation("CheckForChangesAsync: Check already queued or recently completed after this request. No need to run this instance."); @@ -38,7 +38,7 @@ private async Task CheckForChangesAsync() try { var nsLookup = new Dictionary<(string Namespace, string Name), HashSet>(); - await foreach (var (ns, name, hosts) in GetIngressCertsAsync().Concat(GetConfigMapCertsAsync())) + await foreach (var (ns, name, hosts) in GetIngressCertsAsync(tok).Concat(GetConfigMapCertsAsync(tok))) { var key = (ns, name); if (!nsLookup.TryGetValue(key, out var currHosts)) @@ -57,7 +57,7 @@ private async Task CheckForChangesAsync() foreach (var ((ns, name), hosts) in nsLookup) { log.LogInformation("Handling cert {ns} - {name} hosts: {h}", ns, name, string.Join(",", hosts)); // Existing log, good as is. - await kcert.RenewIfNeededAsync(ns, name, [.. hosts], CancellationToken.None); + await kcert.RenewIfNeededAsync(ns, name, [.. hosts], tok); } log.LogInformation("CheckForChangesAsync: Check for certificate changes completed."); @@ -73,12 +73,12 @@ private async Task CheckForChangesAsync() } - private async IAsyncEnumerable<(string Namespace, string Name, IEnumerable Hosts)> GetIngressCertsAsync() + private async IAsyncEnumerable<(string Namespace, string Name, IEnumerable Hosts)> GetIngressCertsAsync([EnumeratorCancellation] CancellationToken tok) { int ingressCount = 0; int skipped = 0; int hosts = 0; - await foreach (var ing in k8s.GetAllIngressesAsync()) + await foreach (var ing in k8s.GetAllIngressesAsync(tok)) { ingressCount++; if (ing?.Spec?.Tls == null || !ing.Spec.Tls.Any()) @@ -97,12 +97,12 @@ private async Task CheckForChangesAsync() log.LogInformation("GetIngressCertsAsync: Processed {Count} Ingresses. Skipped {skipped} ingresses. Extracted {numHosts} hosts", ingressCount, skipped, hosts); } - private async IAsyncEnumerable<(string Namespace, string Name, IEnumerable hosts)> GetConfigMapCertsAsync() + private async IAsyncEnumerable<(string Namespace, string Name, IEnumerable hosts)> GetConfigMapCertsAsync([EnumeratorCancellation] CancellationToken tok) { int foundConfigMapsCount = 0; int skipped = 0; int numHosts = 0; - await foreach (var config in k8s.GetAllConfigMapsAsync()) + await foreach (var config in k8s.GetAllConfigMapsAsync(tok)) { foundConfigMapsCount++; var ns = config.Namespace(); diff --git a/Services/CertClient.cs b/Services/CertClient.cs index 4048433..decd03e 100644 --- a/Services/CertClient.cs +++ b/Services/CertClient.cs @@ -7,7 +7,6 @@ namespace KCert.Services; -[Service] public class CertClient(KCertConfig cfg) { private const int PEMLineLen = 64; diff --git a/Services/ConfigMonitorService.cs b/Services/ConfigMonitorService.cs index ba36e3b..b581a51 100644 --- a/Services/ConfigMonitorService.cs +++ b/Services/ConfigMonitorService.cs @@ -1,7 +1,8 @@ -namespace KCert.Services; +using Polly; -[Service] -public class ConfigMonitorService(ILogger log, KCertConfig cfg, ExponentialBackoff exp, K8sWatchClient watch, CertChangeService certChange) : IHostedService +namespace KCert.Services; + +public class ConfigMonitorService(ILogger log, KCertConfig cfg, K8sWatchClient watch, CertChangeService certChange) : IHostedService { public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; @@ -11,7 +12,9 @@ public Task StartAsync(CancellationToken cancellationToken) { log.LogInformation("Watching for configmaps is enabled"); Task action() => WatchConfigMapsAsync(cancellationToken); - _ = exp.DoWithExponentialBackoffAsync("Watch configmaps", action, cancellationToken); + _ = Policy.Handle() + .WaitAndRetryAsync(5, i => TimeSpan.FromSeconds(Math.Pow(2, i))) + .ExecuteAsync(async ct => await action(), cancellationToken); } else { @@ -24,14 +27,14 @@ public Task StartAsync(CancellationToken cancellationToken) private async Task WatchConfigMapsAsync(CancellationToken cancellationToken) { log.LogInformation("Watching for configmaps changes"); - await watch.WatchConfigMapsAsync((_, _) => HandleConfigMapEventAsync(), cancellationToken); + await watch.WatchConfigMapsAsync((_, _, t) => HandleConfigMapEventAsync(t), cancellationToken); } - private Task HandleConfigMapEventAsync() + private Task HandleConfigMapEventAsync(CancellationToken tok) { try { - certChange.RunCheck(); + certChange.RunCheck(tok); } catch (TaskCanceledException ex) { diff --git a/Services/EmailClient.cs b/Services/EmailClient.cs index 6a395f2..c49f9ab 100644 --- a/Services/EmailClient.cs +++ b/Services/EmailClient.cs @@ -4,38 +4,46 @@ namespace KCert.Services; -[Service] public class EmailClient(ILogger log, KCertConfig cfg) { private const string TestSubject = "KCert Test Email"; private const string TestMessage = "If you received this, then KCert is able to send emails!"; - public async Task SendTestEmailAsync() + public async Task SendTestEmailAsync(CancellationToken tok) { + if (!cfg.SmtpEnabled) + { + return; + } + log.LogInformation("Attempting to send a test email."); - await SendAsync(TestSubject, TestMessage); + await SendAsync(TestSubject, TestMessage, tok); } - public async Task NotifyRenewalResultAsync(string secretNamespace, string secretName, RenewalException? ex) + public async Task NotifyRenewalResultAsync(string secretNamespace, string secretName, RenewalException? ex, CancellationToken tok) { - await SendAsync(RenewalSubject(secretNamespace, secretName, ex), RenewalMessage(secretNamespace, secretName, ex)); - } + if (!cfg.SmtpEnabled) + { + return; + } - public async Task NotifyFailureAsync(string message, Exception ex) - { - var subject = "KCert encountered an unexpected error"; - var body = $"{message}\n\n{ex.Message}\n\n{ex.StackTrace}"; - await SendAsync(subject, body); + await SendAsync(RenewalSubject(secretNamespace, secretName, ex), RenewalMessage(secretNamespace, secretName, ex), tok); } - private async Task SendAsync(string subject, string text) + public async Task NotifyFailureAsync(string message, Exception ex, CancellationToken tok) { - if (cfg.SmtpHost == null || cfg.SmtpUser == null || cfg.SmtpPass == null || cfg.SmtpEmailFrom == null) + if (!cfg.SmtpEnabled) { - log.LogInformation("Cannot send email email because it's not configured correctly"); return; } + var subject = "KCert encountered an unexpected error"; + var body = $"{message}\n\n{ex.Message}\n\n{ex.StackTrace}"; + await SendAsync(subject, body, tok); + } + + private async Task SendAsync(string subject, string text, CancellationToken tok) + { var client = new SmtpClient(cfg.SmtpHost, cfg.SmtpPort) { EnableSsl = true, @@ -44,7 +52,7 @@ private async Task SendAsync(string subject, string text) var message = new MailMessage(cfg.SmtpEmailFrom, cfg.AcmeEmail, subject, text); - await client.SendMailAsync(message); + await client.SendMailAsync(message, tok); } private static string RenewalSubject(string secretNamespace, string secretName, RenewalException? ex = null) diff --git a/Services/ExponentialBackoff.cs b/Services/ExponentialBackoff.cs deleted file mode 100644 index 5e427f6..0000000 --- a/Services/ExponentialBackoff.cs +++ /dev/null @@ -1,38 +0,0 @@ -namespace KCert.Services; - -[Service] -public class ExponentialBackoff(ILogger log, KCertConfig cfg, EmailClient email) -{ - public async Task DoWithExponentialBackoffAsync(string actionName, Func action, CancellationToken tok) - { - int sleepOnFailure = cfg.InitialSleepOnFailure; - while (true) - { - try - { - await action(); - } - catch (TaskCanceledException ex) - { - log.LogError(ex, "{name} loop cancelled.", actionName); - return; - } - catch (Exception ex) - { - log.LogError(ex, "{name} failed", actionName); - try - { - await email.NotifyFailureAsync($"{actionName} failed unexpectedly", ex); - } - catch (Exception ex2) - { - log.LogError(ex2, "{name} failed to send error notification", actionName); - } - } - - log.LogError("{name} failed. Sleeping for {n} seconds before trying again.", actionName, sleepOnFailure); - await Task.Delay(TimeSpan.FromSeconds(sleepOnFailure), tok); - sleepOnFailure *= 2; - } - } -} diff --git a/Services/HttpRouteHandler.cs b/Services/HttpRouteHandler.cs new file mode 100644 index 0000000..b366dac --- /dev/null +++ b/Services/HttpRouteHandler.cs @@ -0,0 +1,53 @@ +using k8s; +using Newtonsoft.Json; +using System.Text.Json.Nodes; + +namespace KCert.Services; + +public class HttpRouteHandler(Kubernetes k8s) : IK8sInterface +{ + private const string K8sGroup = "gateway.networking.k8s.io"; + private const string K8sVersion = "v1"; + private const string K8sPluralName = "httproutes"; + + public async ListMet ListAsync(CancellationToken tok) + { + await foreach (var obj in k8s.listcustom + { + yield return obj; + } + } + + public async Task CreateAsync(string ns, string name, JsonObject obj, CancellationToken tok) + { + var res = await k8s.CreateNamespacedCustomObjectAsync(obj, K8sGroup, K8sVersion, ns, K8sPluralName, cancellationToken: tok); + return Convert(res); + } + + public async Task DeleteAsync(string ns, string name, CancellationToken tok) + { + await k8s.DeleteNamespacedCustomObjectAsync(K8sGroup, K8sVersion, ns, K8sPluralName, name, cancellationToken: tok); + } + + public async Task GetAsync(string ns, string name, CancellationToken tok) + { + var res = await k8s.GetNamespacedCustomObjectAsync(K8sGroup, K8sVersion, ns, K8sPluralName, name, tok); + return Convert(res); + } + + public void Update(JsonObject source, JsonObject target) + { + target["spec"] = source["spec"]; + } + + public async Task UpdateAsync(string ns, string name, JsonObject obj, CancellationToken tok) + { + await k8s.ReplaceNamespacedCustomObjectAsync(obj, K8sGroup, K8sVersion, ns, K8sPluralName, name, cancellationToken: tok); + } + + private static JsonObject Convert(object obj) + { + var json = obj.ToString() ?? throw new Exception("No JSON provided"); + return JsonConvert.DeserializeObject(json) ?? throw new Exception("INVALID JSON"); + } +} diff --git a/Services/IK8sInterface.cs b/Services/IK8sInterface.cs new file mode 100644 index 0000000..fcce3ea --- /dev/null +++ b/Services/IK8sInterface.cs @@ -0,0 +1,12 @@ +namespace KCert.Services; + +public interface IK8sInterface +{ + IAsyncEnumerable ListAsync(CancellationToken cancellationToken); + IAsyncEnumerable ListAsync(string ns, CancellationToken cancellationToken); + Task GetAsync(string ns, string name, CancellationToken tok); + Task CreateAsync(string ns, string name, T obj, CancellationToken tok); + Task DeleteAsync(string ns, string name, CancellationToken tok); + void Update(T source, T target); + Task UpdateAsync(string ns, string name, T obj, CancellationToken tok); +} diff --git a/Services/IngressMonitorService.cs b/Services/IngressMonitorService.cs index dc22d1e..ca90e58 100644 --- a/Services/IngressMonitorService.cs +++ b/Services/IngressMonitorService.cs @@ -1,10 +1,11 @@ using k8s; using k8s.Models; +using Polly; +using Polly.Retry; namespace KCert.Services; -[Service] -public class IngressMonitorService(ILogger log, KCertConfig cfg, ExponentialBackoff exp, K8sWatchClient watch, CertChangeService certChange) : IHostedService +public class IngressMonitorService(ILogger log, KCertConfig cfg, K8sWatchClient watch, CertChangeService certChange) : IHostedService { public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; @@ -13,8 +14,10 @@ public Task StartAsync(CancellationToken cancellationToken) if (cfg.WatchIngresses) { log.LogInformation("Watching for ingress is enabled"); - var action = () => WatchIngressesAsync(cancellationToken); - _ = exp.DoWithExponentialBackoffAsync("Watch ingresses", action, cancellationToken); + Task action() => WatchIngressesAsync(cancellationToken); + _ = Policy.Handle() + .WaitAndRetryAsync(5, i => TimeSpan.FromSeconds(Math.Pow(2, i))) + .ExecuteAsync(async ct => await action(), cancellationToken); } else { @@ -30,12 +33,12 @@ private async Task WatchIngressesAsync(CancellationToken tok) await watch.WatchIngressesAsync(HandleIngressEventAsync, tok); } - private Task HandleIngressEventAsync(WatchEventType type, V1Ingress ingress) + private Task HandleIngressEventAsync(WatchEventType type, V1Ingress ingress, CancellationToken tok) { try { log.LogInformation("Ingress change event [{type}] for {ns}-{name}", type, ingress.Namespace(), ingress.Name()); - certChange.RunCheck(); + certChange.RunCheck(tok); } catch (TaskCanceledException ex) { diff --git a/Services/K8sClient.cs b/Services/K8sClient.cs index 5fc0a2e..779d4c2 100644 --- a/Services/K8sClient.cs +++ b/Services/K8sClient.cs @@ -2,61 +2,38 @@ using k8s.Autorest; using k8s.Models; using System.Net; +using System.Runtime.CompilerServices; using System.Text; namespace KCert.Services; -[Service] public class K8sClient(KCertConfig cfg, Kubernetes client) { private const string TlsSecretType = "kubernetes.io/tls"; private const string CertLabelKey = "kcert.dev/secret"; public const string IngressLabelKey = "kcert.dev/ingress"; - private const string TlsTypeSelector = "type=kubernetes.io/tls"; private string IngressLabel => $"{IngressLabelKey}={cfg.IngressLabelValue}"; private static string ConfigMapLabel => $"{K8sWatchClient.CertRequestKey}={K8sWatchClient.CertRequestValue}"; private string ManagedSecretLabel => $"{CertLabelKey}={cfg.IngressLabelValue}"; - private static string UnManagedSecretLabel => $"!{CertLabelKey}"; + public IAsyncEnumerable GetAllIngressesAsync(CancellationToken tok) => IterateAsync(GetAllIngressesAsync, GetNsIngressesAsync, tok); + private Task GetAllIngressesAsync(string? c, CancellationToken tok) => client.ListIngressForAllNamespacesAsync(labelSelector: IngressLabel, continueParameter: c, cancellationToken: tok); + private Task GetNsIngressesAsync(string ns, string? cTok, CancellationToken tok) => client.ListNamespacedIngressAsync(ns, labelSelector: IngressLabel, continueParameter: cTok, cancellationToken: tok); - public IAsyncEnumerable GetAllIngressesAsync() - { - return IterateAsync(GetAllIngressesAsync, GetNsIngressesAsync); - } - - private Task GetAllIngressesAsync(string? tok) => client.ListIngressForAllNamespacesAsync(labelSelector: IngressLabel, continueParameter: tok); - private Task GetNsIngressesAsync(string ns, string? tok) => client.ListNamespacedIngressAsync(ns, labelSelector: IngressLabel, continueParameter: tok); - - public IAsyncEnumerable GetAllConfigMapsAsync() - { - return IterateAsync(GetAllConfigMapsAsync, GetNsConfigMapsAsync); - } + public IAsyncEnumerable GetAllConfigMapsAsync(CancellationToken tok) => IterateAsync(GetAllConfigMapsAsync, GetNsConfigMapsAsync, tok); + private Task GetAllConfigMapsAsync(string? continuationToken, CancellationToken tok) => client.ListConfigMapForAllNamespacesAsync(labelSelector: ConfigMapLabel, continueParameter: continuationToken, cancellationToken: tok); + private Task GetNsConfigMapsAsync(string ns, string? tok, CancellationToken t) => client.ListNamespacedConfigMapAsync(ns, labelSelector: ConfigMapLabel, continueParameter: tok, cancellationToken: t); - private Task GetAllConfigMapsAsync(string? tok) => client.ListConfigMapForAllNamespacesAsync(labelSelector: ConfigMapLabel, continueParameter: tok); - private Task GetNsConfigMapsAsync(string ns, string? tok) => client.ListNamespacedConfigMapAsync(ns, labelSelector: ConfigMapLabel, continueParameter: tok); + public IAsyncEnumerable GetManagedSecretsAsync(CancellationToken tok) => IterateAsync(GetAllManagedSecretsAsync, GetNsManagedSecretsAsync, tok); + private Task GetAllManagedSecretsAsync(string? tok, CancellationToken t) => client.ListSecretForAllNamespacesAsync(labelSelector: ManagedSecretLabel, continueParameter: tok, cancellationToken: t); + private Task GetNsManagedSecretsAsync(string ns, string? tok, CancellationToken t) => client.ListNamespacedSecretAsync(ns, labelSelector: ManagedSecretLabel, continueParameter: tok, cancellationToken: t); - public IAsyncEnumerable GetManagedSecretsAsync() - { - return IterateAsync(GetAllManagedSecretsAsync, GetNsManagedSecretsAsync); - } - - private Task GetAllManagedSecretsAsync(string? tok) => client.ListSecretForAllNamespacesAsync(labelSelector: ManagedSecretLabel, continueParameter: tok); - private Task GetNsManagedSecretsAsync(string ns, string? tok) => client.ListNamespacedSecretAsync(ns, labelSelector: ManagedSecretLabel, continueParameter: tok); - - public IAsyncEnumerable GetUnManagedSecretsAsync() - { - return IterateAsync(GetAllUnManagedSecretsAsync, GetNsUnManagedSecretsAsync); - } - - private Task GetAllUnManagedSecretsAsync(string? tok) => client.ListSecretForAllNamespacesAsync(fieldSelector: TlsTypeSelector, labelSelector: UnManagedSecretLabel, continueParameter: tok); - private Task GetNsUnManagedSecretsAsync(string ns, string? tok) => client.ListNamespacedSecretAsync(ns, fieldSelector: TlsTypeSelector, labelSelector: UnManagedSecretLabel, continueParameter: tok); - - public async Task GetSecretAsync(string ns, string name) + public async Task GetSecretAsync(string ns, string name, CancellationToken tok) { try { - return await client.ReadNamespacedSecretAsync(name, ns); + return await client.ReadNamespacedSecretAsync(name, ns, cancellationToken: tok); } catch (HttpOperationException ex) { @@ -103,90 +80,88 @@ public async Task DeleteIngressAsync(string ns, string name, CancellationToken t } } - public async Task CreateIngressAsync(V1Ingress ingress) + public async Task CreateIngressAsync(V1Ingress ingress, CancellationToken tok) { - await client.CreateNamespacedIngressAsync(ingress, cfg.KCertNamespace); + await client.CreateNamespacedIngressAsync(ingress, cfg.KCertNamespace, cancellationToken: tok); } - public async Task UpdateTlsSecretAsync(string ns, string name, string key, string cert) + public async Task UpdateTlsSecretAsync(string ns, string name, string key, string cert, CancellationToken tok) { - var secret = await GetSecretAsync(ns, name); + var secret = await GetSecretAsync(ns, name, tok); + var alreadyExists = false; if (secret != null) { // if it's a cert we can directly replace it if (secret.Type == TlsSecretType) { - UpdateSecretData(secret, ns, name, key, cert); - await client.ReplaceNamespacedSecretAsync(secret, name, ns); - return; + alreadyExists = true; + } + else + { + // if it's an opaque secret (ie: a request to create a cert) we delete it and create the cert + await client.DeleteNamespacedSecretAsync(name, ns, cancellationToken: tok); } - - // if it's an opaque secret (ie: a request to create a cert) we delete it and create the cert - await client.DeleteNamespacedSecretAsync(name, ns); } - secret = InitSecret(name); - UpdateSecretData(secret, ns, name, key, cert); - await client.CreateNamespacedSecretAsync(secret, ns); - } - - private void UpdateSecretData(V1Secret secret, string ns, string name, string key, string cert) - { - if (secret.Type != TlsSecretType) + if (secret == null || !alreadyExists) { - throw new Exception($"Secret {ns}:{name} is not a TLS secret type"); + secret = new V1Secret + { + ApiVersion = "v1", + Kind = "Secret", + Type = TlsSecretType, + Data = new Dictionary(), + Metadata = new V1ObjectMeta + { + Name = name + } + }; } secret.Metadata.Labels ??= new Dictionary(); secret.Metadata.Labels[CertLabelKey] = cfg.IngressLabelValue; secret.Data["tls.key"] = Encoding.UTF8.GetBytes(key); secret.Data["tls.crt"] = Encoding.UTF8.GetBytes(cert); - } - private static V1Secret InitSecret(string name) - { - return new V1Secret + if (alreadyExists) { - ApiVersion = "v1", - Kind = "Secret", - Type = TlsSecretType, - Data = new Dictionary(), - Metadata = new V1ObjectMeta - { - Name = name - } - }; + await client.ReplaceNamespacedSecretAsync(secret, name, ns, cancellationToken: tok); + } + else + { + await client.CreateNamespacedSecretAsync(secret, ns, cancellationToken: tok); + } } - private delegate Task ListAllFunc(string? tok); - private delegate Task ListNsFunc(string ns, string? tok); + private delegate Task ListAllFunc(string? continueToken, CancellationToken tok) where TT : IKubernetesObject, IItems; + private delegate Task ListNsFunc(string ns, string? continueToken, CancellationToken tokt) where TT : IKubernetesObject, IItems; - private IAsyncEnumerable IterateAsync(ListAllFunc all, ListNsFunc byNs) where TList : IKubernetesObject, IItems + private IAsyncEnumerable IterateAsync(ListAllFunc all, ListNsFunc byNs, CancellationToken tok) where TT : IKubernetesObject, IItems { return cfg.NamespaceConstraints.Length == 0 - ? IterateAsync(all) - : IterateAsync(byNs); + ? IterateAsync(all, tok) + : IterateAsync(byNs, tok); } - private static async IAsyncEnumerable IterateAsync(ListAllFunc callback) where TList : IKubernetesObject, IItems + private static async IAsyncEnumerable IterateAsync(ListAllFunc callback, [EnumeratorCancellation] CancellationToken tok) where TT : IKubernetesObject, IItems { - string? tok = null; + string? continueToken = null; do { - var result = await callback(tok); - tok = result.Continue(); - foreach (var item in result.Items) + var res = await callback(continueToken, tok); + continueToken = res.Continue(); + foreach (var item in res.Items) { yield return item; } - } while (tok != null); + } while (continueToken != null); } - private async IAsyncEnumerable IterateAsync(ListNsFunc callback) where TList : IKubernetesObject, IItems + private async IAsyncEnumerable IterateAsync(ListNsFunc callback, [EnumeratorCancellation] CancellationToken tok) where TT : IKubernetesObject, IItems { foreach (var ns in cfg.NamespaceConstraints) { - await foreach (var item in IterateAsync(tok => callback(ns, tok))) + await foreach (var item in IterateAsync((t, c) => callback(ns, t, c), tok)) { yield return item; } diff --git a/Services/K8sHelper.cs b/Services/K8sHelper.cs new file mode 100644 index 0000000..81ddab1 --- /dev/null +++ b/Services/K8sHelper.cs @@ -0,0 +1,31 @@ +using k8s.Autorest; +using System.Net; + +namespace KCert.Services; + +public class K8sHelper(IK8sInterface handler) where T : class +{ + public async Task GetAsync(string ns, string name, CancellationToken tok) + { + try + { + return await handler.GetAsync(ns, name, tok); + } + catch (HttpOperationException ex) when (ex.Response.StatusCode == HttpStatusCode.NotFound) + { + return default; + } + } + + public async Task CreateOrUpdateAsync(string ns, string name, T obj, CancellationToken tok) + { + if (await GetAsync(ns, name, tok) is { } prev) + { + handler.Update(obj, prev); + await handler.UpdateAsync(ns, name, prev, tok); + return; + } + + await handler.CreateAsync(ns, name, obj, tok); + } +} diff --git a/Services/K8sWatchClient.cs b/Services/K8sWatchClient.cs index a34e204..1793278 100644 --- a/Services/K8sWatchClient.cs +++ b/Services/K8sWatchClient.cs @@ -4,7 +4,6 @@ namespace KCert.Services; -[Service] public class K8sWatchClient(KCertConfig cfg, ILogger log, Kubernetes client) { public const string CertRequestKey = "kcert.dev/cert-request"; @@ -14,7 +13,7 @@ public class K8sWatchClient(KCertConfig cfg, ILogger log, Kubernetes public string IngressLabel => $"{IngressLabelKey}={cfg.IngressLabelValue}"; public string ConfigLabel => $"{CertRequestKey}={CertRequestValue}"; - public delegate Task ChangeCallback(WatchEventType type, T item); + public delegate Task ChangeCallback(WatchEventType type, T item, CancellationToken tok); public Task WatchIngressesAsync(ChangeCallback callback, CancellationToken tok) { @@ -56,10 +55,8 @@ private Task WatchInLoopAsync(ChangeCallback callback, WatchAllFunc private Task WatchInLoopAsync(ChangeCallback callback, WatchNsFunc func, CancellationToken tok) { - return Task.WhenAll(cfg.NamespaceConstraints - .Select(ns => WatchInLoopAsync($"{ns}:{typeof(T).Name}", callback, (t) => func(ns, t), tok)) - .ToArray() - ); + var tasks = cfg.NamespaceConstraints.Select(ns => WatchInLoopAsync($"{ns}:{typeof(T).Name}", callback, (t) => func(ns, t), tok)); + return Task.WhenAll([..tasks]); } private async Task WatchInLoopAsync(string id, ChangeCallback callback, WatchAllFunc watch, CancellationToken tok) @@ -71,7 +68,7 @@ private async Task WatchInLoopAsync(string id, ChangeCallback callback, { await foreach (var (type, item) in watch(tok).WatchAsync()) { - await callback(type, item); + await callback(type, item, tok); } } catch (HttpRequestException ex) diff --git a/Services/KCertClient.cs b/Services/KCertClient.cs index 1529d32..6088a0d 100644 --- a/Services/KCertClient.cs +++ b/Services/KCertClient.cs @@ -3,68 +3,57 @@ namespace KCert.Services; -[Service] -public class KCertClient(K8sClient kube, RenewalHandler getCert, ILogger log, EmailClient email, CertClient cert) +public class KCertClient(K8sClient kube, ILogger log, EmailClient email, CertClient cert, IServiceProvider svc) { - private Task _running = Task.CompletedTask; + private readonly SemaphoreSlim _semaphore = new(1); public async Task RenewIfNeededAsync(string ns, string name, string[] hosts, CancellationToken tok) { - var secret = await kube.GetSecretAsync(ns, name); - tok.ThrowIfCancellationRequested(); - - if (secret != null) + var secret = await kube.GetSecretAsync(ns, name, tok); + if (false == await IsRenewalNeededAsync(ns, name, hosts, tok)) { - var c = cert.GetCert(secret); - var certHosts = cert.GetHosts(c).ToHashSet(); - if (hosts.Length == certHosts.Count && hosts.All(h => certHosts.Contains(h))) - { - // nothing to do, cert already has all the hosts it needs to have - log.LogInformation("Certificate already has all the needed hosts configured"); - return; - } + // nothing to do, cert already has all the hosts it needs to have + log.LogInformation("Certificate already has all the needed hosts configured"); + return; } - await StartRenewalProcessAsync(ns, name, hosts, tok); + await StartRenewalProcessAsync(ns, name, hosts, tok); } - // Ensure that no certs are renewed in parallel - public Task StartRenewalProcessAsync(string ns, string secretName, string[] hosts, CancellationToken tok) + private async Task IsRenewalNeededAsync(string ns, string name, string[] hosts, CancellationToken tok) { - Task task; - lock (this) - { - task = RenewCertAsync(_running, ns, secretName, hosts, tok); - _running = task; - } - - return task; + var secret = await kube.GetSecretAsync(ns, name, tok); + if (secret == null) return true; + var currentHosts = cert.GetHosts(cert.GetCert(secret)); + return hosts.Union(currentHosts).Count() != hosts.Length; } - private async Task RenewCertAsync(Task prev, string ns, string secretName, string[] hosts, CancellationToken tok) + public async Task StartRenewalProcessAsync(string ns, string secretName, string[] hosts, CancellationToken tok) { + await _semaphore.WaitAsync(tok); try { - await prev; - tok.ThrowIfCancellationRequested(); + log.LogInformation("Starting renewal process for secret {ns}/{secretName} with hosts {hosts}", ns, secretName, string.Join(", ", hosts)); + await RenewCertAsync(ns, secretName, hosts, tok); } - catch (Exception ex) + finally { - log.LogError(ex, "Previous task in rewal chain failed."); + _semaphore.Release(); } + } + private async Task RenewCertAsync(string ns, string secretName, string[] hosts, CancellationToken tok) + { + var getCert = svc.GetRequiredService(); try { await getCert.RenewCertAsync(ns, secretName, hosts, tok); - tok.ThrowIfCancellationRequested(); - - await email.NotifyRenewalResultAsync(ns, secretName, null); - tok.ThrowIfCancellationRequested(); + await email.NotifyRenewalResultAsync(ns, secretName, null, tok); } catch (RenewalException ex) { log.LogError(ex, "Renewal failed"); - await email.NotifyRenewalResultAsync(ns, secretName, ex); + await email.NotifyRenewalResultAsync(ns, secretName, ex, tok); } catch (HttpOperationException ex) { diff --git a/Services/KCertConfig.cs b/Services/KCertConfig.cs index 138b107..290d2f6 100644 --- a/Services/KCertConfig.cs +++ b/Services/KCertConfig.cs @@ -1,29 +1,23 @@ namespace KCert.Services; -[Service] public class KCertConfig(IConfiguration cfg) { - private readonly string _key = CertClient.GenerateNewKey(); + private readonly string _backupKey = CertClient.GenerateNewKey(); public bool WatchIngresses => GetBool("KCert:WatchIngresses"); public bool WatchConfigMaps => GetBool("KCert:WatchConfigMaps"); - public string? K8sConfigFile => cfg["Config"]; public string KCertNamespace => GetRequiredString("KCert:Namespace"); public string KCertSecretName => GetRequiredString("KCert:SecretName"); public string KCertServiceName => GetRequiredString("KCert:ServiceName"); public string KCertIngressName => GetRequiredString("KCert:IngressName"); public int KCertServicePort => GetInt("KCert:ServicePort"); public bool ShowRenewButton => GetBool("KCert:ShowRenewButton"); - public int InitialSleepOnFailure => GetInt("KCert:InitialSleepOnFailure"); public string[] NamespaceConstraints => GetString("KCert:NamespaceConstraints")?.Split(",") ?? []; - public bool UseChallengeIngressClassName => GetBool("ChallengeIngress:UseClassName"); public string ChallengeIngressClassName => GetRequiredString("ChallengeIngress:ClassName"); - public bool UseChallengeIngressAnnotations => GetBool("ChallengeIngress:UseAnnotations"); public Dictionary ChallengeIngressAnnotations => GetDictionary("ChallengeIngress:Annotations"); - public bool UseChallengeIngressLabels => GetBool("ChallengeIngress:UseLabels"); public Dictionary ChallengeIngressLabels => GetDictionary("ChallengeIngress:Labels"); public TimeSpan ChallengeIngressMaxPropagationWaitTime => TimeSpan.FromSeconds(GetInt("ChallengeIngress:MaxPropagationWaitTimeSeconds")); @@ -38,17 +32,19 @@ public class KCertConfig(IConfiguration cfg) public Uri AcmeDir => new(GetRequiredString("Acme:DirUrl")); public string AcmeEmail => GetRequiredString("Acme:Email"); - public string AcmeKey => GetString("Acme:Key") ?? _key; // If no key is provided via configs, use generated key. + public string AcmeKey => cfg.GetValue("Acme:Key", _backupKey); public bool AcmeAccepted => GetBool("Acme:TermsAccepted"); - public string? AcmeEabKeyId => GetString("Acme:EabKeyId"); - public string? AcmeHmacKey => GetString("Acme:EabHmacKey"); + public bool UseEabKey => !string.IsNullOrEmpty(AcmeEabKeyId); + public string AcmeEabKeyId => GetRequiredString("Acme:EabKeyId"); + public string AcmeHmacKey => GetRequiredString("Acme:EabHmacKey"); - public string? SmtpEmailFrom => GetString("Smtp:EmailFrom"); - public string? SmtpHost => GetString("Smtp:Host"); + public bool SmtpEnabled => !string.IsNullOrEmpty(SmtpEmailFrom); + public string SmtpEmailFrom => GetRequiredString("Smtp:EmailFrom"); + public string SmtpHost => GetRequiredString("Smtp:Host"); public int SmtpPort => GetInt("Smtp:Port"); - public string? SmtpUser => GetString("Smtp:User"); - public string? SmtpPass => GetString("Smtp:Pass"); + public string SmtpUser => GetRequiredString("Smtp:User"); + public string SmtpPass => GetRequiredString("Smtp:Pass"); public string IngressLabelValue => GetRequiredString("ChallengeIngress:IngressLabelValue"); @@ -61,51 +57,24 @@ public class KCertConfig(IConfiguration cfg) public string ChallengeType => GetRequiredString("KCert:ChallengeType"); - public object AllConfigs => new + public object AllConfigs { - KCert = new + get { - Namespace = KCertNamespace, - IngressName = KCertIngressName, - SecertName = KCertSecretName, - ServiceName = KCertServiceName, - ServicePort = KCertServicePort, - ShowRenewButton, - NamespaceConstraints, - ChallengeType, - }, - ACME = new - { - ValidationWaitTimeSeconds = AcmeWaitTime, - ValidationNumRetries = AcmeNumRetries, - AutoRenewal = EnableAutoRenew, - RenewalCheckTimeHours = RenewalTimeBetweenChecks, - RenewalThresholdDays = RenewalExpirationLimit, - TermsAccepted = AcmeAccepted, - DirUrl = AcmeDir, - Email = HideString(AcmeEmail), - Key = HideString(AcmeKey) - }, - SMTP = new - { - EmailFrom = HideString(SmtpEmailFrom), - Host = HideString(SmtpHost), - Port = SmtpPort, - User = HideString(SmtpUser), - Pass = HideString(SmtpPass) - }, - Route53 = new - { - AccessKeyId = Route53AccessKeyId, - SecretAccessKey = HideString(Route53SecretAccessKey), - Region = Route53Region, - }, - Cloudflare = new - { - ApiToken = HideString(CloudflareApiToken), - AccountId = CloudflareAccountId, - }, - }; + var res = new Dictionary(); + var props = typeof(KCertConfig).GetProperties(System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public) + .Where(p => p.Name != nameof(AllConfigs)).OrderBy(p => p.Name); + + foreach (var prop in props) + { + string[] redactTerms = ["Key", "Pass", "Token", "Secret"]; + bool redact = redactTerms.Any(term => prop.Name.Contains(term, StringComparison.OrdinalIgnoreCase)); + var val = prop.GetValue(this); + res[prop.Name] = redact ? HideString(val?.ToString()) : val ?? "[NOT CONFIGURED]"; + } + return res; + } + } private static string HideString(string? val) => string.IsNullOrEmpty(val) ? "" : "[REDACTED]"; private string? GetString(string key) => cfg.GetValue(key); @@ -115,7 +84,7 @@ public class KCertConfig(IConfiguration cfg) private Dictionary GetDictionary(string key) { - var data = cfg.GetSection(key)?.GetChildren() ?? Enumerable.Empty(); + var data = cfg.GetSection(key)?.GetChildren() ?? []; return data.ToDictionary(s => s.Key, s => s.Value ?? ""); } } diff --git a/Services/KubernetesFactory.cs b/Services/KubernetesFactory.cs deleted file mode 100644 index 8b69686..0000000 --- a/Services/KubernetesFactory.cs +++ /dev/null @@ -1,22 +0,0 @@ -using k8s; -using k8s.Exceptions; - -namespace KCert.Services; - -[Service] -public class KubernetesFactory(KCertConfig cfg) -{ - public Kubernetes GetClient() => new(GetConfig()); - - private KubernetesClientConfiguration GetConfig() - { - try - { - return KubernetesClientConfiguration.InClusterConfig(); - } - catch (KubeConfigException) - { - return KubernetesClientConfiguration.BuildConfigFromConfigFile(cfg.K8sConfigFile); - } - } -} diff --git a/Services/RenewalHandler.cs b/Services/RenewalHandler.cs index 79a937d..80e06ad 100644 --- a/Services/RenewalHandler.cs +++ b/Services/RenewalHandler.cs @@ -3,7 +3,6 @@ namespace KCert.Services; -[Service] public class RenewalHandler(ILogger log, AcmeClient acme, K8sClient kube, KCertConfig cfg, CertClient cert, IChallengeProvider chal) { public async Task RenewCertAsync(string ns, string secretName, string[] hosts, CancellationToken tok) @@ -13,19 +12,18 @@ public async Task RenewCertAsync(string ns, string secretName, string[] hosts, C try { - var account = await InitAsync(); + var account = await InitAsync(tok); var kid = account.Location; logbuf.LogInformation("Initialized renewal process for secret {ns}/{secretName} - hosts {hosts} - kid {kid}", ns, secretName, string.Join(",", hosts), kid); - var (orderUri, finalizeUri, authorizations, nonce) = await CreateOrderAsync(cfg.AcmeKey, hosts, kid, account.Nonce, logbuf); + var (orderUri, finalizeUri, authorizations) = await CreateOrderAsync(cfg.AcmeKey, hosts, kid, logbuf, tok); logbuf.LogInformation("Order {orderUri} created with finalizeUri {finalizeUri}", orderUri, finalizeUri); List auths = []; foreach (var authUrl in authorizations) { - var auth = await acme.GetAuthzAsync(cfg.AcmeKey, authUrl, kid, nonce); + var auth = await acme.GetAuthzAsync(cfg.AcmeKey, authUrl, kid, tok); auths.Add(auth); - nonce = auth.Nonce; logbuf.LogInformation("Get Auth {authUri}: {status}", authUrl, auth.Status); } @@ -33,20 +31,19 @@ public async Task RenewCertAsync(string ns, string secretName, string[] hosts, C foreach (var auth in auths) { - nonce = await ValidateAuthorizationAsync(auth, cfg.AcmeKey, kid, nonce, new Uri(auth.Location), logbuf); + await ValidateAuthorizationAsync(auth, cfg.AcmeKey, kid, new Uri(auth.Location), logbuf, tok); logbuf.LogInformation("Validated auth: {authUrl}", auth.Location); } - var (certUri, finalizeNonce) = await FinalizeOrderAsync(cfg.AcmeKey, orderUri, finalizeUri, hosts, kid, nonce, logbuf); + var certUri = await FinalizeOrderAsync(cfg.AcmeKey, orderUri, finalizeUri, hosts, kid, logbuf, tok); logbuf.LogInformation("Finalized order and received cert URI: {certUri}", certUri); - await SaveCertAsync(cfg.AcmeKey, ns, secretName, certUri, kid, finalizeNonce); + await SaveCertAsync(cfg.AcmeKey, ns, secretName, certUri, kid, tok); logbuf.LogInformation("Saved cert"); await chal.CleanupChallengeAsync(chalState, tok); } catch (Exception ex) { - logbuf.LogError(ex, "Certificate renewal failed."); throw new RenewalException(ex.Message, ex) { SecretNamespace = ns, @@ -56,35 +53,32 @@ public async Task RenewCertAsync(string ns, string secretName, string[] hosts, C } } - private async Task InitAsync() + private async Task InitAsync(CancellationToken tok) { - await acme.ReadDirectoryAsync(cfg.AcmeDir); - var nonce = await acme.GetNonceAsync(); - return await acme.CreateAccountAsync(nonce); + await acme.InitAsync(tok); + return await acme.CreateAccountAsync(tok); } - private async Task<(Uri OrderUri, Uri FinalizeUri, List Authorizations, string Nonce)> CreateOrderAsync(string key, string[] hosts, string kid, string nonce, ILogger logbuf) + private async Task<(Uri OrderUri, Uri FinalizeUri, List Authorizations)> CreateOrderAsync(string key, string[] hosts, string kid, ILogger logbuf, CancellationToken tok) { - var order = await acme.CreateOrderAsync(key, kid, hosts, nonce); + var order = await acme.CreateOrderAsync(key, kid, hosts, tok); logbuf.LogInformation("Created order: {status}", order.Status); var urls = order.Authorizations.Select(a => new Uri(a)).ToList(); - return (new Uri(order.Location), new Uri(order.Finalize), urls, order.Nonce); + return (new Uri(order.Location), new Uri(order.Finalize), urls); } - private async Task ValidateAuthorizationAsync(AcmeAuthzResponse auth, string key, string kid, string nonce, Uri authUri, ILogger logbuf) + private async Task ValidateAuthorizationAsync(AcmeAuthzResponse auth, string key, string kid, Uri authUri, ILogger logbuf, CancellationToken tok) { var (waitTime, numRetries) = (cfg.AcmeWaitTime, cfg.AcmeNumRetries); var url = auth.Challenges.First(c => c.Type == chal.AcmeChallengeType).Url; var challengeUri = new Uri(url); - var chall = await acme.TriggerChallengeAsync(key, challengeUri, kid, nonce); - nonce = chall.Nonce; + var chall = await acme.TriggerChallengeAsync(key, challengeUri, kid, tok); logbuf.LogInformation("TriggerChallenge {challengeUri}: {status}", challengeUri, chall.Status); do { - await Task.Delay(waitTime); - auth = await acme.GetAuthzAsync(key, authUri, kid, nonce); - nonce = auth.Nonce; + await Task.Delay(waitTime, tok); + auth = await acme.GetAuthzAsync(key, authUri, kid, tok); logbuf.LogInformation("Get Auth {authUri}: {status}", authUri, auth.Status); } while (numRetries-- > 0 && !auth.Challenges.Any(c => c.Status == "valid")); @@ -92,21 +86,19 @@ private async Task ValidateAuthorizationAsync(AcmeAuthzResponse auth, st { throw new Exception($"Auth {authUri} did not complete in time. Last Response: {auth.Status}"); } - - return nonce; } - private async Task<(Uri CertUri, string Nonce)> FinalizeOrderAsync(string key, Uri orderUri, Uri finalizeUri, - IEnumerable hosts, string kid, string nonce, ILogger logbuf) + private async Task FinalizeOrderAsync(string key, Uri orderUri, Uri finalizeUri, + IEnumerable hosts, string kid, ILogger logbuf, CancellationToken tok) { var (waitTime, numRetries) = (cfg.AcmeWaitTime, cfg.AcmeNumRetries); - var finalize = await acme.FinalizeOrderAsync(key, finalizeUri, hosts, kid, nonce); + var finalize = await acme.FinalizeOrderAsync(key, finalizeUri, hosts, kid, tok); logbuf.LogInformation("Finalize {finalizeUri}: {status}", finalizeUri, finalize.Status); while (numRetries-- >= 0 && finalize.Status != "valid") { - await Task.Delay(waitTime); - finalize = await acme.GetOrderAsync(key, orderUri, kid, finalize.Nonce); + await Task.Delay(waitTime, tok); + finalize = await acme.GetOrderAsync(key, orderUri, kid, tok); logbuf.LogInformation("Check Order {orderUri}: {finalize.Status}", orderUri, finalize.Status); } @@ -115,13 +107,13 @@ private async Task ValidateAuthorizationAsync(AcmeAuthzResponse auth, st throw new Exception($"Order not complete: {finalize.Status}"); } - return (new Uri(finalize.Certificate), finalize.Nonce); + return new Uri(finalize.Certificate); } - private async Task SaveCertAsync(string key, string ns, string secretName, Uri certUri, string kid, string nonce) + private async Task SaveCertAsync(string key, string ns, string secretName, Uri certUri, string kid, CancellationToken tok) { - var certVal = await acme.GetCertAsync(key, certUri, kid, nonce); + var certVal = await acme.GetCertAsync(key, certUri, kid, tok); var pem = cert.GetPemKey(); - await kube.UpdateTlsSecretAsync(ns, secretName, pem, certVal); + await kube.UpdateTlsSecretAsync(ns, secretName, pem, certVal, tok); } } diff --git a/Services/RenewalService.cs b/Services/RenewalService.cs index aeae678..87df1ac 100644 --- a/Services/RenewalService.cs +++ b/Services/RenewalService.cs @@ -3,7 +3,6 @@ namespace KCert.Services; -[Service] public class RenewalService(ILogger log, KCertClient kcert, KCertConfig cfg, K8sClient k8s, CertClient cert, EmailClient email) : IHostedService { private const int MaxServiceFailures = 5; @@ -48,7 +47,7 @@ public async Task StartInnerAsync(CancellationToken cancellationToken) { try { - await email.NotifyFailureAsync("Certificate renewal failed unexpectedly", error); + await email.NotifyFailureAsync("Certificate renewal failed unexpectedly", error, cancellationToken); } catch (Exception ex) { @@ -62,7 +61,6 @@ private async Task RunLoopAsync(CancellationToken tok) { while (true) { - tok.ThrowIfCancellationRequested(); await StartRenewalJobAsync(tok); log.LogInformation("Sleeping for {renewalTime}", cfg.RenewalTimeBetweenChecks); await Task.Delay(cfg.RenewalTimeBetweenChecks, tok); @@ -77,9 +75,8 @@ private async Task StartRenewalJobAsync(CancellationToken tok) } log.LogInformation("Checking for certs that need renewals..."); - await foreach (var secret in k8s.GetManagedSecretsAsync()) + await foreach (var secret in k8s.GetManagedSecretsAsync(tok)) { - tok.ThrowIfCancellationRequested(); await TryRenewAsync(secret, tok); } @@ -97,17 +94,16 @@ private async Task TryRenewAsync(V1Secret secret, CancellationToken tok) return; } - tok.ThrowIfCancellationRequested(); log.LogInformation("Renewing: {ns} / {name} / {hosts}", secret.Namespace(), secret.Name(), string.Join(',', hosts)); try { - await kcert.StartRenewalProcessAsync(secret.Namespace(), secret.Name(), hosts.ToArray(), tok); - await email.NotifyRenewalResultAsync(secret.Namespace(), secret.Name(), null); + await kcert.StartRenewalProcessAsync(secret.Namespace(), secret.Name(), [.. hosts], tok); + await email.NotifyRenewalResultAsync(secret.Namespace(), secret.Name(), null, tok); } catch (RenewalException ex) { - await email.NotifyRenewalResultAsync(secret.Namespace(), secret.Name(), ex); + await email.NotifyRenewalResultAsync(secret.Namespace(), secret.Name(), ex, tok); } } } diff --git a/TEST.md b/TEST.md new file mode 100644 index 0000000..792117e --- /dev/null +++ b/TEST.md @@ -0,0 +1,9 @@ +# Test Checklist and Reminders + +- Email turned on and off +- EAB Key on and off +- Challenge Types: + - HTTP + - Route53 + - Cloudflare + diff --git a/appsettings.Development.json b/appsettings.Development.json new file mode 100644 index 0000000..7e81884 --- /dev/null +++ b/appsettings.Development.json @@ -0,0 +1,14 @@ +{ + "Acme": { + "Email": "test@example.com", + "DirUrl": "https://acme-staging-v02.api.letsencrypt.org/directory", + "TermsAccepted": true + }, + "kcert": { + "ShowRenewButton": true + }, + "ChallengeIngress": { + "SkipIngressPropagationCheck": true, + "MaxPropagationWaitTimeSeconds": 60 + } +} \ No newline at end of file diff --git a/appsettings.json b/appsettings.json index 8329a82..a18bef9 100644 --- a/appsettings.json +++ b/appsettings.json @@ -18,7 +18,6 @@ "WatchConfigMaps": true, "ShowRenewButton": false, "NamespaceConstraints": null, - "InitialSleepOnFailure": 30, "ChallengeType": "http" }, "Acme": { @@ -27,21 +26,27 @@ "ValidationNumRetries": 5, "RenewalCheckTimeHours": 6, "RenewalThresholdDays": 30, - "AutoRenewal": true + "AutoRenewal": true, + "EabKeyId": "", + "EabHmacKey": "" }, "ChallengeIngress": { - "UseClassName": true, - "ClassName": "nginx", - "UseAnnotations": false, + "ClassName": "", "Annotations": { "kubernetes.io/ingress.class": "nginx" }, "IngressLabelValue": "managed", - "UseLabels": false, - "Labels": null, + "Labels": {}, "MaxPropagationWaitTimeSeconds": 300, "PropagationCheckIntervalMilliseconds": 5000 }, + "Smtp": { + "EmailFrom": "", + "Host": "", + "Port": 587, + "User": "", + "Pass": "" + }, "Route53": { "AccessKeyId": "", "SecretAccessKey": "", diff --git a/charts/kcert/Chart.yaml b/charts/kcert/Chart.yaml index 46d59a3..e27829c 100644 --- a/charts/kcert/Chart.yaml +++ b/charts/kcert/Chart.yaml @@ -1,8 +1,8 @@ apiVersion: v2 name: kcert description: Deploys KCert for issuing Let's Encrypt certificates -version: 1.0.7 -appVersion: 1.2.0 +version: 2.0.0 +appVersion: 2.0.0 maintainers: - name: Nabeel Sulieman url: https://nabeel.dev diff --git a/charts/kcert/templates/020-ServiceAccount.yaml b/charts/kcert/templates/020-ServiceAccount.yaml index d21adbd..a6e1380 100644 --- a/charts/kcert/templates/020-ServiceAccount.yaml +++ b/charts/kcert/templates/020-ServiceAccount.yaml @@ -1,9 +1,7 @@ apiVersion: v1 kind: ServiceAccount metadata: - name: {{ include "kcert.fullname" . }} - namespace: {{ .Release.Namespace | default "default" }} -{{- if .Values.forHelm }} + name: {{ .Values.name | quote }} + namespace: {{ .Release.Namespace | quote }} labels: {{- include "kcert.labels" . | nindent 4 }} -{{- end }} diff --git a/charts/kcert/templates/030-ClusterRole.yaml b/charts/kcert/templates/030-ClusterRole.yaml index 349b7bb..fae8b14 100644 --- a/charts/kcert/templates/030-ClusterRole.yaml +++ b/charts/kcert/templates/030-ClusterRole.yaml @@ -1,12 +1,10 @@ -{{ if !.Values.allowedNamespaces }} +{{ if not .Values.env.KCERT__NAMESPACECONSTRAINTS }} apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: - name: {{ include "kcert.fullname" . }} -{{- if .Values.forHelm }} + name: {{ .Values.name | quote }} labels: {{- include "kcert.labels" . | nindent 4 }} -{{- end }} rules: - apiGroups: [""] resources: ["secrets"] @@ -18,19 +16,17 @@ rules: resources: ["configmaps"] verbs: ["get", "list", "watch"] {{ else }} -{{- range $index, $value := .Values.allowedNamespaces }} -{{- if ne $index 0 }} +{{- range $index, $value := split "," .Values.env.KCERT__NAMESPACECONSTRAINTS }} +{{- if ne $index "0" }} --- {{- end }} apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: - name: {{ include "kcert.fullname" . }} - namespace: {{ $value }} -{{- if .Values.forHelm }} + name: {{ $.Values.name | quote }} + namespace: {{ $value | quote }} labels: - {{- include "kcert.labels" . | nindent 4 }} -{{- end }} + {{- include "kcert.labels" $ | nindent 4 }} rules: - apiGroups: [""] resources: ["secrets"] @@ -42,4 +38,4 @@ rules: resources: ["configmaps"] verbs: ["get", "list", "watch"] {{- end }} -{{ end }} +{{- end }} diff --git a/charts/kcert/templates/040-ClusterRoleBinding.yaml b/charts/kcert/templates/040-ClusterRoleBinding.yaml index 9d14082..59b094b 100644 --- a/charts/kcert/templates/040-ClusterRoleBinding.yaml +++ b/charts/kcert/templates/040-ClusterRoleBinding.yaml @@ -1,41 +1,37 @@ -{{ if !.Values.allowedNamespaces }} +{{ if not .Values.env.KCERT__NAMESPACECONSTRAINTS }} apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: - name: {{ include "kcert.fullname" . }} -{{- if .Values.forHelm }} + name: {{ .Values.name | quote }} labels: {{- include "kcert.labels" . | nindent 4 }} -{{- end }} subjects: - kind: ServiceAccount - name: {{ include "kcert.fullname" . }} - namespace: {{ .Release.Namespace | default "kcert" }} + name: {{ .Values.name | quote }} + namespace: {{ .Release.Namespace | quote }} roleRef: kind: ClusterRole - name: {{ include "kcert.fullname" . }} + name: {{ .Values.name | quote }} apiGroup: rbac.authorization.k8s.io {{ else }} -{{- range $index, $value := .Values.allowedNamespaces }} -{{- if ne $index 0 }} +{{- range $index, $value := split "," .Values.env.KCERT__NAMESPACECONSTRAINTS }} +{{- if ne $index "0" }} --- {{- end }} apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: - name: {{ include "kcert.fullname" . }} - namespace: {{ $value }} -{{- if .Values.forHelm }} + name: {{ $.Values.name | quote }} + namespace: {{ $value | quote }} labels: - {{- include "kcert.labels" . | nindent 4 }} -{{- end }} + {{- include "kcert.labels" $ | nindent 4 }} subjects: - kind: ServiceAccount - name: {{ include "kcert.fullname" . }} - namespace: {{ .Release.Namespace | default "default" }} + name: {{ $.Values.name | quote }} + namespace: {{ $.Release.Namespace | quote }} roleRef: kind: Role - name: {{ include "kcert.fullname" . }} + name: {{ $.Values.name | quote }} apiGroup: rbac.authorization.k8s.io {{- end }} {{ end }} diff --git a/charts/kcert/templates/050-Role.yaml b/charts/kcert/templates/050-Role.yaml index a72fe41..acbbd79 100644 --- a/charts/kcert/templates/050-Role.yaml +++ b/charts/kcert/templates/050-Role.yaml @@ -1,12 +1,10 @@ apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: - name: {{ include "kcert.fullname" . }} + name: {{ .Values.name | quote }} namespace: {{ .Release.Namespace | default "default" }} -{{- if .Values.forHelm }} labels: {{- include "kcert.labels" . | nindent 4 }} -{{- end }} rules: - apiGroups: ["networking.k8s.io"] resources: ["ingresses"] diff --git a/charts/kcert/templates/060-RoleBinding.yaml b/charts/kcert/templates/060-RoleBinding.yaml index 285ff22..985e3ea 100644 --- a/charts/kcert/templates/060-RoleBinding.yaml +++ b/charts/kcert/templates/060-RoleBinding.yaml @@ -1,17 +1,15 @@ apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: - name: {{ include "kcert.fullname" . }} + name: {{ .Values.name | quote }} namespace: {{ .Release.Namespace | default "default" }} -{{- if .Values.forHelm }} labels: {{- include "kcert.labels" . | nindent 4 }} -{{- end }} subjects: - kind: ServiceAccount - name: {{ include "kcert.fullname" . }} + name: {{ .Values.name | quote }} namespace: {{ .Release.Namespace | default "default" }} roleRef: kind: Role - name: {{ include "kcert.fullname" . }} + name: {{ .Values.name | quote }} apiGroup: rbac.authorization.k8s.io diff --git a/charts/kcert/templates/070-Deployment.yaml b/charts/kcert/templates/070-Deployment.yaml index 8b0f65b..bd29bc6 100644 --- a/charts/kcert/templates/070-Deployment.yaml +++ b/charts/kcert/templates/070-Deployment.yaml @@ -1,32 +1,26 @@ apiVersion: apps/v1 kind: Deployment metadata: - name: {{ include "kcert.fullname" . }} + name: {{ .Values.name | quote }} namespace: {{ .Release.Namespace | default "default" }} labels: - app: {{ include "kcert.fullname" . }} - {{- if .Values.forHelm }} + app: {{ .Values.name | quote }} {{- include "kcert.labels" . | nindent 4 }} - {{- end }} spec: replicas: 1 selector: matchLabels: - app: {{ include "kcert.fullname" . }} - {{- if .Values.forHelm }} + app: {{ .Values.name | quote }} {{- include "kcert.selectorLabels" . | nindent 6 }} - {{- end }} template: metadata: labels: - app: {{ include "kcert.fullname" . }} - {{- if .Values.forHelm }} + app: {{ .Values.name | quote }} {{- include "kcert.selectorLabels" . | nindent 8 }} - {{- end }} spec: - serviceAccountName: {{ include "kcert.fullname" . }} + serviceAccountName: {{ .Values.name | quote }} containers: - - name: {{ include "kcert.fullname" . }} + - name: {{ .Values.name | quote }} image: {{ required "kcertImage is required" .Values.kcertImage }} securityContext: {{- toYaml .Values.securityContext | nindent 10 }} @@ -40,72 +34,14 @@ spec: {{- toYaml .Values.resources | nindent 10 }} {{- end }} env: - - name: KCERT__NAMESPACE - value: {{ .Release.Namespace | default "default" }} - - name: KCERT__SERVICENAME - value: {{ include "kcert.fullname" .}} - - name: KCERT__INGRESSNAME - value: {{ include "kcert.fullname" .}} - - name: ACME__DIRURL - # https://acme-staging-v02.api.letsencrypt.org/directory or https://acme-v02.api.letsencrypt.org/directory - value: {{ required "acmeDirUrl is required" .Values.acmeDirUrl }} - - name: ACME__TERMSACCEPTED - # You must set this to "true" to indicate your acceptance of Let's Encrypt's terms of service (https://letsencrypt.org/documents/LE-SA-v1.2-November-15-2017.pdf) - value: {{ required "acmeTermsAccepted is required" .Values.acmeTermsAccepted | quote }} - - name: ACME__EMAIL - # Your email address for Let's Encrypt and email notifications - value: {{ required "acmeEmail is required" .Values.acmeEmail }} -{{- if .Values.smtp.secretName }} - - name: SMTP__EMAILFROM - valueFrom: - secretKeyRef: - name: {{ .Values.smtp.secretName }} - key: {{ required "smtp.emailName is required" .Values.smtp.emailName }} - - name: SMTP__HOST - valueFrom: - secretKeyRef: - name: {{ .Values.smtp.secretName }} - key: {{ required "smtp.hostName is required" .Values.smtp.hostName }} - - name: SMTP__PORT - valueFrom: - secretKeyRef: - name: {{ .Values.smtp.secretName }} - key: {{ required "smtp.portName is required" .Values.smtp.portName }} - - name: SMTP__USER - valueFrom: - secretKeyRef: - name: {{ .Values.smtp.secretName }} - key: {{ required "smtp.userName is required" .Values.smtp.userName }} - - name: SMTP__PASS - valueFrom: - secretKeyRef: - name: {{ .Values.smtp.secretName }} - key: {{ required "smtp.passName is required" .Values.smtp.passName }} -{{- end }} -{{- if .Values.acmeKey.secretName }} - - name: ACME__KEY - valueFrom: - secretKeyRef: - name: "{{ .Values.acmeKey.secretName }}" - key: "{{ required "acmeKey.keyName is required" .Values.acmeKey.keyName }}" -{{- end }} -{{- if .Values.acmeEabKey.secretName }} - - name: ACME__EABKEYID - valueFrom: - secretKeyRef: - name: "{{ .Values.acmeEabKey.secretName }}" - key: "{{ required "acmeEabKey.keyIdName is required" .Values.acmeEabKey.keyIdName }}" - - name: ACME__EABHMACKEY - valueFrom: - secretKeyRef: - name: "{{ .Values.acmeEabKey.secretName }}" - key: "{{ required "acmeEabKey.hmacKeyName is required" .Values.acmeEabKey.hmacKeyName }}" -{{- end }} -{{- if .Values.showRenewButton }} - - name: KCERT__SHOWRENEWBUTTON - value: "{{ .Values.showRenewButton }}" -{{- end }} -{{- range $key, $value := .Values.env }} + {{- range $key, $value := .Values.env }} - name: "{{ $key }}" value: "{{ $value }}" -{{- end }} + {{- end }} + {{- range $secret := .Values.envSecrets }} + - name: "{{ $secret.name }}" + valueFrom: + secretKeyRef: + name: "{{ $secret.secretName }}" + key: "{{ $secret.secretKey }}" + {{- end }} diff --git a/charts/kcert/templates/080-Service.yaml b/charts/kcert/templates/080-Service.yaml index 2c68d13..8548709 100644 --- a/charts/kcert/templates/080-Service.yaml +++ b/charts/kcert/templates/080-Service.yaml @@ -1,13 +1,11 @@ apiVersion: v1 kind: Service metadata: - name: {{ include "kcert.fullname" . }} + name: {{ .Values.name | quote }} namespace: {{ .Release.Namespace | default "default" }} labels: - app: {{ include "kcert.fullname" . }} - {{- if .Values.forHelm }} + app: {{ .Values.name | quote }} {{- include "kcert.labels" . | nindent 4 }} - {{- end }} spec: ports: - name: http @@ -19,7 +17,5 @@ spec: port: 8080 targetPort: 8080 selector: - app: {{ include "kcert.fullname" . }} - {{- if .Values.forHelm }} + app: {{ .Values.name | quote }} {{- include "kcert.selectorLabels" . | nindent 4 }} - {{- end }} diff --git a/charts/kcert/templates/NOTES.txt b/charts/kcert/templates/NOTES.txt index 4b38811..64d7105 100644 --- a/charts/kcert/templates/NOTES.txt +++ b/charts/kcert/templates/NOTES.txt @@ -1,4 +1,4 @@ Congratulations! kcert should now be setup and running in your cluster. -You can check out the dashboard by running `kubectl -n {{ .Release.Namespace | default "default" }} port-forward svc/{{ include "kcert.fullname" . }} 8080` +You can check out the dashboard by running `kubectl -n {{ .Release.Namespace | default "default" }} port-forward svc/{{ .Values.name }} 8080` and then opening http://localhost:8080 in your browser. diff --git a/charts/kcert/templates/_helpers.tpl b/charts/kcert/templates/_helpers.tpl index 43acffd..c438cda 100644 --- a/charts/kcert/templates/_helpers.tpl +++ b/charts/kcert/templates/_helpers.tpl @@ -1,52 +1,11 @@ -{{/* -Expand the name of the chart. -*/}} -{{- define "kcert.name" -}} -{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} -{{- end }} - -{{/* -Create a default fully qualified app name. -We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). -If release name contains chart name it will be used as a full name. -*/}} -{{- define "kcert.fullname" -}} -{{- if .Values.fullnameOverride }} -{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} -{{- else }} -{{- $name := default .Chart.Name .Values.nameOverride }} -{{- if contains $name .Release.Name }} -{{- .Release.Name | trunc 63 | trimSuffix "-" }} -{{- else }} -{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} -{{- end }} -{{- end }} -{{- end }} - -{{/* -Create chart name and version as used by the chart label. -*/}} -{{- define "kcert.chart" -}} -{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} -{{- end }} - -{{/* -Common labels -*/}} {{- define "kcert.labels" -}} -helm.sh/chart: {{ include "kcert.chart" . }} -{{ include "kcert.selectorLabels" . }} -{{- if .Chart.AppVersion }} +helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | quote }} app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +app.kubernetes.io/managed-by: {{ .Release.Service | quote }} +{{ include "kcert.selectorLabels" . }} {{- end }} -app.kubernetes.io/managed-by: {{ .Release.Service }} -{{- end }} - -{{/* -Selector labels -*/}} {{- define "kcert.selectorLabels" -}} -app.kubernetes.io/name: {{ include "kcert.name" . }} -app.kubernetes.io/instance: {{ .Release.Name }} -{{- end }} \ No newline at end of file +app.kubernetes.io/name: {{ .Chart.Name | quote }} +app.kubernetes.io/instance: {{ .Release.Name | quote }} +{{- end }} diff --git a/charts/kcert/values.yaml b/charts/kcert/values.yaml index ef88aa6..c351ec9 100644 --- a/charts/kcert/values.yaml +++ b/charts/kcert/values.yaml @@ -1,31 +1,20 @@ -acmeDirUrl: https://acme-staging-v02.api.letsencrypt.org/directory -acmeEmail: null -acmeTermsAccepted: false -kcertImage: nabsul/kcert:v1.2.0 - -env: {} - -# set to null to allow managing all namespaces, or limit with array of namespaces -allowedNamespaces: null #["namespace1", "namespace2", ...] - -acmeKey: - secretName: null - keyName: key - -acmeEabKey: - secretName: null - keyIdName: keyid - hmacKeyName: hmac - -showRenewButton: null - -smtp: - secretName: null # set this to the secret containing the smtp credentials in keys as defined below - emailName: email - hostName: host - portName: port - userName: user - passName: password +kcertImage: "nabsul/kcert:v1.2.0" +name: "kcert" + +# KCert uses the standard .NET configuration system, which allows you to set configuration values via environment variables. +# The following environment variables must be set for KCert to work properly. All others can be set according to your needs. +# You can see all available configuration options in apsettings.json or in KCertConfig.cs. +env: + ACME__EMAIL: "" + ACME__TERMSACCEPTED: "false" + ACME__DIRURL: "https://acme-staging-v02.api.letsencrypt.org/directory" # https://acme-v02.api.letsencrypt.org/directory + KCERT__SHOWRENEWBUTTON: "false" + +# You can also set environment variables via Kubernetes secrets in this section. +envSecrets: + # - name: "ENV_VAR" + # secretName: "my-secret" + # secretKey: "key-in-secret" securityContext: {} # capabilities: @@ -36,6 +25,3 @@ securityContext: {} # runAsUser: 1000 resources: {} - -# Set this to false in order to generate a plain yaml template without the Helm custom labels -forHelm: true diff --git a/test-app/Dockerfile b/test-app/Dockerfile new file mode 100644 index 0000000..1680db6 --- /dev/null +++ b/test-app/Dockerfile @@ -0,0 +1,13 @@ +# Simple Dockerfile for Go web app +FROM golang:alpine AS builder +WORKDIR /app +COPY . . +RUN go build -ldflags="-s -w" -o app run.go + +# Use distroless for minimal and secure runtime +FROM gcr.io/distroless/static-debian12:nonroot +WORKDIR /app +COPY --from=builder /app/app . +EXPOSE 8080 +USER nonroot +CMD ["/app/app"] diff --git a/test-app/go.mod b/test-app/go.mod new file mode 100644 index 0000000..638d166 --- /dev/null +++ b/test-app/go.mod @@ -0,0 +1,3 @@ +module github.com/nabsul/kcert/test-app + +go 1.25.0 diff --git a/test-app/run.go b/test-app/run.go new file mode 100644 index 0000000..2546999 --- /dev/null +++ b/test-app/run.go @@ -0,0 +1,41 @@ +package main + +import ( + "fmt" + "io" + "net/http" +) + +func handler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + fmt.Fprintln(w, "") + fmt.Fprintln(w, "

Request Info & Headers

")
+	// Special fields
+	fmt.Fprintf(w, "Method: %s\n", r.Method)
+	fmt.Fprintf(w, "URL: %s\n", r.URL.String())
+	fmt.Fprintf(w, "Proto: %s\n", r.Proto)
+	fmt.Fprintf(w, "Host: %s\n", r.Host)
+	fmt.Fprintf(w, "RemoteAddr: %s\n", r.RemoteAddr)
+	fmt.Fprintf(w, "ContentLength: %d\n", r.ContentLength)
+	fmt.Fprintf(w, "TransferEncoding: %v\n", r.TransferEncoding)
+	fmt.Fprintf(w, "Close: %v\n", r.Close)
+	fmt.Fprintf(w, "RequestURI: %s\n", r.RequestURI)
+	fmt.Fprintf(w, "TLS: %v\n", r.TLS != nil)
+	fmt.Fprintln(w, "Headers:")
+	for name, values := range r.Header {
+		for _, value := range values {
+			fmt.Fprintf(w, "%s: %s\n", name, value)
+		}
+	}
+	fmt.Fprintln(w, "
") + fmt.Fprintln(w, "

Body

")
+	body, _ := io.ReadAll(r.Body)
+	fmt.Fprintf(w, "%s", body)
+	fmt.Fprintln(w, "
") +} + +func main() { + http.HandleFunc("/", handler) + fmt.Println("Listening on :8080") + http.ListenAndServe(":8080", nil) +} diff --git a/testing/README.md b/testing/README.md new file mode 100644 index 0000000..065909c --- /dev/null +++ b/testing/README.md @@ -0,0 +1,15 @@ +# Testing KCert + +This doc describes how to create a temporary cluster and test KCert's functionality end to end. + +## nginx ingress controller + +```sh +helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx +helm repo update +helm install nginx-ingress ingress-nginx/ingress-nginx \ + --namespace ingress-nginx --create-namespace \ + --set controller.publishService.enabled=true +``` + + diff --git a/testing/gw-settings.yaml b/testing/gw-settings.yaml new file mode 100644 index 0000000..5fcded0 --- /dev/null +++ b/testing/gw-settings.yaml @@ -0,0 +1,38 @@ +apiVersion: gateway.networking.k8s.io/v1 +kind: GatewayClass +metadata: + name: eg +spec: + controllerName: gateway.envoyproxy.io/gatewayclass-controller +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: eg +spec: + gatewayClassName: eg + listeners: + - name: http + protocol: HTTP + port: 80 +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: backend +spec: + parentRefs: + - name: eg + hostnames: + - "www.example.com" + rules: + - backendRefs: + - group: "" + kind: Service + name: kcert-test + port: 8080 + weight: 1 + matches: + - path: + type: PathPrefix + value: / diff --git a/testing/justfile b/testing/justfile new file mode 100644 index 0000000..31b25a6 --- /dev/null +++ b/testing/justfile @@ -0,0 +1,70 @@ +set shell := ['pwsh', '-NoLogo', '-NoProfile', '-Command'] + +list-vms: + doctl compute droplet list + +delete-vm vm_id: + doctl compute droplet delete {{vm_id}} --force + +create-vm: + #!pwsh + echo "Getting VM parameters..." + $sshKey = (doctl compute ssh-key list -o json | ConvertFrom-Json | where {$_.name.Contains('dummy')}).id + $imageId = (doctl compute image list -o json | ConvertFrom-Json | where {$_.name.Contains('Talos')}).id + $timestamp = Get-Date -Format "yyyyMMdd-HHmmss" + $vmName = "kcert-test-$timestamp" + mkdir $vmName | Out-Null + echo "Creating droplet..." + $vmJson = (doctl compute droplet create --region sfo3 --image $imageId --size s-2vcpu-4gb --enable-private-networking --ssh-keys $sshKey $vmName --wait -o json) + $vm = $vmJson | ConvertFrom-Json + $vmIp = $vm[0].networks.v4 | where {$_.type -eq 'public'} | Select-Object -ExpandProperty ip_address + echo "VM created with IP address: $vmIp" + echo $vmIp > $vmName/ip.txt + echo "Sleeping for 10 seconds to let droplet fully start..." + echo "Initializing Talos cluster at $vmIp" + talosctl gen config $vmName "https://${vmIp}:6443" --additional-sans $vmIp -o $vmName + $env:TALOSCONFIG = (Resolve-Path "$vmName/talosconfig").Path + talosctl config endpoint $vmIp + talosctl config node $vmIp + $yaml = Get-Content -Path "${vmName}/controlplane.yaml" + $yaml = $yaml -replace '# allowSchedulingOnControlPlanes:', 'allowSchedulingOnControlPlanes:' + Set-Content -Path "${vmName}/controlplane.yaml" -Value $yaml + talosctl apply-config --insecure --nodes $vmIp --file "${vmName}/controlplane.yaml" + echo "Sleeping for 10 seconds to allow the node to initialize..." + Start-Sleep -Seconds 10 + talosctl bootstrap + echo "Sleeping for 10 seconds to allow the cluster to stabilize..." + Start-Sleep -Seconds 10 + talosctl health + talosctl kubeconfig $vmName + $env:KUBECONFIG = (Resolve-Path "$vmName/kubeconfig").Path + echo "Setting up metallb" + kubectl apply -f https://raw.githubusercontent.com/metallb/metallb/v0.15.2/config/manifests/metallb-native.yaml + kubectl wait --timeout=5m --for=condition=available --all deployments -n metallb-system + (get-content metallb-config.yaml) -replace '_VM-IP_', $vmIp | kubectl apply -f - + echo "Setting up envoy" + helm install eg oci://docker.io/envoyproxy/gateway-helm --version v1.5.1 -n envoy-gateway-system --create-namespace + kubectl wait --timeout=5m -n envoy-gateway-system deployment/envoy-gateway --for=condition=Available + echo "Setting up kcert test app" + kubectl apply -f .\test-svc.yaml + helm install kcert ..\charts\kcert --namespace kcert --create-namespace -f ..\values.yaml + kubectl wait --timeout=5m -n kcert deployment/kcert --for=condition=Available + kubectl apply -f .\gw-settings.yaml + echo "Here are your environment variables:" + $envVars = @( + "`$env:KUBECONFIG = '$env:KUBECONFIG'", + "`$env:TALOSCONFIG = '$env:TALOSCONFIG'", + "`$env:VMIP = '$vmIp'" + ) + $envVars | ForEach-Object { echo $_ } + $envVars | Out-File -FilePath "$vmName/env.txt" -Encoding utf8 + +save-configs dir_name: + tar czvf {{dir_name}}.tgz {{dir_name}}/* + op document create {{dir_name}}.tgz --vault kcert + rm {{dir_name}}.tgz + +download-configs dir_name: + op document get {{dir_name}}.tgz --vault nblspace --output {{dir_name}}.tgz + tar xzvf {{dir_name}}.tgz + rm {{dir_name}}.tgz diff --git a/testing/metallb-config.yaml b/testing/metallb-config.yaml new file mode 100644 index 0000000..a551824 --- /dev/null +++ b/testing/metallb-config.yaml @@ -0,0 +1,19 @@ +apiVersion: metallb.io/v1beta1 +kind: IPAddressPool +metadata: + name: lab-pool + namespace: metallb-system +spec: + addresses: + - __PRIVATE_IP__/32 +--- +apiVersion: metallb.io/v1beta1 +kind: L2Advertisement +metadata: + name: default + namespace: metallb-system +spec: + # This tells MetalLB to advertise all pools, which in our + # case is just the "default" pool we created above. + ipAddressPools: + - default \ No newline at end of file diff --git a/testing/nginx-namespace.yaml b/testing/nginx-namespace.yaml new file mode 100644 index 0000000..f1f1600 --- /dev/null +++ b/testing/nginx-namespace.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: nginx + labels: + talos.dev/allow-privileged: "true" + pod-security.kubernetes.io/enforce: privileged + pod-security.kubernetes.io/enforce-version: latest diff --git a/testing/patch.yaml b/testing/patch.yaml new file mode 100644 index 0000000..627c339 --- /dev/null +++ b/testing/patch.yaml @@ -0,0 +1,2 @@ +cluster: + allowSchedulingOnControlPlanes: true \ No newline at end of file diff --git a/testing/test-svc.yaml b/testing/test-svc.yaml new file mode 100644 index 0000000..3e1c432 --- /dev/null +++ b/testing/test-svc.yaml @@ -0,0 +1,58 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: kcert-test + namespace: default +spec: + replicas: 1 + selector: + matchLabels: + app: kcert-test + template: + metadata: + labels: + app: kcert-test + spec: + securityContext: + runAsNonRoot: true + runAsUser: 1001 + runAsGroup: 1001 + fsGroup: 1001 + seccompProfile: + type: RuntimeDefault + containers: + - name: kcert-test + image: nabsul/kcert-test-app:v0.0.2 + ports: + - containerPort: 8080 + securityContext: + allowPrivilegeEscalation: false + runAsNonRoot: true + runAsUser: 1001 + runAsGroup: 1001 + capabilities: + drop: + - ALL + seccompProfile: + type: RuntimeDefault + resources: + requests: + cpu: "10m" + memory: "16Mi" + limits: + cpu: "100m" + memory: "64Mi" +--- +apiVersion: v1 +kind: Service +metadata: + name: kcert-test + namespace: default +spec: + selector: + app: kcert-test + ports: + - protocol: TCP + port: 8080 + targetPort: 8080 + type: ClusterIP