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;