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;
+ }
+ }
+}
+
+
+