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 sources = new(); bool alreadyPresent = false; if (existing.TryGetProperty("spec", out JsonElement spec) && spec.TryGetProperty("sources", out JsonElement sourcesEl)) { foreach (JsonElement source in sourcesEl.EnumerateArray()) { sources.Add(JsonSerializer.Deserialize(source.GetRawText())!); if (source.TryGetProperty("secret", out JsonElement secretSource) && secretSource.TryGetProperty("name", out JsonElement nameEl) && nameEl.GetString() == secretName) { alreadyPresent = true; } } } if (!alreadyPresent) { Dictionary secretSpec = new() { ["name"] = secretName, ["key"] = certKey }; // trust-manager defaults to cert-manager namespace; only // include the namespace field when the secret lives elsewhere. if (!string.Equals(namespaceName, "cert-manager", StringComparison.OrdinalIgnoreCase)) { secretSpec["namespace"] = namespaceName; } sources.Add(new Dictionary { ["secret"] = secretSpec }); Dictionary patch = new() { ["apiVersion"] = "trust.cert-manager.io/v1alpha1", ["kind"] = "Bundle", ["metadata"] = new Dictionary { ["name"] = bundleName }, ["spec"] = new Dictionary { ["sources"] = sources } }; await client.CustomObjects.PatchClusterCustomObjectAsync( body: new V1Patch(patch, V1Patch.PatchType.MergePatch), group: "trust.cert-manager.io", version: "v1alpha1", plural: "bundles", name: bundleName, cancellationToken: ct); } } catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) { // Bundle doesn't exist — create it with the CA as the first source. Dictionary newSecretSpec = new() { ["name"] = secretName, ["key"] = certKey }; if (!string.Equals(namespaceName, "cert-manager", StringComparison.OrdinalIgnoreCase)) { newSecretSpec["namespace"] = namespaceName; } Dictionary bundle = new() { ["apiVersion"] = "trust.cert-manager.io/v1alpha1", ["kind"] = "Bundle", ["metadata"] = new Dictionary { ["name"] = bundleName }, ["spec"] = new Dictionary { ["sources"] = new List { new Dictionary { ["useDefaultCAs"] = true }, new Dictionary { ["secret"] = newSecretSpec } }, ["target"] = new Dictionary { ["configMap"] = new Dictionary { ["key"] = "ca-certificates.crt" } } } }; await client.CustomObjects.CreateClusterCustomObjectAsync( body: bundle, group: "trust.cert-manager.io", version: "v1alpha1", plural: "bundles", cancellationToken: ct); } } private async Task RemoveCAFromTrustBundle( Kubernetes client, string bundleName, string secretName, CancellationToken ct) { try { JsonElement existing = await client.CustomObjects.GetClusterCustomObjectAsync( "trust.cert-manager.io", "v1alpha1", "bundles", bundleName, ct); if (existing.TryGetProperty("spec", out JsonElement spec) && spec.TryGetProperty("sources", out JsonElement sourcesEl)) { List sources = new(); foreach (JsonElement source in sourcesEl.EnumerateArray()) { // Skip the source that references our secret. if (source.TryGetProperty("secret", out JsonElement secretSource) && secretSource.TryGetProperty("name", out JsonElement nameEl) && nameEl.GetString() == secretName) { continue; } sources.Add(JsonSerializer.Deserialize(source.GetRawText())!); } Dictionary patch = new() { ["apiVersion"] = "trust.cert-manager.io/v1alpha1", ["kind"] = "Bundle", ["metadata"] = new Dictionary { ["name"] = bundleName }, ["spec"] = new Dictionary { ["sources"] = sources } }; await client.CustomObjects.PatchClusterCustomObjectAsync( body: new V1Patch(patch, V1Patch.PatchType.MergePatch), group: "trust.cert-manager.io", version: "v1alpha1", plural: "bundles", name: bundleName, cancellationToken: ct); } } catch (Exception ex) { logger.LogDebug(ex, "Could not remove CA from trust bundle '{Bundle}'", bundleName); } } // ─── Delete helpers ─────────────────────────────────────────────────── private static async Task DeleteClusterIssuerSafe(Kubernetes client, string name, CancellationToken ct) { try { await client.CustomObjects.DeleteClusterCustomObjectAsync( "cert-manager.io", "v1", "clusterissuers", name, cancellationToken: ct); } catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) { // Already gone — that's fine. } } private static async Task DeleteCertificateSafe(Kubernetes client, string name, CancellationToken ct) { try { await client.CustomObjects.DeleteNamespacedCustomObjectAsync( "cert-manager.io", "v1", "cert-manager", "certificates", name, cancellationToken: ct); } catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) { // Already gone. } } private static async Task DeleteSecretSafe(Kubernetes client, string name, CancellationToken ct) { try { await client.CoreV1.DeleteNamespacedSecretAsync(name, "cert-manager", cancellationToken: ct); } catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) { // Already gone. } } // ─── K8s apply helpers ──────────────────────────────────────────────── private static async Task ApplyClusterCustomObject( Kubernetes client, string group, string version, string plural, string name, Dictionary body, CancellationToken ct) { try { await client.CustomObjects.PatchClusterCustomObjectAsync( body: new V1Patch(body, V1Patch.PatchType.MergePatch), group: group, version: version, plural: plural, name: name, cancellationToken: ct); } catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) { await client.CustomObjects.CreateClusterCustomObjectAsync( body: body, group: group, version: version, plural: plural, cancellationToken: ct); } } private static async Task ApplyNamespacedCustomObject( Kubernetes client, string group, string version, string ns, string plural, string name, Dictionary body, CancellationToken ct) { try { await client.CustomObjects.PatchNamespacedCustomObjectAsync( body: new V1Patch(body, V1Patch.PatchType.MergePatch), group: group, version: version, namespaceParameter: ns, plural: plural, name: name, cancellationToken: ct); } catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) { await client.CustomObjects.CreateNamespacedCustomObjectAsync( body: body, group: group, version: version, namespaceParameter: ns, plural: plural, cancellationToken: ct); } } private static Kubernetes BuildClient(KubernetesCluster cluster) { byte[] kubeConfigBytes = Encoding.UTF8.GetBytes(cluster.KubeConfig); using MemoryStream stream = new(kubeConfigBytes); KubernetesClientConfiguration config = KubernetesClientConfiguration.BuildConfigFromConfigFile(stream, cluster.ContextName); return new Kubernetes(config); } } // ─── Request models ────────────────────────────────────────────────────── public record CreateInternalCARequest( string Name, string Organization = "EntKube Platform", int DurationDays = 3650, string? BundleName = null); public record CreateDomainCARequest( string Name, List Domains, string? TlsCert = null, string? TlsKey = null, string? Organization = null, int? DurationDays = null, string? BundleName = null); public record CaOperationResult(bool Success, string Message, List Actions) { public static CaOperationResult Ok(string message, List? actions = null) => new(true, message, actions ?? new()); public static CaOperationResult Failure(string message, List? actions = null) => new(false, message, actions ?? new()); } /// /// Represents a server/leaf TLS certificate discovered on the cluster. /// These are NOT CA certificates — they're service certs used by workloads, /// ingresses, etc. Includes the first namespace where the Secret was found /// and whether it's a wildcard. /// public record CertificateInfo( string Name, string SecretName, string Namespace, List Domains, DateTimeOffset? NotBefore, DateTimeOffset? NotAfter, string? Organization, string? Subject, string? IssuerDN, string? SerialNumber, string? Thumbprint, string SecretType, bool IsWildcard, bool InTrustBundle); /// /// Represents a source referenced by a trust-manager Bundle, which can /// be a Secret, ConfigMap, or inline PEM data. Includes the namespace /// where the source lives — trust-manager Bundle sources can reference /// Secrets and ConfigMaps in any namespace, not just cert-manager. /// internal record TrustBundleSourceInfo( TrustSourceType SourceType, string Name, string Key, string? InlineData, string Namespace = "cert-manager"); /// /// The type of trust bundle source — determines how to read the cert data. /// internal enum TrustSourceType { Secret, ConfigMap, InLine } /// /// Holds all extracted X.509 certificate details, used internally to pass /// cert information between parsing and model construction. /// internal record CertDetails( DateTimeOffset NotBefore, DateTimeOffset NotAfter, string? Organization, string? CommonName, string Subject, string IssuerDN, string SerialNumber, string Thumbprint, List Domains);