diff --git a/Directory.Build.props b/Directory.Build.props index 380e3c9ec..26b0aac70 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -7,7 +7,7 @@ © Microsoft Corporation. All rights reserved. - 12.0 + 13.0 MIT Microsoft true diff --git a/src/Kubernetes.Controller/Caching/IngressCache.cs b/src/Kubernetes.Controller/Caching/IngressCache.cs index 1670c7411..92cbe5e63 100644 --- a/src/Kubernetes.Controller/Caching/IngressCache.cs +++ b/src/Kubernetes.Controller/Caching/IngressCache.cs @@ -99,10 +99,13 @@ public void Update(WatchEventType eventType, V1Secret secret) if (!string.Equals(namespacedName.ToString(), _options.DefaultSslCertificate, StringComparison.OrdinalIgnoreCase)) { - return; + _logger.LogInformation("Found secret `{NamespacedName}` to use as default certificate for HTTPS traffic", namespacedName); + } + else + { + _logger.LogInformation("Found secret `{NamespacedName}` to use for HTTPS traffic", namespacedName); } - _logger.LogInformation("Found secret `{NamespacedName}` to use as default certificate for HTTPS traffic", namespacedName); var certificate = _certificateHelper.ConvertCertificate(namespacedName, secret); if (certificate is null) diff --git a/src/Kubernetes.Controller/Certificates/ImmutableCertificateCache.cs b/src/Kubernetes.Controller/Certificates/ImmutableCertificateCache.cs new file mode 100644 index 000000000..e5907213d --- /dev/null +++ b/src/Kubernetes.Controller/Certificates/ImmutableCertificateCache.cs @@ -0,0 +1,123 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; + +#nullable enable +namespace Yarp.Kubernetes.Controller.Certificates; + +public abstract class ImmutableCertificateCache where TCert : class +{ + private readonly List _wildCardDomains = new(); + private readonly Dictionary _certificates = new(StringComparer.OrdinalIgnoreCase); + + public ImmutableCertificateCache(IEnumerable certificates, Func> getDomains) + { + foreach (var certificate in certificates) + { + foreach (var domain in getDomains(certificate)) + { + if (domain.StartsWith("*.")) + { + _wildCardDomains.Add(new (domain[1..], certificate)); + } + else + { + _certificates[domain] = certificate; + } + } + } + + _wildCardDomains.Sort(DomainNameComparer.Instance); + } + + + + protected abstract TCert? GetDefaultCertificate(); + + public TCert? GetCertificate(string domain) + { + if (TryGetCertificateExact(domain, out var certificate)) + { + return certificate; + } + if (TryGetWildcardCertificate(domain, out certificate)) + { + return certificate; + } + + return GetDefaultCertificate(); + } + + protected IReadOnlyList WildcardCertificates => _wildCardDomains; + + protected IReadOnlyDictionary Certificates => _certificates; + + protected record struct WildCardDomain(string Domain, TCert? Certificate); + + private bool TryGetCertificateExact(string domain, [NotNullWhen(true)] out TCert? certificate) => + _certificates.TryGetValue(domain, out certificate); + + private bool TryGetWildcardCertificate(string domain, [NotNullWhen(true)] out TCert? certificate) + { + if (_wildCardDomains.BinarySearch(new WildCardDomain(domain, null!), DomainNameComparer.Instance) is { } index and > -1) + { + certificate = _wildCardDomains[index].Certificate!; + return true; + } + + certificate = null; + return false; + } + + /// + /// Sorts domain names right to left. + /// This allows us to use a Binary Search to achieve a suffix + /// search. + /// + private class DomainNameComparer : IComparer + { + public static readonly DomainNameComparer Instance = new(); + + public int Compare(WildCardDomain x, WildCardDomain y) + { + var ret = Compare(x.Domain.AsSpan(), y.Domain.AsSpan()); + if (ret != 0) + { + return ret; + } + + return (x.Certificate, y.Certificate) switch + { + (null, not null) when x.Domain.Length > y.Domain.Length => 0, + (not null, null) when x.Domain.Length < y.Domain.Length => 0, + _ => x.Domain.Length - y.Domain.Length + }; + } + + private static int Compare(ReadOnlySpan x, ReadOnlySpan y) + { + + var length = Math.Min(x.Length, y.Length); + x = x[^length..]; + y = y[^length..]; + + for (var index = length - 1; index >= 0; index--) + { + var charA = x[index] & 0x5F; + var charB = y[index] & 0x5F; + + if (charA == charB) + { + continue; + } + + return charB - charA; + } + + return 0; + } + } +} diff --git a/src/Kubernetes.Controller/Certificates/ServerCertificateSelector.CertificateCache.cs b/src/Kubernetes.Controller/Certificates/ServerCertificateSelector.CertificateCache.cs new file mode 100644 index 000000000..98a8e7207 --- /dev/null +++ b/src/Kubernetes.Controller/Certificates/ServerCertificateSelector.CertificateCache.cs @@ -0,0 +1,67 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +#nullable enable +using System.Collections.Generic; +using System.Formats.Asn1; +using System.Linq; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Options; + +namespace Yarp.Kubernetes.Controller.Certificates; + +internal partial class ServerCertificateSelector +{ + private class ImmutableX509CertificateCache(X509Certificate2? defaultCertificate, IEnumerable certificates) + : ImmutableCertificateCache(certificates, GetDomains) + { + protected override X509Certificate2? GetDefaultCertificate() + { + if (WildcardCertificates.Count != 0) + { + return WildcardCertificates[0].Certificate; + } + return Certificates.Values.FirstOrDefault(); + } + + public X509Certificate2? DefaultCertificate() + { + return defaultCertificate ?? Certificates.Values.FirstOrDefault(); + } + } + + private static IEnumerable GetDomains(X509Certificate2 certificate) + { + if (certificate.GetNameInfo(X509NameType.DnsName, false) is { } dnsName) + { + yield return dnsName; + } + + const string SAN_OID = "2.5.29.17"; + var extension = certificate.Extensions[SAN_OID]; + if (extension is null) + { + yield break; + } + + var dnsNameTag = new Asn1Tag(TagClass.ContextSpecific, tagValue: 2, isConstructed: false); + + var asnReader = new AsnReader(extension.RawData, AsnEncodingRules.BER); + var sequenceReader = asnReader.ReadSequence(Asn1Tag.Sequence); + while (sequenceReader.HasData) + { + var tag = sequenceReader.PeekTag(); + if (tag != dnsNameTag) + { + sequenceReader.ReadEncodedValue(); + continue; + } + + var alternativeName = sequenceReader.ReadCharacterString(UniversalTagNumber.IA5String, dnsNameTag); + yield return alternativeName; + } + + } + + + +} diff --git a/src/Kubernetes.Controller/Certificates/ServerCertificateSelector.cs b/src/Kubernetes.Controller/Certificates/ServerCertificateSelector.cs index 0c2bfdd10..0f0a15065 100644 --- a/src/Kubernetes.Controller/Certificates/ServerCertificateSelector.cs +++ b/src/Kubernetes.Controller/Certificates/ServerCertificateSelector.cs @@ -1,27 +1,100 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System; +using System.Collections.Concurrent; using System.Security.Cryptography.X509Certificates; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; using Microsoft.AspNetCore.Connections; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +#nullable enable namespace Yarp.Kubernetes.Controller.Certificates; -internal class ServerCertificateSelector : IServerCertificateSelector +internal partial class ServerCertificateSelector + : BackgroundService + , IServerCertificateSelector { - private X509Certificate2 _defaultCertificate; + private readonly ConcurrentDictionary _certificates = new(); + private bool _hasBeenUpdated; + private string? _defaultCertificate; + private readonly IDisposable? _defaultCertificateSubscription; + + private ImmutableX509CertificateCache _certificateStore = new(null, []); + + public ServerCertificateSelector(IOptionsMonitor options) + { + _defaultCertificateSubscription = options.OnChange(x => + { + if (_defaultCertificate != x.DefaultSslCertificate) + { + _defaultCertificate = x.DefaultSslCertificate; + _hasBeenUpdated = true; + } + }); + } + + public override void Dispose() + { + if (_defaultCertificateSubscription is {} subscription) + { + subscription.Dispose(); + } + base.Dispose(); + } public void AddCertificate(NamespacedName certificateName, X509Certificate2 certificate) { - _defaultCertificate = certificate; + _certificates[certificateName] = certificate; + _hasBeenUpdated = true; } - public X509Certificate2 GetCertificate(ConnectionContext connectionContext, string domainName) + public X509Certificate2? GetCertificate(ConnectionContext connectionContext, string? domainName) { - return _defaultCertificate; + if (string.IsNullOrEmpty(domainName)) + { + return _certificateStore.DefaultCertificate(); + } + return _certificateStore.GetCertificate(domainName); } public void RemoveCertificate(NamespacedName certificateName) { - _defaultCertificate = null; + _ = _certificates.TryRemove(certificateName, out _); + _hasBeenUpdated = true; + } + + [GeneratedRegex("(?[a-z0-9\\-\\.]*)/(?[a-z0-9\\-\\.]*)", RegexOptions.IgnoreCase, "en-US")] + private static partial Regex DefaultCertificateNameParser(); + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + // Poll every 10 seconds for updates to + while (!stoppingToken.IsCancellationRequested) + { + await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken); + if (_hasBeenUpdated) + { + _hasBeenUpdated = false; + + X509Certificate2? defaultCert = null; + if (_defaultCertificate is { } certificateName + && DefaultCertificateNameParser().Match(certificateName) is { Success: true } match) + { + var namespaceName = match.Groups["namespace"].Value; + var name = match.Groups["certificateName"].Value; + var certificateNamespacedName = new NamespacedName(namespaceName, name); + + _ = _certificates.TryGetValue(certificateNamespacedName, out defaultCert); + } + + _certificateStore = new ImmutableX509CertificateCache(defaultCert, _certificates.Values); + } + } } } + + diff --git a/src/Kubernetes.Controller/Management/KubernetesReverseProxyServiceCollectionExtensions.cs b/src/Kubernetes.Controller/Management/KubernetesReverseProxyServiceCollectionExtensions.cs index 663fa5921..cd4570053 100644 --- a/src/Kubernetes.Controller/Management/KubernetesReverseProxyServiceCollectionExtensions.cs +++ b/src/Kubernetes.Controller/Management/KubernetesReverseProxyServiceCollectionExtensions.cs @@ -92,7 +92,9 @@ public static IServiceCollection AddKubernetesControllerRuntime(this IServiceCol services.RegisterResourceInformer("type=kubernetes.io/tls"); // Add the Ingress/Secret to certificate management - services.AddSingleton(); + services.AddSingleton(); + services.AddHostedService(x => x.GetRequiredService()); + services.AddSingleton(x => x.GetRequiredService()); services.AddSingleton(); // ingress status updater diff --git a/test/Kubernetes.Tests/Certificates/CertificateCacheTests.cs b/test/Kubernetes.Tests/Certificates/CertificateCacheTests.cs new file mode 100644 index 000000000..125fbc5e2 --- /dev/null +++ b/test/Kubernetes.Tests/Certificates/CertificateCacheTests.cs @@ -0,0 +1,56 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using Xunit; +#nullable enable +namespace Yarp.Kubernetes.Controller.Certificates.Tests; + +public class CertificateCacheTests +{ + + private static readonly FakeCertificateCache Cache = new( + new FakeCertificate("Acme", "mail.acme.com", "www.acme.com"), + new FakeCertificate("Initech", "*.initech.com", "initech.com"), + new FakeCertificate("Northwind", "*.northwind.com") + ); + + [Theory] + [InlineData("www.acme.com", "Acme")] + [InlineData("www.ACME.com", "Acme")] + [InlineData("mail.acme.com", "Acme")] + [InlineData("acme.com", null)] + [InlineData("store.acme.com", null)] + [InlineData("www.northwind.com", "Northwind")] + [InlineData("mail.northwind.com", "Northwind")] + [InlineData("northwind.com", null)] + [InlineData("initech.com", "Initech")] + [InlineData("www.initech.com", "Initech")] + [InlineData("www.IniTech.coM", "Initech")] + public void CertificateConversionFromPem(string requestedDomain, string? expectedCompanyName) + { + var certificate = Cache.GetCertificate(requestedDomain); + if (expectedCompanyName != null) + { + Assert.Equal(expectedCompanyName, certificate?.Name); + } + else + { + Assert.Null(certificate?.Name); + } + } + + private record FakeCertificate(string Name, params string[] Domains); + + private class FakeCertificateCache(params IEnumerable certificates) + : ImmutableCertificateCache(certificates, static cert => cert.Domains) + { + protected override FakeCertificate? GetDefaultCertificate() + { + return null; + } + } +} + + +