From af36e6e0bdb6b362f0842bd5a52cacfc61132e7c Mon Sep 17 00:00:00 2001 From: Ian Buse <57817326+ian-buse@users.noreply.github.com> Date: Wed, 15 May 2024 00:31:37 -0700 Subject: [PATCH] feat(core): Add certificates to Operator.Web (#756) This adds a new CertificateGenerator to Operator.Web, and it reworks a handful of the classes in Operator.Web to support using certificates in code. I more or less re-implemented the BouncyCastle solution from the CLI in Operator.Web using the built-in .NET classes, and I went to some length to ensure the generated certificates are virtually identical to those produced by BouncyCastle. --- .../Certificates/CertificatePair.cs | 7 + .../Certificates/ICertificateProvider.cs | 22 ++ .../Certificates/CertificateGenerator.cs | 129 ----------- src/KubeOps.Cli/Certificates/Extensions.cs | 22 -- .../Generators/CertificateGenerator.cs | 18 +- src/KubeOps.Cli/KubeOps.Cli.csproj | 5 +- .../Builder/OperatorBuilderExtensions.cs | 62 ++++- .../Certificates/CertificateExtensions.cs | 58 +++++ .../Certificates/CertificateGenerator.cs | 217 ++++++++++++++++++ .../Certificates/CertificateWebhookService.cs | 30 +++ .../KubeOps.Operator.Web.csproj | 5 +- .../LocalTunnel/DevelopmentTunnel.cs | 40 ++++ .../LocalTunnel/DevelopmentTunnelService.cs | 151 ------------ .../LocalTunnel/TunnelConfig.cs | 3 - .../LocalTunnel/TunnelWebhookService.cs | 30 +++ src/KubeOps.Operator.Web/README.md | 32 +++ .../Webhooks/WebhookConfig.cs | 3 + .../WebhookLoader.cs | 3 +- .../Webhooks/WebhookServiceBase.cs | 144 ++++++++++++ .../Builder/OperatorBuilderExtensions.Test.cs | 42 +++- .../Certificates/CertificateGenerator.Test.cs | 43 ++++ .../IntegrationTestCollection.cs | 1 + 22 files changed, 734 insertions(+), 333 deletions(-) create mode 100644 src/KubeOps.Abstractions/Certificates/CertificatePair.cs create mode 100644 src/KubeOps.Abstractions/Certificates/ICertificateProvider.cs delete mode 100644 src/KubeOps.Cli/Certificates/CertificateGenerator.cs delete mode 100644 src/KubeOps.Cli/Certificates/Extensions.cs create mode 100644 src/KubeOps.Operator.Web/Certificates/CertificateExtensions.cs create mode 100644 src/KubeOps.Operator.Web/Certificates/CertificateGenerator.cs create mode 100644 src/KubeOps.Operator.Web/Certificates/CertificateWebhookService.cs create mode 100644 src/KubeOps.Operator.Web/LocalTunnel/DevelopmentTunnel.cs delete mode 100644 src/KubeOps.Operator.Web/LocalTunnel/DevelopmentTunnelService.cs delete mode 100644 src/KubeOps.Operator.Web/LocalTunnel/TunnelConfig.cs create mode 100644 src/KubeOps.Operator.Web/LocalTunnel/TunnelWebhookService.cs create mode 100644 src/KubeOps.Operator.Web/Webhooks/WebhookConfig.cs rename src/KubeOps.Operator.Web/{LocalTunnel => Webhooks}/WebhookLoader.cs (93%) create mode 100644 src/KubeOps.Operator.Web/Webhooks/WebhookServiceBase.cs create mode 100644 test/KubeOps.Operator.Web.Test/Certificates/CertificateGenerator.Test.cs diff --git a/src/KubeOps.Abstractions/Certificates/CertificatePair.cs b/src/KubeOps.Abstractions/Certificates/CertificatePair.cs new file mode 100644 index 00000000..122fba70 --- /dev/null +++ b/src/KubeOps.Abstractions/Certificates/CertificatePair.cs @@ -0,0 +1,7 @@ +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + +namespace KubeOps.Abstractions.Certificates +{ + public record CertificatePair(X509Certificate2 Certificate, AsymmetricAlgorithm Key); +} diff --git a/src/KubeOps.Abstractions/Certificates/ICertificateProvider.cs b/src/KubeOps.Abstractions/Certificates/ICertificateProvider.cs new file mode 100644 index 00000000..fd9d5cb1 --- /dev/null +++ b/src/KubeOps.Abstractions/Certificates/ICertificateProvider.cs @@ -0,0 +1,22 @@ +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + +namespace KubeOps.Abstractions.Certificates +{ + /// + /// Defines properties for certificate/key pair so a custom certificate/key provider may be implemented. + /// The provider is used by the CertificateWebhookService to provide a caBundle to the webhooks. + /// + public interface ICertificateProvider : IDisposable + { + /// + /// The server certificate and key. + /// + CertificatePair Server { get; } + + /// + /// The root certificate and key. + /// + CertificatePair Root { get; } + } +} diff --git a/src/KubeOps.Cli/Certificates/CertificateGenerator.cs b/src/KubeOps.Cli/Certificates/CertificateGenerator.cs deleted file mode 100644 index 5d4d04c7..00000000 --- a/src/KubeOps.Cli/Certificates/CertificateGenerator.cs +++ /dev/null @@ -1,129 +0,0 @@ -using Org.BouncyCastle.Asn1.X509; -using Org.BouncyCastle.Crypto; -using Org.BouncyCastle.Crypto.Generators; -using Org.BouncyCastle.Crypto.Operators; -using Org.BouncyCastle.Crypto.Prng; -using Org.BouncyCastle.Math; -using Org.BouncyCastle.Security; -using Org.BouncyCastle.Utilities; -using Org.BouncyCastle.X509; -using Org.BouncyCastle.X509.Extension; - -namespace KubeOps.Cli.Certificates; - -internal static class CertificateGenerator -{ - public static (X509Certificate Certificate, AsymmetricCipherKeyPair Key) CreateCaCertificate() - { - var randomGenerator = new CryptoApiRandomGenerator(); - var random = new SecureRandom(randomGenerator); - - // The Certificate Generator - var certificateGenerator = new X509V3CertificateGenerator(); - - // Serial Number - var serialNumber = BigIntegers.CreateRandomInRange(BigInteger.One, BigInteger.ValueOf(long.MaxValue), random); - certificateGenerator.SetSerialNumber(serialNumber); - - // Issuer and Subject Name - var name = new X509Name("CN=Operator Root CA, C=DEV, L=Kubernetes"); - certificateGenerator.SetIssuerDN(name); - certificateGenerator.SetSubjectDN(name); - - // Valid For - var notBefore = DateTime.UtcNow.Date; - var notAfter = notBefore.AddYears(5); - certificateGenerator.SetNotBefore(notBefore); - certificateGenerator.SetNotAfter(notAfter); - - // Cert Extensions - certificateGenerator.AddExtension( - X509Extensions.BasicConstraints, - true, - new BasicConstraints(true)); - certificateGenerator.AddExtension( - X509Extensions.KeyUsage, - true, - new KeyUsage(KeyUsage.KeyCertSign | KeyUsage.CrlSign | KeyUsage.KeyEncipherment)); - - // Subject Public Key - const int keyStrength = 256; - var keyGenerator = new ECKeyPairGenerator("ECDSA"); - keyGenerator.Init(new KeyGenerationParameters(random, keyStrength)); - var key = keyGenerator.GenerateKeyPair(); - - certificateGenerator.SetPublicKey(key.Public); - - var signatureFactory = new Asn1SignatureFactory("SHA512WITHECDSA", key.Private, random); - var certificate = certificateGenerator.Generate(signatureFactory); - - return (certificate, key); - } - - public static (X509Certificate Certificate, AsymmetricCipherKeyPair Key) CreateServerCertificate( - (X509Certificate Certificate, AsymmetricCipherKeyPair Key) ca, string serverName, string serverNamespace) - { - var randomGenerator = new CryptoApiRandomGenerator(); - var random = new SecureRandom(randomGenerator); - - // The Certificate Generator - var certificateGenerator = new X509V3CertificateGenerator(); - - // Serial Number - var serialNumber = BigIntegers.CreateRandomInRange(BigInteger.One, BigInteger.ValueOf(long.MaxValue), random); - certificateGenerator.SetSerialNumber(serialNumber); - - // Issuer and Subject Name - certificateGenerator.SetIssuerDN(ca.Certificate.SubjectDN); - certificateGenerator.SetSubjectDN(new X509Name("CN=Operator Service, C=DEV, L=Kubernetes")); - - // Valid For - var notBefore = DateTime.UtcNow.Date; - var notAfter = notBefore.AddYears(5); - certificateGenerator.SetNotBefore(notBefore); - certificateGenerator.SetNotAfter(notAfter); - - // Cert Extensions - certificateGenerator.AddExtension( - X509Extensions.BasicConstraints, - false, - new BasicConstraints(false)); - certificateGenerator.AddExtension( - X509Extensions.KeyUsage, - true, - new KeyUsage(KeyUsage.NonRepudiation | KeyUsage.KeyEncipherment | KeyUsage.DigitalSignature)); - certificateGenerator.AddExtension( - X509Extensions.ExtendedKeyUsage, - false, - new ExtendedKeyUsage(KeyPurposeID.id_kp_clientAuth, KeyPurposeID.id_kp_serverAuth)); - certificateGenerator.AddExtension( - X509Extensions.SubjectKeyIdentifier, - false, - new SubjectKeyIdentifierStructure(ca.Key.Public)); - certificateGenerator.AddExtension( - X509Extensions.AuthorityKeyIdentifier, - false, - new AuthorityKeyIdentifierStructure(ca.Certificate)); - certificateGenerator.AddExtension( - X509Extensions.SubjectAlternativeName, - false, - new GeneralNames([ - new GeneralName(GeneralName.DnsName, $"{serverName}.{serverNamespace}.svc"), - new GeneralName(GeneralName.DnsName, $"*.{serverNamespace}.svc"), - new GeneralName(GeneralName.DnsName, "*.svc"), - ])); - - // Subject Public Key - const int keyStrength = 256; - var keyGenerator = new ECKeyPairGenerator("ECDSA"); - keyGenerator.Init(new KeyGenerationParameters(random, keyStrength)); - var key = keyGenerator.GenerateKeyPair(); - - certificateGenerator.SetPublicKey(key.Public); - - var signatureFactory = new Asn1SignatureFactory("SHA512WITHECDSA", ca.Key.Private, random); - var certificate = certificateGenerator.Generate(signatureFactory); - - return (certificate, key); - } -} diff --git a/src/KubeOps.Cli/Certificates/Extensions.cs b/src/KubeOps.Cli/Certificates/Extensions.cs deleted file mode 100644 index dfe53b83..00000000 --- a/src/KubeOps.Cli/Certificates/Extensions.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.Text; - -using Org.BouncyCastle.Crypto; -using Org.BouncyCastle.OpenSsl; -using Org.BouncyCastle.X509; - -namespace KubeOps.Cli.Certificates; - -internal static class Extensions -{ - public static string ToPem(this X509Certificate cert) => ObjToPem(cert); - - public static string ToPem(this AsymmetricCipherKeyPair key) => ObjToPem(key); - - private static string ObjToPem(object obj) - { - var sb = new StringBuilder(); - using var writer = new PemWriter(new StringWriter(sb)); - writer.WriteObject(obj); - return sb.ToString(); - } -} diff --git a/src/KubeOps.Cli/Generators/CertificateGenerator.cs b/src/KubeOps.Cli/Generators/CertificateGenerator.cs index 101a5c0e..3eb720e1 100644 --- a/src/KubeOps.Cli/Generators/CertificateGenerator.cs +++ b/src/KubeOps.Cli/Generators/CertificateGenerator.cs @@ -1,5 +1,5 @@ -using KubeOps.Cli.Certificates; using KubeOps.Cli.Output; +using KubeOps.Operator.Web.Certificates; namespace KubeOps.Cli.Generators; @@ -7,17 +7,11 @@ internal class CertificateGenerator(string serverName, string namespaceName) : I { public void Generate(ResultOutput output) { - var (caCert, caKey) = Certificates.CertificateGenerator.CreateCaCertificate(); + using Operator.Web.CertificateGenerator generator = new(serverName, namespaceName); - output.Add("ca.pem", caCert.ToPem(), OutputFormat.Plain); - output.Add("ca-key.pem", caKey.ToPem(), OutputFormat.Plain); - - var (srvCert, srvKey) = Certificates.CertificateGenerator.CreateServerCertificate( - (caCert, caKey), - serverName, - namespaceName); - - output.Add("svc.pem", srvCert.ToPem(), OutputFormat.Plain); - output.Add("svc-key.pem", srvKey.ToPem(), OutputFormat.Plain); + output.Add("ca.pem", generator.Root.Certificate.EncodeToPem(), OutputFormat.Plain); + output.Add("ca-key.pem", generator.Root.Key.EncodeToPem(), OutputFormat.Plain); + output.Add("svc.pem", generator.Server.Certificate.EncodeToPem(), OutputFormat.Plain); + output.Add("svc-key.pem", generator.Server.Key.EncodeToPem(), OutputFormat.Plain); } } diff --git a/src/KubeOps.Cli/KubeOps.Cli.csproj b/src/KubeOps.Cli/KubeOps.Cli.csproj index 9be286d5..6f731eae 100644 --- a/src/KubeOps.Cli/KubeOps.Cli.csproj +++ b/src/KubeOps.Cli/KubeOps.Cli.csproj @@ -1,4 +1,4 @@ - + Exe @@ -18,7 +18,6 @@ - @@ -34,7 +33,7 @@ - + diff --git a/src/KubeOps.Operator.Web/Builder/OperatorBuilderExtensions.cs b/src/KubeOps.Operator.Web/Builder/OperatorBuilderExtensions.cs index 429dd216..53d5a447 100644 --- a/src/KubeOps.Operator.Web/Builder/OperatorBuilderExtensions.cs +++ b/src/KubeOps.Operator.Web/Builder/OperatorBuilderExtensions.cs @@ -2,7 +2,10 @@ using System.Runtime.Versioning; using KubeOps.Abstractions.Builder; +using KubeOps.Abstractions.Certificates; +using KubeOps.Operator.Web.Certificates; using KubeOps.Operator.Web.LocalTunnel; +using KubeOps.Operator.Web.Webhooks; using Microsoft.Extensions.DependencyInjection; @@ -39,16 +42,67 @@ public static class OperatorBuilderExtensions /// /// [RequiresPreviewFeatures( - "Localtunnel is sometimes unstable, use with caution. " + - "This API is in preview and may be removed in future versions if no stable alternative is found.")] + "LocalTunnel is sometimes unstable, use with caution.")] +#pragma warning disable S1133 // Deprecated code should be removed + [Obsolete( + "LocalTunnel features are deprecated and will be removed in a future version. " + + $"Instead, use the {nameof(UseCertificateProvider)} method for development webhooks.")] +#pragma warning restore S1133 // Deprecated code should be removed public static IOperatorBuilder AddDevelopmentTunnel( this IOperatorBuilder builder, ushort port, string hostname = "localhost") { - builder.Services.AddHostedService(); - builder.Services.AddSingleton(new TunnelConfig(hostname, port)); + builder.Services.AddHostedService(); builder.Services.AddSingleton(new WebhookLoader(Assembly.GetEntryAssembly()!)); + builder.Services.AddSingleton(new WebhookConfig(hostname, port)); + builder.Services.AddSingleton(); + + return builder; + } + + /// + /// Adds a hosted service to the system that uses the server certificate from an + /// implementation to configure development webhooks. The webhooks will be configured to use the hostname and port. + /// + /// The operator builder. + /// The port that the webhooks will use to connect to the operator. + /// The hostname, IP, or FQDN of the machine running the operator. + /// The the + /// will use to generate the PEM-encoded server certificate for the webhooks. + /// The builder for chaining. + /// + /// Use the development webhooks. + /// + /// var builder = WebApplication.CreateBuilder(args); + /// string ip = "192.168.1.100"; + /// ushort port = 443; + /// + /// using CertificateGenerator generator = new CertificateGenerator(ip); + /// using X509Certificate2 cert = generator.Server.CopyServerCertWithPrivateKey(); + /// // Configure Kestrel to listen on IPv4, use port 443, and use the server certificate + /// builder.WebHost.ConfigureKestrel(serverOptions => + /// { + /// serverOptions.Listen(System.Net.IPAddress.Any, port, async listenOptions => + /// { + /// listenOptions.UseHttps(cert); + /// }); + /// }); + /// builder.Services + /// .AddKubernetesOperator() + /// // Create the development webhook service using the cert provider + /// .UseCertificateProvider(port, ip, generator) + /// // More code + /// + /// + /// + public static IOperatorBuilder UseCertificateProvider(this IOperatorBuilder builder, ushort port, string hostname, ICertificateProvider certificateProvider) + { + builder.Services.AddHostedService(); + builder.Services.AddSingleton(new WebhookLoader(Assembly.GetEntryAssembly()!)); + builder.Services.AddSingleton(new WebhookConfig(hostname, port)); + builder.Services.AddSingleton(certificateProvider); + return builder; } } diff --git a/src/KubeOps.Operator.Web/Certificates/CertificateExtensions.cs b/src/KubeOps.Operator.Web/Certificates/CertificateExtensions.cs new file mode 100644 index 00000000..734e2d89 --- /dev/null +++ b/src/KubeOps.Operator.Web/Certificates/CertificateExtensions.cs @@ -0,0 +1,58 @@ +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; + +using KubeOps.Abstractions.Certificates; + +namespace KubeOps.Operator.Web.Certificates +{ + public static class CertificateExtensions + { + /// + /// Encodes the certificate in PEM format for use in Kubernetes. + /// + /// The certificate to encode. + /// The byte representation of the PEM-encoded certificate. + public static byte[] EncodeToPemBytes(this X509Certificate2 certificate) => Encoding.UTF8.GetBytes(certificate.EncodeToPem()); + + /// + /// Encodes the certificate in PEM format. + /// + /// The certificate to encode. + /// The string representation of the PEM-encoded certificate. + public static string EncodeToPem(this X509Certificate2 certificate) => new(PemEncoding.Write("CERTIFICATE", certificate.RawData)); + + /// + /// Encodes the key in PEM format. + /// + /// The key to encode. + /// The string representation of the PEM-encoded key. + public static string EncodeToPem(this AsymmetricAlgorithm key) => new(PemEncoding.Write("PRIVATE KEY", key.ExportPkcs8PrivateKey())); + + /// + /// Generates a new server certificate with its private key attached, and sets . + /// For example, this certificate can be used in development environments to configure . + /// + /// The cert/key tuple to attach. + /// An with the private key attached. + /// The not have a CopyWithPrivateKey method, or the + /// method has not been implemented in this extension. + public static X509Certificate2 CopyServerCertWithPrivateKey(this CertificatePair serverPair) + { + const string? password = null; + using X509Certificate2 temp = serverPair.Key switch + { + ECDsa ecdsa => serverPair.Certificate.CopyWithPrivateKey(ecdsa), + RSA rsa => serverPair.Certificate.CopyWithPrivateKey(rsa), + ECDiffieHellman ecdh => serverPair.Certificate.CopyWithPrivateKey(ecdh), + DSA dsa => serverPair.Certificate.CopyWithPrivateKey(dsa), + _ => throw new NotImplementedException($"{serverPair.Key} is not implemented for {nameof(CopyServerCertWithPrivateKey)}"), + }; + + return new X509Certificate2( + temp.Export(X509ContentType.Pfx, password), + password, + X509KeyStorageFlags.Exportable | X509KeyStorageFlags.PersistKeySet); + } + } +} diff --git a/src/KubeOps.Operator.Web/Certificates/CertificateGenerator.cs b/src/KubeOps.Operator.Web/Certificates/CertificateGenerator.cs new file mode 100644 index 00000000..28e272d8 --- /dev/null +++ b/src/KubeOps.Operator.Web/Certificates/CertificateGenerator.cs @@ -0,0 +1,217 @@ +using System.Formats.Asn1; +using System.Net; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + +using KubeOps.Abstractions.Certificates; + +namespace KubeOps.Operator.Web +{ + /// + /// Generates a self-signed CA certificate and server certificate using ECDsa that can be used for operator webhooks. + /// + public class CertificateGenerator : ICertificateProvider + { + private readonly string _serverName; + private readonly string? _serverNamespace; + private readonly DateTime _startDate; + private readonly DateTime _endDate; + private readonly Lazy _root; + private readonly Lazy _server; + + /// + /// Initializes a new instance of the class. + /// + /// The hostname, IP, or FQDN of the machine running the operator. + public CertificateGenerator(string serverName) + { + _serverName = serverName; + _serverNamespace = null; + _startDate = DateTime.UtcNow.Date; + _endDate = _startDate.AddYears(5); + _root = new(GenerateRootCertificate); + _server = new(GenerateServerCertificate); + } + + /// + /// + /// + /// + /// The Kubernetes namespace the server will run in. + public CertificateGenerator(string serverName, string serverNamespace) + : this(serverName) + { + _serverNamespace = serverNamespace; + } + + public CertificatePair Root => _root.Value; + + public CertificatePair Server => _server.Value; + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (_root.IsValueCreated) + { + _root.Value.Certificate.Dispose(); + _root.Value.Key.Dispose(); + } + + if (_server.IsValueCreated) + { + _server.Value.Certificate.Dispose(); + _server.Value.Key.Dispose(); + } + } + + private CertificatePair GenerateRootCertificate() + { + ECDsa? key = null; + X509Certificate2? cert = null; + try + { + // Create an ECDsa key and a certificate request + key = ECDsa.Create(ECCurve.NamedCurves.nistP256); + var request = new CertificateRequest( + "CN=Operator Root CA, C=DEV, L=Kubernetes", + key, + HashAlgorithmName.SHA512); + + // Specify certain details of how the certificate can be used + request.CertificateExtensions.Add( + new X509BasicConstraintsExtension(true, false, 0, true)); + request.CertificateExtensions.Add( + new X509KeyUsageExtension( + X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign | X509KeyUsageFlags.KeyEncipherment, + true)); + + // Create the self-signed cert + cert = request.CreateSelfSigned(_startDate, _endDate); + return new(cert, key); + } + catch + { + key?.Dispose(); + cert?.Dispose(); + throw; + } + } + + private CertificatePair GenerateServerCertificate() + { + ECDsa? key = null; + X509Certificate2? cert = null; + try + { + key = ECDsa.Create(ECCurve.NamedCurves.nistP256); + var request = new CertificateRequest( + "CN=Operator Service, C=DEV, L=Kubernetes", + key, + HashAlgorithmName.SHA512); + + request.CertificateExtensions.Add( + new X509BasicConstraintsExtension(false, false, 0, false)); + request.CertificateExtensions.Add( + new X509KeyUsageExtension( + X509KeyUsageFlags.NonRepudiation | X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, + true)); + + // Key purpose: clientAuth and serverAuth + request.CertificateExtensions.Add( + new X509EnhancedKeyUsageExtension( + [new Oid("1.3.6.1.5.5.7.3.1"), new Oid("1.3.6.1.5.5.7.3.2")], + false)); + request.CertificateExtensions.Add( + new X509SubjectKeyIdentifierExtension( + Root.Certificate.PublicKey, + false)); + request.CertificateExtensions.Add( + new CustomX509AuthorityKeyIdentifierExtension( + Root.Certificate, + false)); + + // If a server namespace is provided, it's safe to assume that the operator will be running in the Kubernetes cluster + // Otherwise, just try to parse whatever is there (i.e. for development) + var sanBuilder = new SubjectAlternativeNameBuilder(); + if (_serverNamespace != null) + { + sanBuilder.AddDnsName($"{_serverName}.{_serverNamespace}.svc"); + sanBuilder.AddDnsName($"*.{_serverNamespace}.svc"); + sanBuilder.AddDnsName("*.svc"); + } + else if (IPAddress.TryParse(_serverName, out IPAddress? ipAddress)) + { + sanBuilder.AddIpAddress(ipAddress); + } + else + { + sanBuilder.AddDnsName(_serverName); + } + + request.CertificateExtensions.Add(sanBuilder.Build()); + + // Generate using the root certificate + X509SignatureGenerator generator = X509SignatureGenerator + .CreateForECDsa(Root.Certificate.GetECDsaPrivateKey()!); + + // Generate + cert = request.Create( + Root.Certificate.SubjectName, + generator, + _startDate, + _endDate, + Guid.NewGuid().ToByteArray()); + + return new(cert, key); + } + catch + { + key?.Dispose(); + cert?.Dispose(); + throw; + } + } + + /// + /// Custom class for implementing a slim version of the .NET7/8 X509AuthorityKeyIdentifierExtension class. + /// + private sealed class CustomX509AuthorityKeyIdentifierExtension(X509Certificate2 certificate, bool critical) + : X509Extension(new Oid("2.5.29.35"), CreateFromCertificate(certificate), critical) + { + // https://source.dot.net/#System.Security.Cryptography/System/Security/Cryptography/X509Certificates/X509AuthorityKeyIdentifierExtension.cs + // This .NET code is shipped with .NET 7/8, but is not in .NET 6, which is still supported by operator + // The method below uses portions of the static methods CreateFromCertificate() and Create() + private static byte[] CreateFromCertificate(X509Certificate2 certificate) + { + X509SubjectKeyIdentifierExtension skid = + (X509SubjectKeyIdentifierExtension?)certificate.Extensions["2.5.29.14"] ?? + new X509SubjectKeyIdentifierExtension(certificate.PublicKey, false); + + byte[] skidBytes = Convert.FromHexString(skid.SubjectKeyIdentifier!); + + AsnWriter writer = new(AsnEncodingRules.DER); + + using (writer.PushSequence()) + { + writer.WriteOctetString(skidBytes, new Asn1Tag(TagClass.ContextSpecific, 0)); + + using (writer.PushSequence(new Asn1Tag(TagClass.ContextSpecific, 1))) + using (writer.PushSequence(new Asn1Tag(TagClass.ContextSpecific, 4))) + { + writer.WriteEncodedValue(certificate.IssuerName.RawData); + } + + byte[] serialBytes = Convert.FromHexString(certificate.SerialNumber); + writer.WriteInteger(serialBytes, new Asn1Tag(TagClass.ContextSpecific, 2)); + } + + return writer.Encode(); + } + } + } +} diff --git a/src/KubeOps.Operator.Web/Certificates/CertificateWebhookService.cs b/src/KubeOps.Operator.Web/Certificates/CertificateWebhookService.cs new file mode 100644 index 00000000..e6acf069 --- /dev/null +++ b/src/KubeOps.Operator.Web/Certificates/CertificateWebhookService.cs @@ -0,0 +1,30 @@ +using KubeOps.Abstractions.Certificates; +using KubeOps.KubernetesClient; +using KubeOps.Operator.Web.Webhooks; + +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace KubeOps.Operator.Web.Certificates +{ + internal class CertificateWebhookService(ILogger logger, IKubernetesClient client, WebhookLoader loader, WebhookConfig config, ICertificateProvider provider) + : WebhookServiceBase(client, loader, config), IHostedService + { + private readonly ILogger _logger = logger; + private readonly ICertificateProvider _provider = provider; + + public async Task StartAsync(CancellationToken cancellationToken) + { + CaBundle = _provider.Server.Certificate.EncodeToPemBytes(); + + _logger.LogDebug("Registering webhooks"); + await RegisterAll(); + } + + public Task StopAsync(CancellationToken cancellationToken) + { + _provider.Dispose(); + return Task.CompletedTask; + } + } +} diff --git a/src/KubeOps.Operator.Web/KubeOps.Operator.Web.csproj b/src/KubeOps.Operator.Web/KubeOps.Operator.Web.csproj index 2a70b454..018ee0cc 100644 --- a/src/KubeOps.Operator.Web/KubeOps.Operator.Web.csproj +++ b/src/KubeOps.Operator.Web/KubeOps.Operator.Web.csproj @@ -17,11 +17,12 @@ - + + - + diff --git a/src/KubeOps.Operator.Web/LocalTunnel/DevelopmentTunnel.cs b/src/KubeOps.Operator.Web/LocalTunnel/DevelopmentTunnel.cs new file mode 100644 index 00000000..3b43aea4 --- /dev/null +++ b/src/KubeOps.Operator.Web/LocalTunnel/DevelopmentTunnel.cs @@ -0,0 +1,40 @@ +using KubeOps.Operator.Web.Webhooks; + +using Localtunnel; +using Localtunnel.Endpoints.Http; +using Localtunnel.Handlers.Kestrel; +using Localtunnel.Processors; +using Localtunnel.Tunnels; + +using Microsoft.Extensions.Logging; + +namespace KubeOps.Operator.Web.LocalTunnel; + +internal class DevelopmentTunnel(ILoggerFactory loggerFactory, WebhookConfig config) : IDisposable +{ + private readonly LocaltunnelClient _tunnelClient = new(loggerFactory); + private Tunnel? _tunnel; + + public async Task StartAsync(CancellationToken cancellationToken) + { + _tunnel = await _tunnelClient.OpenAsync( + new KestrelTunnelConnectionHandler( + new HttpRequestProcessingPipelineBuilder() + .Append(new HttpHostHeaderRewritingRequestProcessor(config.Hostname)).Build(), + new HttpTunnelEndpointFactory(config.Hostname, config.Port)), + cancellationToken: cancellationToken); + await _tunnel.StartAsync(cancellationToken: cancellationToken); + return _tunnel.Information.Url; + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + _tunnel?.Dispose(); + } +} diff --git a/src/KubeOps.Operator.Web/LocalTunnel/DevelopmentTunnelService.cs b/src/KubeOps.Operator.Web/LocalTunnel/DevelopmentTunnelService.cs deleted file mode 100644 index 4441952a..00000000 --- a/src/KubeOps.Operator.Web/LocalTunnel/DevelopmentTunnelService.cs +++ /dev/null @@ -1,151 +0,0 @@ -using k8s; -using k8s.Models; - -using KubeOps.KubernetesClient; -using KubeOps.Transpiler; - -using Localtunnel; -using Localtunnel.Endpoints.Http; -using Localtunnel.Handlers.Kestrel; -using Localtunnel.Processors; -using Localtunnel.Tunnels; - -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; - -namespace KubeOps.Operator.Web.LocalTunnel; - -internal class DevelopmentTunnelService(ILoggerFactory loggerFactory, IKubernetesClient client, TunnelConfig config, WebhookLoader loader) - : IHostedService -{ - private readonly LocaltunnelClient _tunnelClient = new(loggerFactory); - private Tunnel? _tunnel; - - public async Task StartAsync(CancellationToken cancellationToken) - { - _tunnel = await _tunnelClient.OpenAsync( - new KestrelTunnelConnectionHandler( - new HttpRequestProcessingPipelineBuilder() - .Append(new HttpHostHeaderRewritingRequestProcessor(config.Hostname)).Build(), - new HttpTunnelEndpointFactory(config.Hostname, config.Port)), - cancellationToken: cancellationToken); - await _tunnel.StartAsync(cancellationToken: cancellationToken); - await RegisterValidators(_tunnel.Information.Url); - await RegisterMutators(_tunnel.Information.Url); - await RegisterConverters(_tunnel.Information.Url); - } - - public Task StopAsync(CancellationToken cancellationToken) - { - _tunnel?.Dispose(); - return Task.CompletedTask; - } - - private async Task RegisterValidators(Uri uri) - { - var validationWebhooks = loader - .ValidationWebhooks - .Select(t => (HookTypeName: t.BaseType!.GenericTypeArguments[0].Name.ToLowerInvariant(), - Entities.ToEntityMetadata(t.BaseType!.GenericTypeArguments[0]).Metadata)) - .Select(hook => new V1ValidatingWebhook - { - Name = $"validate.{hook.Metadata.SingularName}.{hook.Metadata.Group}.{hook.Metadata.Version}", - MatchPolicy = "Exact", - AdmissionReviewVersions = new[] { "v1" }, - SideEffects = "None", - Rules = new[] - { - new V1RuleWithOperations - { - Operations = new[] { "*" }, - Resources = new[] { hook.Metadata.PluralName }, - ApiGroups = new[] { hook.Metadata.Group }, - ApiVersions = new[] { hook.Metadata.Version }, - }, - }, - ClientConfig = new Admissionregistrationv1WebhookClientConfig - { - Url = $"{uri}validate/{hook.HookTypeName}", - }, - }); - - var validatorConfig = new V1ValidatingWebhookConfiguration( - metadata: new V1ObjectMeta(name: "dev-validators"), - webhooks: validationWebhooks.ToList()).Initialize(); - - if (validatorConfig.Webhooks.Any()) - { - await client.SaveAsync(validatorConfig); - } - } - - private async Task RegisterMutators(Uri uri) - { - var mutationWebhooks = loader - .MutationWebhooks - .Select(t => (HookTypeName: t.BaseType!.GenericTypeArguments[0].Name.ToLowerInvariant(), - Entities.ToEntityMetadata(t.BaseType!.GenericTypeArguments[0]).Metadata)) - .Select(hook => new V1MutatingWebhook - { - Name = $"mutate.{hook.Metadata.SingularName}.{hook.Metadata.Group}.{hook.Metadata.Version}", - MatchPolicy = "Exact", - AdmissionReviewVersions = new[] { "v1" }, - SideEffects = "None", - Rules = new[] - { - new V1RuleWithOperations - { - Operations = new[] { "*" }, - Resources = new[] { hook.Metadata.PluralName }, - ApiGroups = new[] { hook.Metadata.Group }, - ApiVersions = new[] { hook.Metadata.Version }, - }, - }, - ClientConfig = new Admissionregistrationv1WebhookClientConfig - { - Url = $"{uri}mutate/{hook.HookTypeName}", - }, - }); - - var mutatorConfig = new V1MutatingWebhookConfiguration( - metadata: new V1ObjectMeta(name: "dev-mutators"), - webhooks: mutationWebhooks.ToList()).Initialize(); - - if (mutatorConfig.Webhooks.Any()) - { - await client.SaveAsync(mutatorConfig); - } - } - - private async Task RegisterConverters(Uri uri) - { - var conversionWebhooks = loader.ConversionWebhooks.ToList(); - if (conversionWebhooks.Count == 0) - { - return; - } - - foreach (var wh in conversionWebhooks) - { - var metadata = Entities.ToEntityMetadata(wh.BaseType!.GenericTypeArguments[0]).Metadata; - var crdName = $"{metadata.PluralName}.{metadata.Group}"; - - if (await client.GetAsync(crdName) is not { } crd) - { - continue; - } - - var whUrl = $"{uri}convert/{metadata.Group}/{metadata.PluralName}"; - crd.Spec.Conversion = new V1CustomResourceConversion("Webhook") - { - Webhook = new V1WebhookConversion - { - ConversionReviewVersions = new[] { "v1" }, - ClientConfig = new Apiextensionsv1WebhookClientConfig { Url = whUrl }, - }, - }; - - await client.UpdateAsync(crd); - } - } -} diff --git a/src/KubeOps.Operator.Web/LocalTunnel/TunnelConfig.cs b/src/KubeOps.Operator.Web/LocalTunnel/TunnelConfig.cs deleted file mode 100644 index 5d085589..00000000 --- a/src/KubeOps.Operator.Web/LocalTunnel/TunnelConfig.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace KubeOps.Operator.Web.LocalTunnel; - -internal record TunnelConfig(string Hostname, ushort Port); diff --git a/src/KubeOps.Operator.Web/LocalTunnel/TunnelWebhookService.cs b/src/KubeOps.Operator.Web/LocalTunnel/TunnelWebhookService.cs new file mode 100644 index 00000000..b5a7ebb3 --- /dev/null +++ b/src/KubeOps.Operator.Web/LocalTunnel/TunnelWebhookService.cs @@ -0,0 +1,30 @@ +using KubeOps.KubernetesClient; +using KubeOps.Operator.Web.Certificates; +using KubeOps.Operator.Web.Webhooks; + +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace KubeOps.Operator.Web.LocalTunnel +{ + internal class TunnelWebhookService(ILogger logger, IKubernetesClient client, WebhookLoader loader, WebhookConfig config, DevelopmentTunnel developmentTunnel) + : WebhookServiceBase(client, loader, config), IHostedService + { + private readonly ILogger _logger = logger; + private readonly DevelopmentTunnel _developmentTunnel = developmentTunnel; + + public async Task StartAsync(CancellationToken cancellationToken) + { + Uri = await _developmentTunnel.StartAsync(cancellationToken); + + _logger.LogDebug("Registering webhooks"); + await RegisterAll(); + } + + public Task StopAsync(CancellationToken cancellationToken) + { + _developmentTunnel.Dispose(); + return Task.CompletedTask; + } + } +} diff --git a/src/KubeOps.Operator.Web/README.md b/src/KubeOps.Operator.Web/README.md index 8d5fbee5..56b1a984 100644 --- a/src/KubeOps.Operator.Web/README.md +++ b/src/KubeOps.Operator.Web/README.md @@ -238,3 +238,35 @@ a service, and the webhook registrations required for you. > The generated certificate has a validity of 5 years. After that time, > the certificate needs to be renewed. For now, there is no automatic > renewal process. + +## Webhook Development + +The Operator Web package can be configured to generate self-signed certificates on startup, +and create/update your webhooks in the Kubernetes cluster to point to your development +machine. To use this feature, use the `CertificateGenerator` class and `UseCertificateProvider()` +operator builder extension method. An example of what this might look like in Main: + +```csharp +var builder = WebApplication.CreateBuilder(args); +string ip = "192.168.1.100"; +ushort port = 443; + +using CertificateGenerator generator = new CertificateGenerator(ip); +using X509Certificate2 cert = generator.Server.CopyServerCertWithPrivateKey(); +// Configure Kestrel to listen on IPv4, use port 443, and use the server certificate +builder.WebHost.ConfigureKestrel(serverOptions => +{ + serverOptions.Listen(System.Net.IPAddress.Any, port, listenOptions => + { + listenOptions.UseHttps(cert); + }); +}); + builder.Services + .AddKubernetesOperator() + // Create the development webhook service using the cert provider + .UseCertificateProvider(port, ip, generator) + // More code for generation, controllers, etc. +``` + +The `UseCertificateProvider` method takes an `ICertificateProvider` interface, so it can be used +to implement your own certificate generator/loader for development if necessary. diff --git a/src/KubeOps.Operator.Web/Webhooks/WebhookConfig.cs b/src/KubeOps.Operator.Web/Webhooks/WebhookConfig.cs new file mode 100644 index 00000000..4ed72ab8 --- /dev/null +++ b/src/KubeOps.Operator.Web/Webhooks/WebhookConfig.cs @@ -0,0 +1,3 @@ +namespace KubeOps.Operator.Web.Webhooks; + +internal record WebhookConfig(string Hostname, ushort Port); diff --git a/src/KubeOps.Operator.Web/LocalTunnel/WebhookLoader.cs b/src/KubeOps.Operator.Web/Webhooks/WebhookLoader.cs similarity index 93% rename from src/KubeOps.Operator.Web/LocalTunnel/WebhookLoader.cs rename to src/KubeOps.Operator.Web/Webhooks/WebhookLoader.cs index 2390c5ab..38d7713e 100644 --- a/src/KubeOps.Operator.Web/LocalTunnel/WebhookLoader.cs +++ b/src/KubeOps.Operator.Web/Webhooks/WebhookLoader.cs @@ -1,11 +1,10 @@ using System.Reflection; -using System.Runtime.Versioning; using KubeOps.Operator.Web.Webhooks.Admission.Mutation; using KubeOps.Operator.Web.Webhooks.Admission.Validation; using KubeOps.Operator.Web.Webhooks.Conversion; -namespace KubeOps.Operator.Web.LocalTunnel; +namespace KubeOps.Operator.Web.Webhooks; internal record WebhookLoader(Assembly Entry) { diff --git a/src/KubeOps.Operator.Web/Webhooks/WebhookServiceBase.cs b/src/KubeOps.Operator.Web/Webhooks/WebhookServiceBase.cs new file mode 100644 index 00000000..e6d7b793 --- /dev/null +++ b/src/KubeOps.Operator.Web/Webhooks/WebhookServiceBase.cs @@ -0,0 +1,144 @@ +using k8s; +using k8s.Models; + +using KubeOps.KubernetesClient; +using KubeOps.Transpiler; + +namespace KubeOps.Operator.Web.Webhooks +{ + internal abstract class WebhookServiceBase(IKubernetesClient client, WebhookLoader loader, WebhookConfig config) + { + /// + /// The URI the webhooks will use to connect to the operator. + /// + private protected virtual Uri Uri { get; set; } = new($"https://{config.Hostname}:{config.Port}"); + + private protected IKubernetesClient Client { get; } = client; + + /// + /// The PEM-encoded CA bundle for validating the webhook's certificate. + /// + private protected byte[]? CaBundle { get; set; } + + internal async Task RegisterAll() + { + await RegisterValidators(); + await RegisterMutators(); + await RegisterConverters(); + } + + internal async Task RegisterConverters() + { + var conversionWebhooks = loader.ConversionWebhooks.ToList(); + if (conversionWebhooks.Count == 0) + { + return; + } + + foreach (var wh in conversionWebhooks) + { + var metadata = Entities.ToEntityMetadata(wh.BaseType!.GenericTypeArguments[0]).Metadata; + var crdName = $"{metadata.PluralName}.{metadata.Group}"; + + if (await Client.GetAsync(crdName) is not { } crd) + { + continue; + } + + var whUrl = $"{Uri}convert/{metadata.Group}/{metadata.PluralName}"; + crd.Spec.Conversion = new V1CustomResourceConversion("Webhook") + { + Webhook = new V1WebhookConversion + { + ConversionReviewVersions = new[] { "v1" }, + ClientConfig = new Apiextensionsv1WebhookClientConfig + { + Url = whUrl, + CaBundle = CaBundle, + }, + }, + }; + + await Client.UpdateAsync(crd); + } + } + + internal async Task RegisterMutators() + { + var mutationWebhooks = loader + .MutationWebhooks + .Select(t => (HookTypeName: t.BaseType!.GenericTypeArguments[0].Name.ToLowerInvariant(), + Entities.ToEntityMetadata(t.BaseType!.GenericTypeArguments[0]).Metadata)) + .Select(hook => new V1MutatingWebhook + { + Name = $"mutate.{hook.Metadata.SingularName}.{hook.Metadata.Group}.{hook.Metadata.Version}", + MatchPolicy = "Exact", + AdmissionReviewVersions = new[] { "v1" }, + SideEffects = "None", + Rules = new[] + { + new V1RuleWithOperations + { + Operations = new[] { "*" }, + Resources = new[] { hook.Metadata.PluralName }, + ApiGroups = new[] { hook.Metadata.Group }, + ApiVersions = new[] { hook.Metadata.Version }, + }, + }, + ClientConfig = new Admissionregistrationv1WebhookClientConfig + { + Url = $"{Uri}mutate/{hook.HookTypeName}", + CaBundle = CaBundle, + }, + }); + + var mutatorConfig = new V1MutatingWebhookConfiguration( + metadata: new V1ObjectMeta(name: "dev-mutators"), + webhooks: mutationWebhooks.ToList()).Initialize(); + + if (mutatorConfig.Webhooks.Any()) + { + await Client.SaveAsync(mutatorConfig); + } + } + + internal async Task RegisterValidators() + { + var validationWebhooks = loader + .ValidationWebhooks + .Select(t => (HookTypeName: t.BaseType!.GenericTypeArguments[0].Name.ToLowerInvariant(), + Entities.ToEntityMetadata(t.BaseType!.GenericTypeArguments[0]).Metadata)) + .Select(hook => new V1ValidatingWebhook + { + Name = $"validate.{hook.Metadata.SingularName}.{hook.Metadata.Group}.{hook.Metadata.Version}", + MatchPolicy = "Exact", + AdmissionReviewVersions = new[] { "v1" }, + SideEffects = "None", + Rules = new[] + { + new V1RuleWithOperations + { + Operations = new[] { "*" }, + Resources = new[] { hook.Metadata.PluralName }, + ApiGroups = new[] { hook.Metadata.Group }, + ApiVersions = new[] { hook.Metadata.Version }, + }, + }, + ClientConfig = new Admissionregistrationv1WebhookClientConfig + { + Url = $"{Uri}validate/{hook.HookTypeName}", + CaBundle = CaBundle, + }, + }); + + var validatorConfig = new V1ValidatingWebhookConfiguration( + metadata: new V1ObjectMeta(name: "dev-validators"), + webhooks: validationWebhooks.ToList()).Initialize(); + + if (validatorConfig.Webhooks.Any()) + { + await Client.SaveAsync(validatorConfig); + } + } + } +} diff --git a/test/KubeOps.Operator.Web.Test/Builder/OperatorBuilderExtensions.Test.cs b/test/KubeOps.Operator.Web.Test/Builder/OperatorBuilderExtensions.Test.cs index 8102b4dc..a72dc3d7 100644 --- a/test/KubeOps.Operator.Web.Test/Builder/OperatorBuilderExtensions.Test.cs +++ b/test/KubeOps.Operator.Web.Test/Builder/OperatorBuilderExtensions.Test.cs @@ -1,18 +1,24 @@ -using FluentAssertions; +using System.Xml.Linq; + +using FluentAssertions; using KubeOps.Abstractions.Builder; +using KubeOps.Abstractions.Certificates; using KubeOps.Operator.Builder; using KubeOps.Operator.Web.Builder; +using KubeOps.Operator.Web.Certificates; using KubeOps.Operator.Web.LocalTunnel; +using KubeOps.Operator.Web.Webhooks; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; namespace KubeOps.Operator.Web.Test.Builder; -public class OperatorBuilderExtensionsTest +public class OperatorBuilderExtensionsTest : IDisposable { private readonly IOperatorBuilder _builder = new OperatorBuilder(new ServiceCollection(), new()); + private readonly CertificateGenerator _certProvider = new(Environment.MachineName); [Fact] public void Should_Add_Development_Tunnel() @@ -21,17 +27,43 @@ public void Should_Add_Development_Tunnel() _builder.Services.Should().Contain(s => s.ServiceType == typeof(IHostedService) && - s.ImplementationType == typeof(DevelopmentTunnelService) && + s.ImplementationType == typeof(TunnelWebhookService) && s.Lifetime == ServiceLifetime.Singleton); } [Fact] - public void Should_Add_TunnelConfig() + public void Should_Add_WebhookConfig() { _builder.AddDevelopmentTunnel(1337, "my-host"); + _builder.Services.Should().Contain(s => + s.ServiceType == typeof(WebhookConfig) && + s.Lifetime == ServiceLifetime.Singleton); + } + + [Fact] + public void Should_Add_Webhook_Service() + { + _builder.UseCertificateProvider(12345, Environment.MachineName, _certProvider); + + _builder.Services.Should().Contain(s => + s.ServiceType == typeof(IHostedService) && + s.ImplementationType == typeof(CertificateWebhookService) && + s.Lifetime == ServiceLifetime.Singleton); + } + + [Fact] + public void Should_Add_Certificate_Provider() + { + _builder.UseCertificateProvider(54321, Environment.MachineName, _certProvider); _builder.Services.Should().Contain(s => - s.ServiceType == typeof(TunnelConfig) && + s.ServiceType == typeof(ICertificateProvider) && s.Lifetime == ServiceLifetime.Singleton); } + + public void Dispose() + { + _certProvider.Dispose(); + GC.SuppressFinalize(this); + } } diff --git a/test/KubeOps.Operator.Web.Test/Certificates/CertificateGenerator.Test.cs b/test/KubeOps.Operator.Web.Test/Certificates/CertificateGenerator.Test.cs new file mode 100644 index 00000000..6d3cb78b --- /dev/null +++ b/test/KubeOps.Operator.Web.Test/Certificates/CertificateGenerator.Test.cs @@ -0,0 +1,43 @@ +using System.Security.Cryptography.X509Certificates; + +using FluentAssertions; + +namespace KubeOps.Operator.Web.Test.Certificates +{ + public class CertificateGeneratorTest : IDisposable + { + private readonly CertificateGenerator _certificateGenerator = new(Environment.MachineName); + + [Fact] + public void Root_Should_Be_Valid() + { + var (certificate, key) = _certificateGenerator.Root; + + certificate.Should().NotBeNull(); + DateTime.Parse(certificate.GetEffectiveDateString()).Should().BeOnOrBefore(DateTime.UtcNow); + certificate.Extensions.Any(e => e is X509BasicConstraintsExtension basic && basic.CertificateAuthority).Should().BeTrue(); + certificate.HasPrivateKey.Should().BeTrue(); + + key.Should().NotBeNull(); + } + + [Fact] + public void Server_Should_Be_Valid() + { + var (certificate, key) = _certificateGenerator.Server; + + certificate.Should().NotBeNull(); + DateTime.Parse(certificate.GetEffectiveDateString()).Should().BeOnOrBefore(DateTime.UtcNow); + certificate.Extensions.Any(e => e is X509BasicConstraintsExtension basic && basic.CertificateAuthority).Should().BeFalse(); + certificate.HasPrivateKey.Should().BeFalse(); + + key.Should().NotBeNull(); + } + + public void Dispose() + { + _certificateGenerator.Dispose(); + GC.SuppressFinalize(this); + } + } +} diff --git a/test/KubeOps.Operator.Web.Test/IntegrationTestCollection.cs b/test/KubeOps.Operator.Web.Test/IntegrationTestCollection.cs index 2184379e..ff1ba89d 100644 --- a/test/KubeOps.Operator.Web.Test/IntegrationTestCollection.cs +++ b/test/KubeOps.Operator.Web.Test/IntegrationTestCollection.cs @@ -7,6 +7,7 @@ using KubeOps.Operator.Web.Builder; using KubeOps.Operator.Web.LocalTunnel; using KubeOps.Operator.Web.Test.TestApp; +using KubeOps.Operator.Web.Webhooks; using KubeOps.Transpiler; using Microsoft.AspNetCore.Builder;