From 30befc50522a478743c1d5cdc8224ddea90ae729 Mon Sep 17 00:00:00 2001 From: "ian.buse" Date: Thu, 2 May 2024 13:12:18 -0700 Subject: [PATCH 1/8] feat(web): Add local development webhooks --- .../Builder/OperatorBuilderExtensions.cs | 53 +++- .../Certificates/CertificateExtensions.cs | 57 +++++ .../Certificates/CertificateGenerator.cs | 239 ++++++++++++++++++ .../Certificates/CertificateWebhookService.cs | 29 +++ .../Certificates/ICertificateProvider.cs | 22 ++ .../LocalTunnel/DevelopmentTunnel.cs | 40 +++ .../LocalTunnel/DevelopmentTunnelService.cs | 151 ----------- .../LocalTunnel/TunnelConfig.cs | 3 - .../LocalTunnel/TunnelWebhookService.cs | 30 +++ .../Webhooks/WebhookConfig.cs | 3 + .../WebhookLoader.cs | 3 +- .../Webhooks/WebhookServiceBase.cs | 144 +++++++++++ .../Builder/OperatorBuilderExtensions.Test.cs | 5 +- .../IntegrationTestCollection.cs | 1 + 14 files changed, 620 insertions(+), 160 deletions(-) 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/Certificates/ICertificateProvider.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 diff --git a/src/KubeOps.Operator.Web/Builder/OperatorBuilderExtensions.cs b/src/KubeOps.Operator.Web/Builder/OperatorBuilderExtensions.cs index 429dd216..ceb625e8 100644 --- a/src/KubeOps.Operator.Web/Builder/OperatorBuilderExtensions.cs +++ b/src/KubeOps.Operator.Web/Builder/OperatorBuilderExtensions.cs @@ -2,7 +2,9 @@ using System.Runtime.Versioning; using KubeOps.Abstractions.Builder; +using KubeOps.Operator.Web.Certificates; using KubeOps.Operator.Web.LocalTunnel; +using KubeOps.Operator.Web.Webhooks; using Microsoft.Extensions.DependencyInjection; @@ -46,9 +48,56 @@ public static IOperatorBuilder AddDevelopmentTunnel( 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 without tunnels. 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..79fe9123 --- /dev/null +++ b/src/KubeOps.Operator.Web/Certificates/CertificateExtensions.cs @@ -0,0 +1,57 @@ +using System.Runtime.CompilerServices; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; + +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.ASCII.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 (X509Certificate2 Certificate, AsymmetricAlgorithm Key) server) + { + const string? password = null; + using X509Certificate2 temp = server.Key switch + { + ECDsa ecdsa => server.Certificate.CopyWithPrivateKey(ecdsa), + RSA rsa => server.Certificate.CopyWithPrivateKey(rsa), + ECDiffieHellman ecdh => server.Certificate.CopyWithPrivateKey(ecdh), + DSA dsa => server.Certificate.CopyWithPrivateKey(dsa), + _ => throw new NotImplementedException($"{server.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..babcf713 --- /dev/null +++ b/src/KubeOps.Operator.Web/Certificates/CertificateGenerator.cs @@ -0,0 +1,239 @@ +using System.Formats.Asn1; +using System.Net; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + +using KubeOps.Operator.Web.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 (X509Certificate2 Certificate, AsymmetricAlgorithm Key) _root; + private (X509Certificate2 Certificate, AsymmetricAlgorithm Key) _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); + } + + /// + /// + /// + /// + /// The Kubernetes namespace the server will run in. + public CertificateGenerator(string serverName, string serverNamespace) + : this(serverName) + { + _serverNamespace = serverNamespace; + } + + public (X509Certificate2 Certificate, AsymmetricAlgorithm Key) Server + { + get + { + if (_server == default) + { + _server = GenerateServerCertificate(); + } + + return _server; + } + } + + public (X509Certificate2 Certificate, AsymmetricAlgorithm Key) Root + { + get + { + if (_root == default) + { + _root = GenerateRootCertificate(); + } + + return _root; + } + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + // These tuple values are not supposed to be null. However, if one of the methods throws, + // there is a chance one or both (especially the key) could be null + if (_root != default) + { + _root.Certificate?.Dispose(); + _root.Key?.Dispose(); + } + + if (_server != default) + { + _server.Certificate?.Dispose(); + _server.Key?.Dispose(); + } + } + + private (X509Certificate2 Certificate, AsymmetricAlgorithm Key) 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 (cert, key); + } + catch + { + key?.Dispose(); + cert?.Dispose(); + throw; + } + } + + private (X509Certificate2 Certificate, AsymmetricAlgorithm Key) 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")], + true)); + 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 (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..d6c16d80 --- /dev/null +++ b/src/KubeOps.Operator.Web/Certificates/CertificateWebhookService.cs @@ -0,0 +1,29 @@ +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/Certificates/ICertificateProvider.cs b/src/KubeOps.Operator.Web/Certificates/ICertificateProvider.cs new file mode 100644 index 00000000..06cee883 --- /dev/null +++ b/src/KubeOps.Operator.Web/Certificates/ICertificateProvider.cs @@ -0,0 +1,22 @@ +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + +namespace KubeOps.Operator.Web.Certificates +{ + /// + /// Defines properties for certificates and keys so a custom certificate/key provider may be implemented. + /// The provider is used by the to provide a caBundle to the webhooks. + /// + public interface ICertificateProvider : IDisposable + { + /// + /// The server certificate and key. + /// + (X509Certificate2 Certificate, AsymmetricAlgorithm Key) Server { get; } + + /// + /// The root certificate and key. + /// + (X509Certificate2 Certificate, AsymmetricAlgorithm Key) Root { get; } + } +} 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/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..0de5985a 100644 --- a/test/KubeOps.Operator.Web.Test/Builder/OperatorBuilderExtensions.Test.cs +++ b/test/KubeOps.Operator.Web.Test/Builder/OperatorBuilderExtensions.Test.cs @@ -4,6 +4,7 @@ using KubeOps.Operator.Builder; using KubeOps.Operator.Web.Builder; using KubeOps.Operator.Web.LocalTunnel; +using KubeOps.Operator.Web.Webhooks; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -21,7 +22,7 @@ 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); } @@ -31,7 +32,7 @@ public void Should_Add_TunnelConfig() _builder.AddDevelopmentTunnel(1337, "my-host"); _builder.Services.Should().Contain(s => - s.ServiceType == typeof(TunnelConfig) && + s.ServiceType == typeof(WebhookConfig) && s.Lifetime == ServiceLifetime.Singleton); } } 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; From ce7a7b50ef40ba36fa715dc931254fd639364df3 Mon Sep 17 00:00:00 2001 From: "ian.buse" Date: Thu, 2 May 2024 14:42:38 -0700 Subject: [PATCH 2/8] feat(test): Add tests for certs and cert service --- .../Builder/OperatorBuilderExtensions.Test.cs | 38 ++++++++++++++-- .../Certificates/CertificateGenerator.Test.cs | 43 +++++++++++++++++++ 2 files changed, 77 insertions(+), 4 deletions(-) create mode 100644 test/KubeOps.Operator.Web.Test/Certificates/CertificateGenerator.Test.cs diff --git a/test/KubeOps.Operator.Web.Test/Builder/OperatorBuilderExtensions.Test.cs b/test/KubeOps.Operator.Web.Test/Builder/OperatorBuilderExtensions.Test.cs index 0de5985a..dbe7ad16 100644 --- a/test/KubeOps.Operator.Web.Test/Builder/OperatorBuilderExtensions.Test.cs +++ b/test/KubeOps.Operator.Web.Test/Builder/OperatorBuilderExtensions.Test.cs @@ -1,8 +1,11 @@ -using FluentAssertions; +using System.Xml.Linq; + +using FluentAssertions; using KubeOps.Abstractions.Builder; using KubeOps.Operator.Builder; using KubeOps.Operator.Web.Builder; +using KubeOps.Operator.Web.Certificates; using KubeOps.Operator.Web.LocalTunnel; using KubeOps.Operator.Web.Webhooks; @@ -11,9 +14,10 @@ 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() @@ -27,12 +31,38 @@ public void Should_Add_Development_Tunnel() } [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(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..af15cec9 --- /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); + } + } +} From 3bb58fba8b4b71ec505c3d0972110af4e96f4651 Mon Sep 17 00:00:00 2001 From: "ian.buse" Date: Tue, 7 May 2024 17:34:35 -0700 Subject: [PATCH 3/8] chore(cli): Replace BouncyCastle with built-in libs --- .../Certificates/CertificateGenerator.cs | 129 ------------------ src/KubeOps.Cli/Certificates/Extensions.cs | 22 --- .../Generators/CertificateGenerator.cs | 18 +-- src/KubeOps.Cli/KubeOps.Cli.csproj | 5 +- 4 files changed, 8 insertions(+), 166 deletions(-) delete mode 100644 src/KubeOps.Cli/Certificates/CertificateGenerator.cs delete mode 100644 src/KubeOps.Cli/Certificates/Extensions.cs 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 @@ - + From ea1439a9470fc92ee3341c707f3265d06d0b778f Mon Sep 17 00:00:00 2001 From: Ian Buse <57817326+ian-buse@users.noreply.github.com> Date: Fri, 10 May 2024 11:05:13 -0700 Subject: [PATCH 4/8] fix(web): Set EnhancedKeyUsage.Critical to false --- src/KubeOps.Operator.Web/Certificates/CertificateGenerator.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/KubeOps.Operator.Web/Certificates/CertificateGenerator.cs b/src/KubeOps.Operator.Web/Certificates/CertificateGenerator.cs index babcf713..d401257a 100644 --- a/src/KubeOps.Operator.Web/Certificates/CertificateGenerator.cs +++ b/src/KubeOps.Operator.Web/Certificates/CertificateGenerator.cs @@ -147,7 +147,7 @@ protected virtual void Dispose(bool disposing) 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")], - true)); + false)); request.CertificateExtensions.Add( new X509SubjectKeyIdentifierExtension( Root.Certificate.PublicKey, From cd29c5d27f21b87d7f27cc2edb810f224c7cfc30 Mon Sep 17 00:00:00 2001 From: "ian.buse" Date: Mon, 13 May 2024 11:31:54 -0700 Subject: [PATCH 5/8] Fixes for PR --- .../Certificates/ICertificateProvider.cs | 4 ++-- .../Builder/OperatorBuilderExtensions.cs | 9 ++++++--- .../Certificates/CertificateExtensions.cs | 2 +- .../Certificates/CertificateGenerator.cs | 2 +- .../Certificates/CertificateWebhookService.cs | 3 ++- src/KubeOps.Operator.Web/KubeOps.Operator.Web.csproj | 5 +++-- .../Builder/OperatorBuilderExtensions.Test.cs | 5 +++-- .../Certificates/CertificateGenerator.Test.cs | 2 +- 8 files changed, 19 insertions(+), 13 deletions(-) rename src/{KubeOps.Operator.Web => KubeOps.Abstractions}/Certificates/ICertificateProvider.cs (80%) diff --git a/src/KubeOps.Operator.Web/Certificates/ICertificateProvider.cs b/src/KubeOps.Abstractions/Certificates/ICertificateProvider.cs similarity index 80% rename from src/KubeOps.Operator.Web/Certificates/ICertificateProvider.cs rename to src/KubeOps.Abstractions/Certificates/ICertificateProvider.cs index 06cee883..4536036c 100644 --- a/src/KubeOps.Operator.Web/Certificates/ICertificateProvider.cs +++ b/src/KubeOps.Abstractions/Certificates/ICertificateProvider.cs @@ -1,11 +1,11 @@ using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; -namespace KubeOps.Operator.Web.Certificates +namespace KubeOps.Abstractions.Certificates { /// /// Defines properties for certificates and keys so a custom certificate/key provider may be implemented. - /// The provider is used by the to provide a caBundle to the webhooks. + /// The provider is used by the CertificateWebhookService to provide a caBundle to the webhooks. /// public interface ICertificateProvider : IDisposable { diff --git a/src/KubeOps.Operator.Web/Builder/OperatorBuilderExtensions.cs b/src/KubeOps.Operator.Web/Builder/OperatorBuilderExtensions.cs index ceb625e8..c73b0383 100644 --- a/src/KubeOps.Operator.Web/Builder/OperatorBuilderExtensions.cs +++ b/src/KubeOps.Operator.Web/Builder/OperatorBuilderExtensions.cs @@ -2,6 +2,7 @@ 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; @@ -41,8 +42,10 @@ 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.")] + [Obsolete( + "LocalTunnel features are deprecated and will be removed in a future version. " + + $"Instead, use the {nameof(UseCertificateProvider)} method for development webhooks.")] public static IOperatorBuilder AddDevelopmentTunnel( this IOperatorBuilder builder, ushort port, @@ -58,7 +61,7 @@ public static IOperatorBuilder AddDevelopmentTunnel( /// /// Adds a hosted service to the system that uses the server certificate from an - /// implementation to configure development webhooks without tunnels. The webhooks will be configured to use the hostname and port. + /// 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. diff --git a/src/KubeOps.Operator.Web/Certificates/CertificateExtensions.cs b/src/KubeOps.Operator.Web/Certificates/CertificateExtensions.cs index 79fe9123..c0268124 100644 --- a/src/KubeOps.Operator.Web/Certificates/CertificateExtensions.cs +++ b/src/KubeOps.Operator.Web/Certificates/CertificateExtensions.cs @@ -12,7 +12,7 @@ public static class CertificateExtensions /// /// The certificate to encode. /// The byte representation of the PEM-encoded certificate. - public static byte[] EncodeToPemBytes(this X509Certificate2 certificate) => Encoding.ASCII.GetBytes(certificate.EncodeToPem()); + public static byte[] EncodeToPemBytes(this X509Certificate2 certificate) => Encoding.UTF8.GetBytes(certificate.EncodeToPem()); /// /// Encodes the certificate in PEM format. diff --git a/src/KubeOps.Operator.Web/Certificates/CertificateGenerator.cs b/src/KubeOps.Operator.Web/Certificates/CertificateGenerator.cs index d401257a..c4cdfed7 100644 --- a/src/KubeOps.Operator.Web/Certificates/CertificateGenerator.cs +++ b/src/KubeOps.Operator.Web/Certificates/CertificateGenerator.cs @@ -3,7 +3,7 @@ using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; -using KubeOps.Operator.Web.Certificates; +using KubeOps.Abstractions.Certificates; namespace KubeOps.Operator.Web { diff --git a/src/KubeOps.Operator.Web/Certificates/CertificateWebhookService.cs b/src/KubeOps.Operator.Web/Certificates/CertificateWebhookService.cs index d6c16d80..e6acf069 100644 --- a/src/KubeOps.Operator.Web/Certificates/CertificateWebhookService.cs +++ b/src/KubeOps.Operator.Web/Certificates/CertificateWebhookService.cs @@ -1,4 +1,5 @@ -using KubeOps.KubernetesClient; +using KubeOps.Abstractions.Certificates; +using KubeOps.KubernetesClient; using KubeOps.Operator.Web.Webhooks; using Microsoft.Extensions.Hosting; 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/test/KubeOps.Operator.Web.Test/Builder/OperatorBuilderExtensions.Test.cs b/test/KubeOps.Operator.Web.Test/Builder/OperatorBuilderExtensions.Test.cs index dbe7ad16..468342c7 100644 --- a/test/KubeOps.Operator.Web.Test/Builder/OperatorBuilderExtensions.Test.cs +++ b/test/KubeOps.Operator.Web.Test/Builder/OperatorBuilderExtensions.Test.cs @@ -3,6 +3,7 @@ using FluentAssertions; using KubeOps.Abstractions.Builder; +using KubeOps.Abstractions.Certificates; using KubeOps.Operator.Builder; using KubeOps.Operator.Web.Builder; using KubeOps.Operator.Web.Certificates; @@ -54,8 +55,8 @@ public void Should_Add_Webhook_Service() public void Should_Add_Certificate_Provider() { _builder.UseCertificateProvider(54321, Environment.MachineName, _certProvider); - - _builder.Services.Should().Contain(s => + + _builder.Services.Should().Contain(s => s.ServiceType == typeof(ICertificateProvider) && s.Lifetime == ServiceLifetime.Singleton); } diff --git a/test/KubeOps.Operator.Web.Test/Certificates/CertificateGenerator.Test.cs b/test/KubeOps.Operator.Web.Test/Certificates/CertificateGenerator.Test.cs index af15cec9..6d3cb78b 100644 --- a/test/KubeOps.Operator.Web.Test/Certificates/CertificateGenerator.Test.cs +++ b/test/KubeOps.Operator.Web.Test/Certificates/CertificateGenerator.Test.cs @@ -12,7 +12,7 @@ public class CertificateGeneratorTest : IDisposable 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(); From 6f14d4b3483f80592b4655203531be012436ca68 Mon Sep 17 00:00:00 2001 From: "ian.buse" Date: Mon, 13 May 2024 16:58:27 -0700 Subject: [PATCH 6/8] Supress obsolete warning Hopefully fix linting issue --- src/KubeOps.Operator.Web/Builder/OperatorBuilderExtensions.cs | 2 ++ .../Builder/OperatorBuilderExtensions.Test.cs | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/KubeOps.Operator.Web/Builder/OperatorBuilderExtensions.cs b/src/KubeOps.Operator.Web/Builder/OperatorBuilderExtensions.cs index c73b0383..53d5a447 100644 --- a/src/KubeOps.Operator.Web/Builder/OperatorBuilderExtensions.cs +++ b/src/KubeOps.Operator.Web/Builder/OperatorBuilderExtensions.cs @@ -43,9 +43,11 @@ public static class OperatorBuilderExtensions /// [RequiresPreviewFeatures( "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, diff --git a/test/KubeOps.Operator.Web.Test/Builder/OperatorBuilderExtensions.Test.cs b/test/KubeOps.Operator.Web.Test/Builder/OperatorBuilderExtensions.Test.cs index 468342c7..a72dc3d7 100644 --- a/test/KubeOps.Operator.Web.Test/Builder/OperatorBuilderExtensions.Test.cs +++ b/test/KubeOps.Operator.Web.Test/Builder/OperatorBuilderExtensions.Test.cs @@ -55,7 +55,7 @@ public void Should_Add_Webhook_Service() public void Should_Add_Certificate_Provider() { _builder.UseCertificateProvider(54321, Environment.MachineName, _certProvider); - + _builder.Services.Should().Contain(s => s.ServiceType == typeof(ICertificateProvider) && s.Lifetime == ServiceLifetime.Singleton); From 5175ed7a6c9800da6c9077e677c7340cdd6e9ab1 Mon Sep 17 00:00:00 2001 From: "ian.buse" Date: Mon, 13 May 2024 18:08:19 -0700 Subject: [PATCH 7/8] Use Lazy for delayed creation of certs/keys Add CertificatePair record to Abstractions --- .../Certificates/CertificatePair.cs | 7 +++ .../Certificates/ICertificateProvider.cs | 6 +-- .../Certificates/CertificateExtensions.cs | 21 ++++---- .../Certificates/CertificateGenerator.cs | 54 ++++++------------- 4 files changed, 37 insertions(+), 51 deletions(-) create mode 100644 src/KubeOps.Abstractions/Certificates/CertificatePair.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 index 4536036c..fd9d5cb1 100644 --- a/src/KubeOps.Abstractions/Certificates/ICertificateProvider.cs +++ b/src/KubeOps.Abstractions/Certificates/ICertificateProvider.cs @@ -4,7 +4,7 @@ namespace KubeOps.Abstractions.Certificates { /// - /// Defines properties for certificates and keys so a custom certificate/key provider may be implemented. + /// 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 @@ -12,11 +12,11 @@ public interface ICertificateProvider : IDisposable /// /// The server certificate and key. /// - (X509Certificate2 Certificate, AsymmetricAlgorithm Key) Server { get; } + CertificatePair Server { get; } /// /// The root certificate and key. /// - (X509Certificate2 Certificate, AsymmetricAlgorithm Key) Root { get; } + CertificatePair Root { get; } } } diff --git a/src/KubeOps.Operator.Web/Certificates/CertificateExtensions.cs b/src/KubeOps.Operator.Web/Certificates/CertificateExtensions.cs index c0268124..734e2d89 100644 --- a/src/KubeOps.Operator.Web/Certificates/CertificateExtensions.cs +++ b/src/KubeOps.Operator.Web/Certificates/CertificateExtensions.cs @@ -1,8 +1,9 @@ -using System.Runtime.CompilerServices; -using System.Security.Cryptography; +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 @@ -32,20 +33,20 @@ public static class CertificateExtensions /// 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. + /// 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 (X509Certificate2 Certificate, AsymmetricAlgorithm Key) server) + public static X509Certificate2 CopyServerCertWithPrivateKey(this CertificatePair serverPair) { const string? password = null; - using X509Certificate2 temp = server.Key switch + using X509Certificate2 temp = serverPair.Key switch { - ECDsa ecdsa => server.Certificate.CopyWithPrivateKey(ecdsa), - RSA rsa => server.Certificate.CopyWithPrivateKey(rsa), - ECDiffieHellman ecdh => server.Certificate.CopyWithPrivateKey(ecdh), - DSA dsa => server.Certificate.CopyWithPrivateKey(dsa), - _ => throw new NotImplementedException($"{server.Key} is not implemented for {nameof(CopyServerCertWithPrivateKey)}"), + 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( diff --git a/src/KubeOps.Operator.Web/Certificates/CertificateGenerator.cs b/src/KubeOps.Operator.Web/Certificates/CertificateGenerator.cs index c4cdfed7..28e272d8 100644 --- a/src/KubeOps.Operator.Web/Certificates/CertificateGenerator.cs +++ b/src/KubeOps.Operator.Web/Certificates/CertificateGenerator.cs @@ -16,8 +16,8 @@ public class CertificateGenerator : ICertificateProvider private readonly string? _serverNamespace; private readonly DateTime _startDate; private readonly DateTime _endDate; - private (X509Certificate2 Certificate, AsymmetricAlgorithm Key) _root; - private (X509Certificate2 Certificate, AsymmetricAlgorithm Key) _server; + private readonly Lazy _root; + private readonly Lazy _server; /// /// Initializes a new instance of the class. @@ -29,6 +29,8 @@ public CertificateGenerator(string serverName) _serverNamespace = null; _startDate = DateTime.UtcNow.Date; _endDate = _startDate.AddYears(5); + _root = new(GenerateRootCertificate); + _server = new(GenerateServerCertificate); } /// @@ -42,31 +44,9 @@ public CertificateGenerator(string serverName, string serverNamespace) _serverNamespace = serverNamespace; } - public (X509Certificate2 Certificate, AsymmetricAlgorithm Key) Server - { - get - { - if (_server == default) - { - _server = GenerateServerCertificate(); - } - - return _server; - } - } + public CertificatePair Root => _root.Value; - public (X509Certificate2 Certificate, AsymmetricAlgorithm Key) Root - { - get - { - if (_root == default) - { - _root = GenerateRootCertificate(); - } - - return _root; - } - } + public CertificatePair Server => _server.Value; public void Dispose() { @@ -76,22 +56,20 @@ public void Dispose() protected virtual void Dispose(bool disposing) { - // These tuple values are not supposed to be null. However, if one of the methods throws, - // there is a chance one or both (especially the key) could be null - if (_root != default) + if (_root.IsValueCreated) { - _root.Certificate?.Dispose(); - _root.Key?.Dispose(); + _root.Value.Certificate.Dispose(); + _root.Value.Key.Dispose(); } - if (_server != default) + if (_server.IsValueCreated) { - _server.Certificate?.Dispose(); - _server.Key?.Dispose(); + _server.Value.Certificate.Dispose(); + _server.Value.Key.Dispose(); } } - private (X509Certificate2 Certificate, AsymmetricAlgorithm Key) GenerateRootCertificate() + private CertificatePair GenerateRootCertificate() { ECDsa? key = null; X509Certificate2? cert = null; @@ -114,7 +92,7 @@ protected virtual void Dispose(bool disposing) // Create the self-signed cert cert = request.CreateSelfSigned(_startDate, _endDate); - return (cert, key); + return new(cert, key); } catch { @@ -124,7 +102,7 @@ protected virtual void Dispose(bool disposing) } } - private (X509Certificate2 Certificate, AsymmetricAlgorithm Key) GenerateServerCertificate() + private CertificatePair GenerateServerCertificate() { ECDsa? key = null; X509Certificate2? cert = null; @@ -189,7 +167,7 @@ protected virtual void Dispose(bool disposing) _endDate, Guid.NewGuid().ToByteArray()); - return (cert, key); + return new(cert, key); } catch { From 3d7d2e170a2de26e2010274baec4f2f18b263a60 Mon Sep 17 00:00:00 2001 From: "ian.buse" Date: Mon, 13 May 2024 21:38:37 -0700 Subject: [PATCH 8/8] Update Operator.Web README with feature --- src/KubeOps.Operator.Web/README.md | 32 ++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) 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.