1817 lines
68 KiB
C#
1817 lines
68 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public class CertificateAuthorityHandler
|
|
{
|
|
private readonly IClusterRepository clusterRepository;
|
|
private readonly ILogger<CertificateAuthorityHandler> logger;
|
|
|
|
private const string DefaultBundleName = "platform-trust-bundle";
|
|
|
|
public CertificateAuthorityHandler(
|
|
IClusterRepository clusterRepository,
|
|
ILogger<CertificateAuthorityHandler> logger)
|
|
{
|
|
this.clusterRepository = clusterRepository;
|
|
this.logger = logger;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public async Task<List<CertificateAuthorityInfo>> ListAsync(Guid clusterId, CancellationToken ct)
|
|
{
|
|
KubernetesCluster? cluster = await clusterRepository.GetByIdAsync(clusterId, ct);
|
|
|
|
if (cluster is null)
|
|
{
|
|
return new List<CertificateAuthorityInfo>();
|
|
}
|
|
|
|
Kubernetes client = BuildClient(cluster);
|
|
List<CertificateAuthorityInfo> results = new();
|
|
|
|
// Collect all trust bundle sources so we can check membership
|
|
// and also discover CAs that only exist as trust entries.
|
|
|
|
List<TrustBundleSourceInfo> trustBundleSources = await GetTrustBundleSources(client, ct);
|
|
HashSet<string> 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<JsonElement>(
|
|
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<string> 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<string> 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<CertificateAuthorityInfo> 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<string> 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<CertificateAuthorityInfo> uncoveredCAs = await DiscoverUncoveredCASecrets(
|
|
client, coveredSecrets, seenThumbprints, ct);
|
|
|
|
results.AddRange(uncoveredCAs);
|
|
|
|
return results;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public async Task<List<CertificateInfo>> ListCertificatesAsync(Guid clusterId, CancellationToken ct)
|
|
{
|
|
KubernetesCluster? cluster = await clusterRepository.GetByIdAsync(clusterId, ct);
|
|
|
|
if (cluster is null)
|
|
{
|
|
return new List<CertificateInfo>();
|
|
}
|
|
|
|
Kubernetes client = BuildClient(cluster);
|
|
List<CertificateInfo> results = new();
|
|
HashSet<string> 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<TrustBundleSourceInfo> trustBundleSources = await GetTrustBundleSources(client, ct);
|
|
HashSet<string> 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<byte[]> 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<X509BasicConstraintsExtension>()
|
|
.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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public async Task<CaOperationResult> 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<string> 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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public async Task<CaOperationResult> 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<string> 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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Deletes a CA by removing its ClusterIssuer, Certificate, and Secret resources.
|
|
/// Also removes the CA from the trust bundle.
|
|
/// </summary>
|
|
public async Task<CaOperationResult> 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<string> 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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public async Task<CaOperationResult> 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<string> 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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
public async Task<CaOperationResult> 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<string> 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 ──────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
private async Task<CertificateAuthorityInfo?> ParseClusterIssuer(
|
|
Kubernetes client, JsonElement issuer, HashSet<string> 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<string>(),
|
|
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<string> 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";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads the TLS certificate from a Secret and extracts validity dates,
|
|
/// organization, subject, issuer, serial number, and thumbprint.
|
|
/// </summary>
|
|
private async Task<CertDetails?> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Extracts all relevant details from raw certificate bytes. Used by both
|
|
/// the ClusterIssuer path and the trust-bundle-only path.
|
|
/// </summary>
|
|
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<string> domains = new();
|
|
X509SubjectAlternativeNameExtension? sanExt = cert.Extensions
|
|
.OfType<X509SubjectAlternativeNameExtension>()
|
|
.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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private async Task<List<CertificateAuthorityInfo>> DiscoverUncoveredCASecrets(
|
|
Kubernetes client,
|
|
HashSet<string> alreadyCoveredSecrets,
|
|
HashSet<string> seenThumbprints,
|
|
CancellationToken ct)
|
|
{
|
|
List<CertificateAuthorityInfo> 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<byte[]> 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<X509BasicConstraintsExtension>()
|
|
.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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private async Task<List<TrustBundleSourceInfo>> GetTrustBundleSources(Kubernetes client, CancellationToken ct)
|
|
{
|
|
List<TrustBundleSourceInfo> sources = new();
|
|
|
|
try
|
|
{
|
|
JsonElement bundles = await client.CustomObjects.ListClusterCustomObjectAsync<JsonElement>(
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private async Task<List<CertificateAuthorityInfo>> ReadTrustOnlyCAs(
|
|
Kubernetes client, TrustBundleSourceInfo source, CancellationToken ct)
|
|
{
|
|
List<CertificateAuthorityInfo> 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<byte[]> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads certificate data from a Secret by trying the specified key
|
|
/// and common fallbacks (tls.crt, ca.crt).
|
|
/// </summary>
|
|
private static async Task<byte[]?> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads certificate data from a ConfigMap by trying the specified key
|
|
/// and common fallbacks (ca.crt, ca-certificates.crt).
|
|
/// </summary>
|
|
private static async Task<byte[]?> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Splits raw PEM data into individual DER-encoded certificate blobs.
|
|
/// Handles both single certs and concatenated multi-cert PEM bundles.
|
|
/// </summary>
|
|
private static List<byte[]> ExtractPemCertificates(byte[] rawBytes)
|
|
{
|
|
List<byte[]> 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<string?> GetIssuerSecretName(Kubernetes client, string issuerName, CancellationToken ct)
|
|
{
|
|
try
|
|
{
|
|
JsonElement issuer = await client.CustomObjects.GetClusterCustomObjectAsync<JsonElement>(
|
|
"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<string, object> issuer = new()
|
|
{
|
|
["apiVersion"] = "cert-manager.io/v1",
|
|
["kind"] = "ClusterIssuer",
|
|
["metadata"] = new Dictionary<string, object>
|
|
{
|
|
["name"] = "selfsigned-bootstrap"
|
|
},
|
|
["spec"] = new Dictionary<string, object>
|
|
{
|
|
["selfSigned"] = new Dictionary<string, object>()
|
|
}
|
|
};
|
|
|
|
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<string, object> certificate = new()
|
|
{
|
|
["apiVersion"] = "cert-manager.io/v1",
|
|
["kind"] = "Certificate",
|
|
["metadata"] = new Dictionary<string, object>
|
|
{
|
|
["name"] = caName,
|
|
["namespace"] = "cert-manager"
|
|
},
|
|
["spec"] = new Dictionary<string, object>
|
|
{
|
|
["isCA"] = true,
|
|
["duration"] = duration,
|
|
["secretName"] = secretName,
|
|
["commonName"] = $"{caName} Root CA",
|
|
["subject"] = new Dictionary<string, object>
|
|
{
|
|
["organizations"] = new List<string> { organization }
|
|
},
|
|
["privateKey"] = new Dictionary<string, object>
|
|
{
|
|
["algorithm"] = "ECDSA",
|
|
["size"] = 256
|
|
},
|
|
["issuerRef"] = new Dictionary<string, object>
|
|
{
|
|
["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<string, object> issuer = new()
|
|
{
|
|
["apiVersion"] = "cert-manager.io/v1",
|
|
["kind"] = "ClusterIssuer",
|
|
["metadata"] = new Dictionary<string, object>
|
|
{
|
|
["name"] = caName
|
|
},
|
|
["spec"] = new Dictionary<string, object>
|
|
{
|
|
["ca"] = new Dictionary<string, object>
|
|
{
|
|
["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<string> domains, bool isExternal, CancellationToken ct)
|
|
{
|
|
Dictionary<string, string> annotations = new()
|
|
{
|
|
["entkube.io/domains"] = string.Join(",", domains)
|
|
};
|
|
|
|
if (isExternal)
|
|
{
|
|
annotations["entkube.io/external-ca"] = "true";
|
|
}
|
|
|
|
Dictionary<string, object> issuer = new()
|
|
{
|
|
["apiVersion"] = "cert-manager.io/v1",
|
|
["kind"] = "ClusterIssuer",
|
|
["metadata"] = new Dictionary<string, object>
|
|
{
|
|
["name"] = caName,
|
|
["annotations"] = annotations
|
|
},
|
|
["spec"] = new Dictionary<string, object>
|
|
{
|
|
["ca"] = new Dictionary<string, object>
|
|
{
|
|
["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<string, byte[]> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private async Task AddCAToTrustBundleWithKey(
|
|
Kubernetes client, string bundleName, string secretName, string certKey,
|
|
string namespaceName, CancellationToken ct)
|
|
{
|
|
try
|
|
{
|
|
JsonElement existing = await client.CustomObjects.GetClusterCustomObjectAsync<JsonElement>(
|
|
"trust.cert-manager.io", "v1alpha1", "bundles", bundleName, ct);
|
|
|
|
List<object> 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<object>(source.GetRawText())!);
|
|
|
|
if (source.TryGetProperty("secret", out JsonElement secretSource) &&
|
|
secretSource.TryGetProperty("name", out JsonElement nameEl) &&
|
|
nameEl.GetString() == secretName)
|
|
{
|
|
alreadyPresent = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!alreadyPresent)
|
|
{
|
|
Dictionary<string, object> 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<string, object>
|
|
{
|
|
["secret"] = secretSpec
|
|
});
|
|
|
|
Dictionary<string, object> patch = new()
|
|
{
|
|
["apiVersion"] = "trust.cert-manager.io/v1alpha1",
|
|
["kind"] = "Bundle",
|
|
["metadata"] = new Dictionary<string, object> { ["name"] = bundleName },
|
|
["spec"] = new Dictionary<string, object> { ["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<string, object> newSecretSpec = new()
|
|
{
|
|
["name"] = secretName,
|
|
["key"] = certKey
|
|
};
|
|
|
|
if (!string.Equals(namespaceName, "cert-manager", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
newSecretSpec["namespace"] = namespaceName;
|
|
}
|
|
|
|
Dictionary<string, object> bundle = new()
|
|
{
|
|
["apiVersion"] = "trust.cert-manager.io/v1alpha1",
|
|
["kind"] = "Bundle",
|
|
["metadata"] = new Dictionary<string, object> { ["name"] = bundleName },
|
|
["spec"] = new Dictionary<string, object>
|
|
{
|
|
["sources"] = new List<object>
|
|
{
|
|
new Dictionary<string, object> { ["useDefaultCAs"] = true },
|
|
new Dictionary<string, object>
|
|
{
|
|
["secret"] = newSecretSpec
|
|
}
|
|
},
|
|
["target"] = new Dictionary<string, object>
|
|
{
|
|
["configMap"] = new Dictionary<string, object>
|
|
{
|
|
["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<JsonElement>(
|
|
"trust.cert-manager.io", "v1alpha1", "bundles", bundleName, ct);
|
|
|
|
if (existing.TryGetProperty("spec", out JsonElement spec) &&
|
|
spec.TryGetProperty("sources", out JsonElement sourcesEl))
|
|
{
|
|
List<object> 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<object>(source.GetRawText())!);
|
|
}
|
|
|
|
Dictionary<string, object> patch = new()
|
|
{
|
|
["apiVersion"] = "trust.cert-manager.io/v1alpha1",
|
|
["kind"] = "Bundle",
|
|
["metadata"] = new Dictionary<string, object> { ["name"] = bundleName },
|
|
["spec"] = new Dictionary<string, object> { ["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<string, object> 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<string, object> 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<string> Domains,
|
|
string? TlsCert = null,
|
|
string? TlsKey = null,
|
|
string? Organization = null,
|
|
int? DurationDays = null,
|
|
string? BundleName = null);
|
|
|
|
public record CaOperationResult(bool Success, string Message, List<string> Actions)
|
|
{
|
|
public static CaOperationResult Ok(string message, List<string>? actions = null) =>
|
|
new(true, message, actions ?? new());
|
|
|
|
public static CaOperationResult Failure(string message, List<string>? actions = null) =>
|
|
new(false, message, actions ?? new());
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public record CertificateInfo(
|
|
string Name,
|
|
string SecretName,
|
|
string Namespace,
|
|
List<string> Domains,
|
|
DateTimeOffset? NotBefore,
|
|
DateTimeOffset? NotAfter,
|
|
string? Organization,
|
|
string? Subject,
|
|
string? IssuerDN,
|
|
string? SerialNumber,
|
|
string? Thumbprint,
|
|
string SecretType,
|
|
bool IsWildcard,
|
|
bool InTrustBundle);
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
internal record TrustBundleSourceInfo(
|
|
TrustSourceType SourceType,
|
|
string Name,
|
|
string Key,
|
|
string? InlineData,
|
|
string Namespace = "cert-manager");
|
|
|
|
/// <summary>
|
|
/// The type of trust bundle source — determines how to read the cert data.
|
|
/// </summary>
|
|
internal enum TrustSourceType { Secret, ConfigMap, InLine }
|
|
|
|
/// <summary>
|
|
/// Holds all extracted X.509 certificate details, used internally to pass
|
|
/// cert information between parsing and model construction.
|
|
/// </summary>
|
|
internal record CertDetails(
|
|
DateTimeOffset NotBefore,
|
|
DateTimeOffset NotAfter,
|
|
string? Organization,
|
|
string? CommonName,
|
|
string Subject,
|
|
string IssuerDN,
|
|
string SerialNumber,
|
|
string Thumbprint,
|
|
List<string> Domains);
|