using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Text.Json;
using EntKube.Clusters.Domain;
using k8s;
using k8s.Models;
using Microsoft.Extensions.Logging;
namespace EntKube.Clusters.Features.Certificates;
///
/// Handles all Certificate Authority operations on a Kubernetes cluster. This is the
/// single entry point for listing, creating, deleting, and rotating CAs. It connects
/// to the cluster via kubeConfig and operates directly on cert-manager CRDs and K8s
/// Secrets.
///
/// The handler combines what was previously split between InternalCACheck and
/// DomainCACheck into a unified view — any CA ClusterIssuer on the cluster is
/// returned regardless of type, with proper classification.
///
public class CertificateAuthorityHandler
{
private readonly IClusterRepository clusterRepository;
private readonly ILogger logger;
private const string DefaultBundleName = "platform-trust-bundle";
public CertificateAuthorityHandler(
IClusterRepository clusterRepository,
ILogger logger)
{
this.clusterRepository = clusterRepository;
this.logger = logger;
}
///
/// Lists every Certificate Authority on the cluster. This discovers all
/// CA-type ClusterIssuers and classifies them as internal or domain CAs
/// based on their annotations. Also reads cert validity from the CA Secrets.
///
public async Task> ListAsync(Guid clusterId, CancellationToken ct)
{
KubernetesCluster? cluster = await clusterRepository.GetByIdAsync(clusterId, ct);
if (cluster is null)
{
return new List();
}
Kubernetes client = BuildClient(cluster);
List results = new();
// Collect all trust bundle sources so we can check membership
// and also discover CAs that only exist as trust entries.
List trustBundleSources = await GetTrustBundleSources(client, ct);
HashSet trustBundleSecrets = trustBundleSources
.Where(s => s.SourceType == TrustSourceType.Secret)
.Select(s => s.Name)
.ToHashSet();
try
{
// List all ClusterIssuers and classify them.
JsonElement issuers = await client.CustomObjects.ListClusterCustomObjectAsync(
group: "cert-manager.io",
version: "v1",
plural: "clusterissuers",
cancellationToken: ct);
if (!issuers.TryGetProperty("items", out JsonElement items))
{
return results;
}
foreach (JsonElement issuer in items.EnumerateArray())
{
CertificateAuthorityInfo? ca = await ParseClusterIssuer(client, issuer, trustBundleSecrets, ct);
if (ca is not null)
{
results.Add(ca);
}
}
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to list CAs for cluster {ClusterId}", clusterId);
}
// Some CAs exist only in the trust bundle — they were imported
// for trust purposes without a corresponding ClusterIssuer (no
// private key on-cluster, so they can't sign new certs). These
// are typically external domain CAs whose root cert is distributed
// so workloads can validate connections.
HashSet issuerSecrets = results
.Where(r => !string.IsNullOrEmpty(r.SecretName))
.Select(r => r.SecretName)
.ToHashSet();
// Track thumbprints to avoid duplicates when the same cert appears
// in multiple trust bundle sources.
HashSet seenThumbprints = results
.Where(r => r.Thumbprint is not null)
.Select(r => r.Thumbprint!)
.ToHashSet();
foreach (TrustBundleSourceInfo source in trustBundleSources)
{
// Skip Secret sources already covered by a ClusterIssuer.
if (source.SourceType == TrustSourceType.Secret && issuerSecrets.Contains(source.Name))
{
continue;
}
List trustOnlyCAs = await ReadTrustOnlyCAs(client, source, ct);
foreach (CertificateAuthorityInfo ca in trustOnlyCAs)
{
// Deduplicate by thumbprint — the same cert might be referenced
// from multiple sources or be identical to a ClusterIssuer's cert.
if (ca.Thumbprint is not null && !seenThumbprints.Add(ca.Thumbprint))
{
continue;
}
results.Add(ca);
}
}
// Some CA secrets exist in the cert-manager namespace without being
// referenced by a trust-manager Bundle or backing a ClusterIssuer.
// These are typically externally-managed CA chains imported via
// Terraform, external-secrets, or manual creation and distributed
// to workload namespaces through other mechanisms.
// Build a combined set of secrets already covered — ClusterIssuer secrets
// plus trust bundle secrets in cert-manager namespace — so we don't
// double-discover certs we've already found.
HashSet coveredSecrets = new(issuerSecrets);
foreach (TrustBundleSourceInfo source in trustBundleSources)
{
if (source.SourceType == TrustSourceType.Secret
&& string.Equals(source.Namespace, "cert-manager", StringComparison.OrdinalIgnoreCase))
{
coveredSecrets.Add(source.Name);
}
}
List uncoveredCAs = await DiscoverUncoveredCASecrets(
client, coveredSecrets, seenThumbprints, ct);
results.AddRange(uncoveredCAs);
return results;
}
///
/// Lists server/leaf certificates found across all namespaces on the cluster.
/// These are TLS certificates used by services — not CA certificates. The
/// method scans all namespaces for Secrets containing cert data, parses the
/// X.509 details, and deduplicates by thumbprint so each certificate appears
/// only once even if replicated across many namespaces.
///
/// This is complementary to ListAsync (which shows CAs). Together they give
/// a full picture of certificates on the cluster.
///
public async Task> ListCertificatesAsync(Guid clusterId, CancellationToken ct)
{
KubernetesCluster? cluster = await clusterRepository.GetByIdAsync(clusterId, ct);
if (cluster is null)
{
return new List();
}
Kubernetes client = BuildClient(cluster);
List results = new();
HashSet seenThumbprints = new();
// Load trust bundle sources so we can mark which certs are already
// published. This lets the UI show a publish button only for certs
// that aren't in the bundle yet.
List trustBundleSources = await GetTrustBundleSources(client, ct);
HashSet trustBundleSecrets = trustBundleSources
.Where(s => s.SourceType == TrustSourceType.Secret)
.Select(s => s.Name)
.ToHashSet();
try
{
// Scan all namespaces for TLS and Opaque secrets containing cert
// data. Externally-managed certs (imported via Terraform, external-
// secrets, or manual creation) can be either type.
V1SecretList secrets = await client.CoreV1.ListSecretForAllNamespacesAsync(
cancellationToken: ct);
foreach (V1Secret secret in secrets.Items)
{
if (secret.Type != "kubernetes.io/tls" && secret.Type != "Opaque")
{
continue;
}
if (secret.Data is null)
{
continue;
}
byte[]? certData = null;
if (!secret.Data.TryGetValue("tls.crt", out certData)
&& !secret.Data.TryGetValue("ca.crt", out certData))
{
continue;
}
string secretName = secret.Metadata.Name;
string namespaceName = secret.Metadata.NamespaceProperty ?? "default";
// Parse all PEM certificates. We only take the first cert
// in the chain (the leaf) for the certificate list. CA certs
// in the chain belong in the CA list, not here.
List certBlobs = ExtractPemCertificates(certData);
if (certBlobs.Count == 0)
{
continue;
}
try
{
byte[] leafBytes = certBlobs[0];
X509Certificate2 cert = X509CertificateLoader.LoadCertificate(leafBytes);
// Skip CA certificates — those belong in the CA list.
X509BasicConstraintsExtension? basicConstraints = cert.Extensions
.OfType()
.FirstOrDefault();
if (basicConstraints is not null && basicConstraints.CertificateAuthority)
{
continue;
}
CertDetails details = ExtractCertDetails(leafBytes);
// Deduplicate by thumbprint — the same cert is often
// replicated across many namespaces.
if (details.Thumbprint is not null && !seenThumbprints.Add(details.Thumbprint))
{
continue;
}
// Collect which namespaces this secret appears in. We'll
// track the first namespace and count the rest below.
results.Add(new CertificateInfo(
Name: details.CommonName ?? secretName,
SecretName: secretName,
Namespace: namespaceName,
Domains: details.Domains,
NotBefore: details.NotBefore,
NotAfter: details.NotAfter,
Organization: details.Organization,
Subject: details.Subject,
IssuerDN: details.IssuerDN,
SerialNumber: details.SerialNumber,
Thumbprint: details.Thumbprint,
SecretType: secret.Type ?? "Opaque",
IsWildcard: details.CommonName?.StartsWith("*.") == true
|| details.Domains.Any(d => d.StartsWith("*.")),
InTrustBundle: trustBundleSecrets.Contains(secretName)));
}
catch
{
// Malformed cert — skip.
}
}
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to list certificates for cluster {ClusterId}", clusterId);
}
return results;
}
///
/// Creates a new internal CA on the cluster. This provisions the full cert-manager
/// chain: self-signed bootstrap issuer → CA Certificate → CA ClusterIssuer, then
/// adds the root cert to the trust bundle.
///
public async Task CreateInternalCAAsync(
Guid clusterId, CreateInternalCARequest request, CancellationToken ct)
{
KubernetesCluster? cluster = await clusterRepository.GetByIdAsync(clusterId, ct);
if (cluster is null)
{
return CaOperationResult.Failure("Cluster not found.");
}
Kubernetes client = BuildClient(cluster);
List actions = new();
try
{
// Step 1: Ensure the self-signed bootstrap issuer exists.
await EnsureSelfSignedBootstrapIssuer(client, ct);
actions.Add("Ensured self-signed bootstrap ClusterIssuer exists");
// Step 2: Create the CA Certificate (signed by the bootstrap issuer).
string secretName = $"{request.Name}-secret";
string durationHours = $"{request.DurationDays * 24}h";
await CreateCACertificate(client, request.Name, secretName,
request.Organization, durationHours, ct);
actions.Add($"Created CA Certificate '{request.Name}'");
// Step 3: Create the CA ClusterIssuer that signs certs using the CA key pair.
await CreateCAClusterIssuer(client, request.Name, secretName, ct);
actions.Add($"Created CA ClusterIssuer '{request.Name}'");
// Step 4: Add the CA cert to the platform trust bundle.
string bundleName = request.BundleName ?? DefaultBundleName;
await AddCAToTrustBundle(client, bundleName, secretName, ct);
actions.Add($"Added CA to trust bundle '{bundleName}'");
logger.LogInformation("Created internal CA '{CA}' on cluster {Cluster}", request.Name, clusterId);
return CaOperationResult.Ok($"Internal CA '{request.Name}' created.", actions);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to create internal CA on cluster {ClusterId}", clusterId);
return CaOperationResult.Failure($"Failed to create CA: {ex.Message}", actions);
}
}
///
/// Creates a domain-scoped CA. If tlsCert and tlsKey are provided, imports
/// an external CA. Otherwise creates a self-signed CA restricted to the
/// given domains via annotations.
///
public async Task CreateDomainCAAsync(
Guid clusterId, CreateDomainCARequest request, CancellationToken ct)
{
KubernetesCluster? cluster = await clusterRepository.GetByIdAsync(clusterId, ct);
if (cluster is null)
{
return CaOperationResult.Failure("Cluster not found.");
}
Kubernetes client = BuildClient(cluster);
List actions = new();
try
{
string secretName = $"{request.Name}-secret";
bool isExternal = !string.IsNullOrEmpty(request.TlsCert);
if (isExternal)
{
// Import mode — create a TLS Secret from the provided cert+key.
await CreateTlsSecret(client, secretName, request.TlsCert!, request.TlsKey, ct);
actions.Add($"Created TLS Secret '{secretName}' from imported certificate");
if (!string.IsNullOrEmpty(request.TlsKey))
{
// Full import (cert + key) — create a CA ClusterIssuer.
await CreateDomainCAClusterIssuer(client, request.Name, secretName,
request.Domains, isExternal: true, ct);
actions.Add($"Created domain CA ClusterIssuer '{request.Name}'");
}
else
{
// Trust-only import — no ClusterIssuer, just add to trust bundle.
actions.Add("Trust-only import (no private key) — skipping ClusterIssuer");
}
}
else
{
// Self-signed domain CA — provision via cert-manager chain.
await EnsureSelfSignedBootstrapIssuer(client, ct);
string durationHours = $"{(request.DurationDays ?? 1825) * 24}h";
string organization = request.Organization ?? "EntKube Domain CA";
await CreateCACertificate(client, request.Name, secretName, organization, durationHours, ct);
actions.Add($"Created CA Certificate '{request.Name}'");
await CreateDomainCAClusterIssuer(client, request.Name, secretName,
request.Domains, isExternal: false, ct);
actions.Add($"Created domain CA ClusterIssuer '{request.Name}'");
}
// Add to trust bundle.
string bundleName = request.BundleName ?? DefaultBundleName;
await AddCAToTrustBundle(client, bundleName, secretName, ct);
actions.Add($"Added CA to trust bundle '{bundleName}'");
logger.LogInformation("Created domain CA '{CA}' for domains [{Domains}] on cluster {Cluster}",
request.Name, string.Join(", ", request.Domains), clusterId);
return CaOperationResult.Ok($"Domain CA '{request.Name}' created.", actions);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to create domain CA on cluster {ClusterId}", clusterId);
return CaOperationResult.Failure($"Failed to create domain CA: {ex.Message}", actions);
}
}
///
/// Deletes a CA by removing its ClusterIssuer, Certificate, and Secret resources.
/// Also removes the CA from the trust bundle.
///
public async Task DeleteCAAsync(Guid clusterId, string caName, CancellationToken ct)
{
KubernetesCluster? cluster = await clusterRepository.GetByIdAsync(clusterId, ct);
if (cluster is null)
{
return CaOperationResult.Failure("Cluster not found.");
}
Kubernetes client = BuildClient(cluster);
List actions = new();
try
{
// Find the CA's secret name before deleting.
string? secretName = await GetIssuerSecretName(client, caName, ct);
// Delete the ClusterIssuer.
await DeleteClusterIssuerSafe(client, caName, ct);
actions.Add($"Deleted ClusterIssuer '{caName}'");
// Delete the Certificate resource (in cert-manager namespace).
await DeleteCertificateSafe(client, caName, ct);
actions.Add($"Deleted Certificate '{caName}'");
// Delete the CA Secret.
if (secretName is not null)
{
await DeleteSecretSafe(client, secretName, ct);
actions.Add($"Deleted Secret '{secretName}'");
// Remove from trust bundle.
await RemoveCAFromTrustBundle(client, DefaultBundleName, secretName, ct);
actions.Add($"Removed CA from trust bundle");
}
logger.LogInformation("Deleted CA '{CA}' from cluster {Cluster}", caName, clusterId);
return CaOperationResult.Ok($"CA '{caName}' deleted.", actions);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to delete CA '{CA}' from cluster {ClusterId}", caName, clusterId);
return CaOperationResult.Failure($"Failed to delete CA: {ex.Message}", actions);
}
}
///
/// Rotates a CA by deleting and recreating its Certificate resource. cert-manager
/// automatically re-issues the CA certificate with a new key pair. All certificates
/// signed by the old CA will still be valid until they expire, and new certificates
/// will be signed by the new key.
///
public async Task RotateCAAsync(Guid clusterId, string caName, CancellationToken ct)
{
KubernetesCluster? cluster = await clusterRepository.GetByIdAsync(clusterId, ct);
if (cluster is null)
{
return CaOperationResult.Failure("Cluster not found.");
}
Kubernetes client = BuildClient(cluster);
List actions = new();
try
{
// Read the existing Certificate resource to preserve its config.
string? secretName = await GetIssuerSecretName(client, caName, ct);
if (secretName is null)
{
return CaOperationResult.Failure($"CA '{caName}' not found or has no CA secret.");
}
// Delete the existing CA Secret — cert-manager will detect the missing
// Secret and re-issue the Certificate, generating a new key pair.
await DeleteSecretSafe(client, secretName, ct);
actions.Add($"Deleted CA Secret '{secretName}' to trigger rotation");
actions.Add("cert-manager will re-issue the Certificate with a new key pair");
// Update the trust bundle to pick up the new certificate once re-issued.
actions.Add("Trust bundle will be updated once the new certificate is ready");
logger.LogInformation("Rotated CA '{CA}' on cluster {Cluster}", caName, clusterId);
return CaOperationResult.Ok($"CA '{caName}' rotation initiated.", actions);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to rotate CA '{CA}' on cluster {ClusterId}", caName, clusterId);
return CaOperationResult.Failure($"Failed to rotate CA: {ex.Message}", actions);
}
}
///
/// Publishes a CA certificate to a trust-manager Bundle so workloads across
/// the cluster can trust it. This is for discovered CAs that exist as Secrets
/// in the cert-manager namespace but aren't referenced by any Bundle yet.
/// The user picks a target Bundle (or uses the default platform-trust-bundle),
/// and we add the Secret as a source. We also detect which key in the Secret
/// contains the certificate data (tls.crt or ca.crt).
///
public async Task PublishToTrustBundleAsync(
Guid clusterId, string secretName, string? bundleName, CancellationToken ct)
{
KubernetesCluster? cluster = await clusterRepository.GetByIdAsync(clusterId, ct);
if (cluster is null)
{
return CaOperationResult.Failure("Cluster not found.");
}
Kubernetes client = BuildClient(cluster);
List actions = new();
try
{
// The secret may live in any namespace — find it by searching
// all namespaces. Externally-managed CA secrets (e.g., imported
// via Terraform or external-secrets) often live in workload
// namespaces rather than cert-manager.
string certKey = "tls.crt";
string? foundNamespace = null;
try
{
V1SecretList allSecrets = await client.CoreV1.ListSecretForAllNamespacesAsync(
fieldSelector: $"metadata.name={secretName}",
cancellationToken: ct);
// Find the first matching secret that has cert data.
foreach (V1Secret candidate in allSecrets.Items)
{
if (candidate.Data is null)
{
continue;
}
if (candidate.Data.ContainsKey("tls.crt"))
{
certKey = "tls.crt";
foundNamespace = candidate.Metadata.NamespaceProperty;
break;
}
if (candidate.Data.ContainsKey("ca.crt"))
{
certKey = "ca.crt";
foundNamespace = candidate.Metadata.NamespaceProperty;
break;
}
}
}
catch (Exception ex)
{
logger.LogDebug(ex, "Error searching for secret '{Secret}' across namespaces", secretName);
}
if (foundNamespace is null)
{
return CaOperationResult.Failure($"Secret '{secretName}' not found in any namespace.");
}
// Add the Secret as a source to the target Bundle. When the secret
// lives outside cert-manager, we include the namespace in the Bundle
// source spec so trust-manager can find it.
string targetBundle = bundleName ?? DefaultBundleName;
await AddCAToTrustBundleWithKey(client, targetBundle, secretName, certKey, foundNamespace, ct);
actions.Add($"Added Secret '{foundNamespace}/{secretName}' (key: {certKey}) to Bundle '{targetBundle}'");
logger.LogInformation(
"Published CA secret '{Namespace}/{Secret}' to trust bundle '{Bundle}' on cluster {Cluster}",
foundNamespace, secretName, targetBundle, clusterId);
return CaOperationResult.Ok(
$"Certificate from '{secretName}' published to trust bundle '{targetBundle}'.", actions);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to publish CA '{Secret}' to trust bundle on cluster {ClusterId}",
secretName, clusterId);
return CaOperationResult.Failure($"Failed to publish to trust bundle: {ex.Message}", actions);
}
}
// ─── Private helpers ──────────────────────────────────────────────────
///
/// Parses a single ClusterIssuer JSON element into a CertificateAuthorityInfo,
/// classifying it as internal, domain, or LetsEncrypt based on its spec.
/// Returns null for issuers we don't track (e.g., pure self-signed bootstrap).
///
private async Task ParseClusterIssuer(
Kubernetes client, JsonElement issuer, HashSet trustBundleSecrets, CancellationToken ct)
{
if (!issuer.TryGetProperty("metadata", out JsonElement metadata))
{
return null;
}
string? name = metadata.TryGetProperty("name", out JsonElement nameEl) ? nameEl.GetString() : null;
if (name is null)
{
return null;
}
// Skip the self-signed bootstrap issuer — it's infrastructure, not a real CA.
if (name == "selfsigned-bootstrap")
{
return null;
}
if (!issuer.TryGetProperty("spec", out JsonElement spec))
{
return null;
}
// Check for ACME (Let's Encrypt).
if (spec.TryGetProperty("acme", out _))
{
string acmeStatus = GetIssuerStatus(issuer);
return new CertificateAuthorityInfo(
Name: name,
Type: CaType.LetsEncrypt,
SecretName: string.Empty,
Domains: new List(),
IsExternal: true,
InTrustBundle: true,
Status: acmeStatus,
NotBefore: null,
NotAfter: null,
Organization: "Let's Encrypt");
}
// Check for CA issuer.
if (spec.TryGetProperty("ca", out JsonElement ca) &&
ca.TryGetProperty("secretName", out JsonElement secretNameEl))
{
string secretName = secretNameEl.GetString() ?? string.Empty;
// Classify as domain CA if it has domain annotations.
List domains = new();
bool isExternal = false;
if (metadata.TryGetProperty("annotations", out JsonElement annotations))
{
if (annotations.TryGetProperty("entkube.io/domains", out JsonElement domainsEl))
{
string? domainsStr = domainsEl.GetString();
if (!string.IsNullOrEmpty(domainsStr))
{
domains = domainsStr.Split(',', StringSplitOptions.RemoveEmptyEntries).ToList();
}
}
if (annotations.TryGetProperty("entkube.io/external-ca", out JsonElement extEl))
{
isExternal = extEl.GetString() == "true";
}
}
CaType type = domains.Count > 0 ? CaType.Domain : CaType.Internal;
bool inTrustBundle = trustBundleSecrets.Contains(secretName);
string status = GetIssuerStatus(issuer);
// Read cert details from the Secret.
CertDetails? details = await ReadCertValidity(client, secretName, ct);
return new CertificateAuthorityInfo(
Name: name,
Type: type,
SecretName: secretName,
Domains: domains,
IsExternal: isExternal,
InTrustBundle: inTrustBundle,
Status: status,
NotBefore: details?.NotBefore,
NotAfter: details?.NotAfter,
Organization: details?.Organization,
Subject: details?.Subject,
IssuerDN: details?.IssuerDN,
SerialNumber: details?.SerialNumber,
Thumbprint: details?.Thumbprint);
}
return null;
}
private static string GetIssuerStatus(JsonElement issuer)
{
if (issuer.TryGetProperty("status", out JsonElement status) &&
status.TryGetProperty("conditions", out JsonElement conditions))
{
foreach (JsonElement condition in conditions.EnumerateArray())
{
if (condition.TryGetProperty("type", out JsonElement type) &&
type.GetString() == "Ready")
{
string? condStatus = condition.TryGetProperty("status", out JsonElement s)
? s.GetString() : null;
return condStatus == "True" ? "Ready" : "NotReady";
}
}
}
return "Unknown";
}
///
/// Reads the TLS certificate from a Secret and extracts validity dates,
/// organization, subject, issuer, serial number, and thumbprint.
///
private async Task ReadCertValidity(
Kubernetes client, string secretName, CancellationToken ct)
{
try
{
V1Secret secret = await client.CoreV1.ReadNamespacedSecretAsync(
secretName, "cert-manager", cancellationToken: ct);
if (secret.Data?.TryGetValue("tls.crt", out byte[]? certBytes) == true)
{
return ExtractCertDetails(certBytes);
}
}
catch (Exception ex)
{
logger.LogDebug(ex, "Could not read cert from Secret '{Secret}'", secretName);
}
return null;
}
///
/// Extracts all relevant details from raw certificate bytes. Used by both
/// the ClusterIssuer path and the trust-bundle-only path.
///
private static CertDetails ExtractCertDetails(byte[] certBytes)
{
X509Certificate2 cert = X509CertificateLoader.LoadCertificate(certBytes);
string? org = cert.Subject
.Split(',')
.Select(s => s.Trim())
.FirstOrDefault(s => s.StartsWith("O="))?
.Substring(2);
string? cn = cert.Subject
.Split(',')
.Select(s => s.Trim())
.FirstOrDefault(s => s.StartsWith("CN="))?
.Substring(3);
List domains = new();
X509SubjectAlternativeNameExtension? sanExt = cert.Extensions
.OfType()
.FirstOrDefault();
if (sanExt is not null)
{
foreach (string dns in sanExt.EnumerateDnsNames())
{
domains.Add(dns);
}
}
return new CertDetails(
NotBefore: cert.NotBefore,
NotAfter: cert.NotAfter,
Organization: org,
CommonName: cn,
Subject: cert.Subject,
IssuerDN: cert.Issuer,
SerialNumber: cert.SerialNumber,
Thumbprint: cert.Thumbprint,
Domains: domains);
}
///
/// Scans the cert-manager namespace for Secrets containing CA certificates
/// that aren't already discovered through ClusterIssuers or trust-manager
/// Bundle sources. This catches externally-managed CA chains imported via
/// Terraform, external-secrets, or manual creation — certs that exist as
/// Kubernetes Secrets but aren't published through trust-manager Bundles.
///
/// Only includes certificates where the X.509 Basic Constraints extension
/// marks them as CA certificates, avoiding noise from leaf/service certs.
///
private async Task> DiscoverUncoveredCASecrets(
Kubernetes client,
HashSet alreadyCoveredSecrets,
HashSet seenThumbprints,
CancellationToken ct)
{
List results = new();
try
{
// List all secrets in the cert-manager namespace. This namespace is
// the conventional home for CA certificate material managed by or
// alongside cert-manager.
V1SecretList secrets = await client.CoreV1.ListNamespacedSecretAsync(
"cert-manager", cancellationToken: ct);
foreach (V1Secret secret in secrets.Items)
{
string secretName = secret.Metadata.Name;
// Skip secrets already covered by a ClusterIssuer or trust bundle.
if (alreadyCoveredSecrets.Contains(secretName))
{
continue;
}
if (secret.Data is null)
{
continue;
}
// Try common certificate data keys.
byte[]? certData = null;
if (secret.Data.TryGetValue("tls.crt", out certData)
|| secret.Data.TryGetValue("ca.crt", out certData))
{
// Found cert data — parse it.
}
else
{
continue;
}
// Parse all PEM certificates from the data (could be a chain).
List certBlobs = ExtractPemCertificates(certData);
foreach (byte[] certBytes in certBlobs)
{
try
{
// Only include certificates marked as CA in X.509 Basic Constraints.
// This filters out leaf/service certificates that happen to be
// stored in the cert-manager namespace.
X509Certificate2 cert = X509CertificateLoader.LoadCertificate(certBytes);
X509BasicConstraintsExtension? basicConstraints = cert.Extensions
.OfType()
.FirstOrDefault();
if (basicConstraints is null || !basicConstraints.CertificateAuthority)
{
continue;
}
CertDetails details = ExtractCertDetails(certBytes);
// Deduplicate — skip if we've already seen this exact cert.
if (details.Thumbprint is not null && !seenThumbprints.Add(details.Thumbprint))
{
continue;
}
string name = details.CommonName ?? secretName;
results.Add(new CertificateAuthorityInfo(
Name: name,
Type: CaType.Domain,
SecretName: secretName,
Domains: details.Domains,
IsExternal: true,
InTrustBundle: false,
Status: "Discovered",
NotBefore: details.NotBefore,
NotAfter: details.NotAfter,
Organization: details.Organization,
Subject: details.Subject,
IssuerDN: details.IssuerDN,
SerialNumber: details.SerialNumber,
Thumbprint: details.Thumbprint));
}
catch
{
// Malformed cert — skip.
}
}
}
}
catch (Exception ex)
{
logger.LogDebug(ex, "Could not scan cert-manager namespace for uncovered CA secrets");
}
return results;
}
///
/// Scans all trust-manager Bundle CRs and collects every source that
/// references a certificate — Secrets, ConfigMaps, and inline PEM.
/// This serves two purposes: checking whether a CA is trusted, and
/// discovering CAs that exist only in trust bundles.
///
private async Task> GetTrustBundleSources(Kubernetes client, CancellationToken ct)
{
List sources = new();
try
{
JsonElement bundles = await client.CustomObjects.ListClusterCustomObjectAsync(
group: "trust.cert-manager.io",
version: "v1alpha1",
plural: "bundles",
cancellationToken: ct);
if (bundles.TryGetProperty("items", out JsonElement items))
{
foreach (JsonElement bundle in items.EnumerateArray())
{
if (bundle.TryGetProperty("spec", out JsonElement spec) &&
spec.TryGetProperty("sources", out JsonElement bundleSources))
{
foreach (JsonElement source in bundleSources.EnumerateArray())
{
// Secret source — most common for cert-manager CAs.
// The namespace field tells us where the Secret lives;
// it defaults to the trust-manager namespace (cert-manager)
// but can reference Secrets in any namespace.
if (source.TryGetProperty("secret", out JsonElement secretSource) &&
secretSource.TryGetProperty("name", out JsonElement nameEl))
{
string? name = nameEl.GetString();
string key = secretSource.TryGetProperty("key", out JsonElement keyEl)
? keyEl.GetString() ?? "tls.crt"
: "tls.crt";
string ns = secretSource.TryGetProperty("namespace", out JsonElement nsEl)
? nsEl.GetString() ?? "cert-manager"
: "cert-manager";
if (name is not null)
{
sources.Add(new TrustBundleSourceInfo(
SourceType: TrustSourceType.Secret,
Name: name,
Key: key,
InlineData: null,
Namespace: ns));
}
}
// ConfigMap source — often used for externally-imported CA certs.
if (source.TryGetProperty("configMap", out JsonElement cmSource) &&
cmSource.TryGetProperty("name", out JsonElement cmNameEl))
{
string? name = cmNameEl.GetString();
string key = cmSource.TryGetProperty("key", out JsonElement cmKeyEl)
? cmKeyEl.GetString() ?? "ca.crt"
: "ca.crt";
string ns = cmSource.TryGetProperty("namespace", out JsonElement cmNsEl)
? cmNsEl.GetString() ?? "cert-manager"
: "cert-manager";
if (name is not null)
{
sources.Add(new TrustBundleSourceInfo(
SourceType: TrustSourceType.ConfigMap,
Name: name,
Key: key,
InlineData: null,
Namespace: ns));
}
}
// Inline PEM — raw certificate embedded directly in the Bundle spec.
if (source.TryGetProperty("inLine", out JsonElement inlineEl))
{
string? pem = inlineEl.GetString();
if (!string.IsNullOrEmpty(pem))
{
sources.Add(new TrustBundleSourceInfo(
SourceType: TrustSourceType.InLine,
Name: "inline",
Key: string.Empty,
InlineData: pem));
}
}
}
}
}
}
}
catch (Exception ex)
{
logger.LogDebug(ex, "Could not list trust bundles");
}
return sources;
}
///
/// Reads CAs from a trust bundle source that doesn't have a corresponding
/// ClusterIssuer. A single source may contain multiple PEM-encoded certificates
/// (e.g., a Secret or ConfigMap with concatenated PEM certs), so this returns
/// a list rather than a single item.
///
private async Task> ReadTrustOnlyCAs(
Kubernetes client, TrustBundleSourceInfo source, CancellationToken ct)
{
List results = new();
try
{
byte[]? rawBytes = null;
if (source.SourceType == TrustSourceType.Secret)
{
// Read certificate data from the Secret in its declared namespace.
rawBytes = await ReadSecretCertData(client, source.Name, source.Key, source.Namespace, ct);
}
else if (source.SourceType == TrustSourceType.ConfigMap)
{
// Read certificate data from the ConfigMap in its declared namespace.
rawBytes = await ReadConfigMapCertData(client, source.Name, source.Key, source.Namespace, ct);
}
else if (source.SourceType == TrustSourceType.InLine && source.InlineData is not null)
{
// Inline PEM is already available.
rawBytes = Encoding.UTF8.GetBytes(source.InlineData);
}
if (rawBytes is null || rawBytes.Length == 0)
{
return results;
}
// Parse all PEM certificates from the data. A single source may
// contain multiple concatenated PEM blocks (one per CA cert).
List certBlobs = ExtractPemCertificates(rawBytes);
foreach (byte[] certBytes in certBlobs)
{
CertDetails details = ExtractCertDetails(certBytes);
// Use the CN as the display name, falling back to the source name.
string name = details.CommonName ?? source.Name;
results.Add(new CertificateAuthorityInfo(
Name: name,
Type: CaType.Domain,
SecretName: source.Name,
Domains: details.Domains,
IsExternal: true,
InTrustBundle: true,
Status: "Trust-Only",
NotBefore: details.NotBefore,
NotAfter: details.NotAfter,
Organization: details.Organization,
Subject: details.Subject,
IssuerDN: details.IssuerDN,
SerialNumber: details.SerialNumber,
Thumbprint: details.Thumbprint));
}
}
catch (Exception ex)
{
logger.LogDebug(ex, "Could not read trust-only CA from source '{Source}'", source.Name);
}
return results;
}
///
/// Reads certificate data from a Secret by trying the specified key
/// and common fallbacks (tls.crt, ca.crt).
///
private static async Task ReadSecretCertData(
Kubernetes client, string secretName, string key, string namespaceName, CancellationToken ct)
{
try
{
V1Secret secret = await client.CoreV1.ReadNamespacedSecretAsync(
secretName, namespaceName, cancellationToken: ct);
if (secret.Data is null)
{
return null;
}
if (secret.Data.TryGetValue(key, out byte[]? data))
{
return data;
}
if (secret.Data.TryGetValue("tls.crt", out data))
{
return data;
}
if (secret.Data.TryGetValue("ca.crt", out data))
{
return data;
}
}
catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
// Secret doesn't exist — might be in a different namespace.
}
return null;
}
///
/// Reads certificate data from a ConfigMap by trying the specified key
/// and common fallbacks (ca.crt, ca-certificates.crt).
///
private static async Task ReadConfigMapCertData(
Kubernetes client, string configMapName, string key, string namespaceName, CancellationToken ct)
{
try
{
V1ConfigMap cm = await client.CoreV1.ReadNamespacedConfigMapAsync(
configMapName, namespaceName, cancellationToken: ct);
if (cm.Data is null)
{
return null;
}
string? pem = null;
if (cm.Data.TryGetValue(key, out pem) ||
cm.Data.TryGetValue("ca.crt", out pem) ||
cm.Data.TryGetValue("ca-certificates.crt", out pem))
{
return Encoding.UTF8.GetBytes(pem);
}
}
catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
// ConfigMap doesn't exist — might be in a different namespace.
}
return null;
}
///
/// Splits raw PEM data into individual DER-encoded certificate blobs.
/// Handles both single certs and concatenated multi-cert PEM bundles.
///
private static List ExtractPemCertificates(byte[] rawBytes)
{
List certs = new();
string pem = Encoding.UTF8.GetString(rawBytes);
// If the data doesn't look like PEM, try loading it as a single DER blob.
if (!pem.Contains("-----BEGIN CERTIFICATE-----"))
{
try
{
X509CertificateLoader.LoadCertificate(rawBytes);
certs.Add(rawBytes);
}
catch
{
// Not a valid cert — skip.
}
return certs;
}
// Split concatenated PEM blocks and decode each one.
int startIndex = 0;
const string beginMarker = "-----BEGIN CERTIFICATE-----";
const string endMarker = "-----END CERTIFICATE-----";
while (true)
{
int begin = pem.IndexOf(beginMarker, startIndex, StringComparison.Ordinal);
if (begin < 0)
{
break;
}
int end = pem.IndexOf(endMarker, begin, StringComparison.Ordinal);
if (end < 0)
{
break;
}
int afterEnd = end + endMarker.Length;
string singlePem = pem[begin..afterEnd];
string base64 = singlePem
.Replace(beginMarker, string.Empty)
.Replace(endMarker, string.Empty)
.Trim();
try
{
byte[] certBytes = Convert.FromBase64String(base64);
certs.Add(certBytes);
}
catch
{
// Malformed PEM block — skip it.
}
startIndex = afterEnd;
}
return certs;
}
private async Task GetIssuerSecretName(Kubernetes client, string issuerName, CancellationToken ct)
{
try
{
JsonElement issuer = await client.CustomObjects.GetClusterCustomObjectAsync(
"cert-manager.io", "v1", "clusterissuers", issuerName, ct);
if (issuer.TryGetProperty("spec", out JsonElement spec) &&
spec.TryGetProperty("ca", out JsonElement ca) &&
ca.TryGetProperty("secretName", out JsonElement secretName))
{
return secretName.GetString();
}
}
catch (Exception ex)
{
logger.LogDebug(ex, "Failed to read ClusterIssuer '{Issuer}'", issuerName);
}
return null;
}
// ─── K8s resource creation helpers ────────────────────────────────────
private static async Task EnsureSelfSignedBootstrapIssuer(Kubernetes client, CancellationToken ct)
{
Dictionary issuer = new()
{
["apiVersion"] = "cert-manager.io/v1",
["kind"] = "ClusterIssuer",
["metadata"] = new Dictionary
{
["name"] = "selfsigned-bootstrap"
},
["spec"] = new Dictionary
{
["selfSigned"] = new Dictionary()
}
};
await ApplyClusterCustomObject(client, "cert-manager.io", "v1", "clusterissuers",
"selfsigned-bootstrap", issuer, ct);
}
private static async Task CreateCACertificate(
Kubernetes client, string caName, string secretName, string organization,
string duration, CancellationToken ct)
{
Dictionary certificate = new()
{
["apiVersion"] = "cert-manager.io/v1",
["kind"] = "Certificate",
["metadata"] = new Dictionary
{
["name"] = caName,
["namespace"] = "cert-manager"
},
["spec"] = new Dictionary
{
["isCA"] = true,
["duration"] = duration,
["secretName"] = secretName,
["commonName"] = $"{caName} Root CA",
["subject"] = new Dictionary
{
["organizations"] = new List { organization }
},
["privateKey"] = new Dictionary
{
["algorithm"] = "ECDSA",
["size"] = 256
},
["issuerRef"] = new Dictionary
{
["name"] = "selfsigned-bootstrap",
["kind"] = "ClusterIssuer",
["group"] = "cert-manager.io"
}
}
};
await ApplyNamespacedCustomObject(client, "cert-manager.io", "v1", "cert-manager",
"certificates", caName, certificate, ct);
}
private static async Task CreateCAClusterIssuer(
Kubernetes client, string caName, string secretName, CancellationToken ct)
{
Dictionary issuer = new()
{
["apiVersion"] = "cert-manager.io/v1",
["kind"] = "ClusterIssuer",
["metadata"] = new Dictionary
{
["name"] = caName
},
["spec"] = new Dictionary
{
["ca"] = new Dictionary
{
["secretName"] = secretName
}
}
};
await ApplyClusterCustomObject(client, "cert-manager.io", "v1", "clusterissuers",
caName, issuer, ct);
}
private static async Task CreateDomainCAClusterIssuer(
Kubernetes client, string caName, string secretName,
List domains, bool isExternal, CancellationToken ct)
{
Dictionary annotations = new()
{
["entkube.io/domains"] = string.Join(",", domains)
};
if (isExternal)
{
annotations["entkube.io/external-ca"] = "true";
}
Dictionary issuer = new()
{
["apiVersion"] = "cert-manager.io/v1",
["kind"] = "ClusterIssuer",
["metadata"] = new Dictionary
{
["name"] = caName,
["annotations"] = annotations
},
["spec"] = new Dictionary
{
["ca"] = new Dictionary
{
["secretName"] = secretName
}
}
};
await ApplyClusterCustomObject(client, "cert-manager.io", "v1", "clusterissuers",
caName, issuer, ct);
}
private static async Task CreateTlsSecret(
Kubernetes client, string secretName, string tlsCert, string? tlsKey, CancellationToken ct)
{
Dictionary data = new()
{
["tls.crt"] = Convert.FromBase64String(tlsCert)
};
if (!string.IsNullOrEmpty(tlsKey))
{
data["tls.key"] = Convert.FromBase64String(tlsKey);
}
V1Secret secret = new()
{
Metadata = new V1ObjectMeta
{
Name = secretName,
NamespaceProperty = "cert-manager"
},
Type = "kubernetes.io/tls",
Data = data
};
try
{
await client.CoreV1.ReplaceNamespacedSecretAsync(secret, secretName, "cert-manager", cancellationToken: ct);
}
catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
await client.CoreV1.CreateNamespacedSecretAsync(secret, "cert-manager", cancellationToken: ct);
}
}
private async Task AddCAToTrustBundle(
Kubernetes client, string bundleName, string secretName, CancellationToken ct)
{
await AddCAToTrustBundleWithKey(client, bundleName, secretName, "tls.crt", "cert-manager", ct);
}
///
/// Adds a Secret as a source to a trust-manager Bundle. If the Bundle doesn't
/// exist, creates it with the Secret as the first source. The key parameter
/// specifies which data key in the Secret contains the certificate (e.g.,
/// "tls.crt" or "ca.crt"). The namespace tells trust-manager where to find
/// the Secret — cert-manager is the default, but externally-managed CAs may
/// live in other namespaces.
///
private async Task AddCAToTrustBundleWithKey(
Kubernetes client, string bundleName, string secretName, string certKey,
string namespaceName, CancellationToken ct)
{
try
{
JsonElement existing = await client.CustomObjects.GetClusterCustomObjectAsync(
"trust.cert-manager.io", "v1alpha1", "bundles", bundleName, ct);
List