Skip to content

Commit 3efadeb

Browse files
committed
Implement a fast lookup for wildcard certificates
1 parent 3f7edd5 commit 3efadeb

File tree

3 files changed

+177
-6
lines changed

3 files changed

+177
-6
lines changed
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System;
5+
using System.Collections.Generic;
6+
using System.Formats.Asn1;
7+
using System.Globalization;
8+
using System.Linq;
9+
using System.Security.Cryptography.X509Certificates;
10+
11+
namespace Yarp.Kubernetes.Controller.Certificates;
12+
13+
internal partial class ServerCertificateSelector
14+
{
15+
private class CertificateStore
16+
{
17+
private readonly List<WildCardDomain> _wildCardDomains = new();
18+
private readonly Dictionary<string, X509Certificate2> _certificates = new(StringComparer.OrdinalIgnoreCase);
19+
20+
public CertificateStore(IEnumerable<X509Certificate2> certificates)
21+
{
22+
23+
foreach (var certificate in certificates)
24+
{
25+
foreach (var domain in GetDomains(certificate))
26+
{
27+
if (domain.StartsWith("*."))
28+
{
29+
_wildCardDomains.Add(new (domain[2..], certificate));
30+
}
31+
else
32+
{
33+
_certificates[domain] = certificate;
34+
}
35+
}
36+
}
37+
38+
_wildCardDomains.Sort(DomainNameComparer.Instance);
39+
}
40+
41+
42+
public X509Certificate2 GetCertificate(string domain)
43+
{
44+
// First search for exact match for certificate.
45+
if (_certificates.TryGetValue(domain, out var cert))
46+
{
47+
return cert;
48+
}
49+
50+
51+
// By using a binary search, we can achieve O(log n) suffix search whilst avoiding a complex
52+
// tree/trie structure in the heap.
53+
if (_wildCardDomains.BinarySearch(new WildCardDomain(domain, null!), DomainNameComparer.Instance) is { } index and < -1)
54+
{
55+
var candidate = _wildCardDomains[~index];
56+
if (domain.EndsWith(candidate.Domain, true, CultureInfo.InvariantCulture))
57+
{
58+
return candidate.Certificate;
59+
}
60+
}
61+
62+
return _wildCardDomains.FirstOrDefault()?.Certificate
63+
?? _certificates.Values.FirstOrDefault();
64+
}
65+
}
66+
67+
private record WildCardDomain(string Domain, X509Certificate2 Certificate);
68+
69+
private static IEnumerable<string> GetDomains(X509Certificate2 certificate)
70+
{
71+
if (certificate.GetNameInfo(X509NameType.DnsName, false) is { } dnsName)
72+
{
73+
yield return dnsName;
74+
}
75+
76+
const string SAN_OID = "2.5.29.17";
77+
var extension = certificate.Extensions[SAN_OID];
78+
if (extension is null)
79+
{
80+
yield break;
81+
}
82+
83+
var dnsNameTag = new Asn1Tag(TagClass.ContextSpecific, tagValue: 2, isConstructed: false);
84+
85+
var asnReader = new AsnReader(extension.RawData, AsnEncodingRules.BER);
86+
var sequenceReader = asnReader.ReadSequence(Asn1Tag.Sequence);
87+
while (sequenceReader.HasData)
88+
{
89+
var tag = sequenceReader.PeekTag();
90+
if (tag != dnsNameTag)
91+
{
92+
sequenceReader.ReadEncodedValue();
93+
continue;
94+
}
95+
96+
var alternativeName = sequenceReader.ReadCharacterString(UniversalTagNumber.IA5String, dnsNameTag);
97+
yield return alternativeName;
98+
}
99+
100+
}
101+
102+
103+
/// <summary>
104+
/// Sorts domain names right to left.
105+
/// This allows us to use a Binary Search to achieve a suffix
106+
/// search.
107+
/// </summary>
108+
private class DomainNameComparer : IComparer<WildCardDomain>
109+
{
110+
public static readonly DomainNameComparer Instance = new();
111+
112+
public int Compare(WildCardDomain x, WildCardDomain y)
113+
{
114+
return Compare(x!.Domain.AsSpan(), y!.Domain.AsSpan());
115+
}
116+
117+
private static int Compare(ReadOnlySpan<char> x, ReadOnlySpan<char> y)
118+
{
119+
120+
var length = Math.Min(x.Length, y.Length);
121+
122+
for (var i = 1; i <= length; i++)
123+
{
124+
var charA = x[^i] & 0x3F;
125+
var charB = y[^i] & 0x3F;
126+
127+
if (charA == charB)
128+
{
129+
continue;
130+
}
131+
132+
return charB - charA;
133+
}
134+
135+
return x.Length - y.Length;
136+
}
137+
138+
}
139+
}
Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,57 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4+
using System;
5+
using System.Collections.Concurrent;
6+
using System.Collections.Generic;
47
using System.Security.Cryptography.X509Certificates;
8+
using System.Threading;
9+
using System.Threading.Tasks;
10+
using System.Timers;
511
using Microsoft.AspNetCore.Connections;
12+
using Microsoft.Extensions.Hosting;
613

714
namespace Yarp.Kubernetes.Controller.Certificates;
815

9-
internal class ServerCertificateSelector : IServerCertificateSelector
16+
internal partial class ServerCertificateSelector
17+
: BackgroundService
18+
, IServerCertificateSelector
1019
{
11-
private X509Certificate2 _defaultCertificate;
20+
private readonly ConcurrentDictionary<NamespacedName, X509Certificate2> _certificates = new();
21+
private bool _hasBeenUpdated;
22+
23+
private CertificateStore _certificateStore = new(Array.Empty<X509Certificate2>());
1224

1325
public void AddCertificate(NamespacedName certificateName, X509Certificate2 certificate)
1426
{
15-
_defaultCertificate = certificate;
27+
_certificates[certificateName] = certificate;
28+
_hasBeenUpdated = true;
1629
}
1730

1831
public X509Certificate2 GetCertificate(ConnectionContext connectionContext, string domainName)
1932
{
20-
return _defaultCertificate;
33+
return _certificateStore.GetCertificate(domainName);
2134
}
2235

2336
public void RemoveCertificate(NamespacedName certificateName)
2437
{
25-
_defaultCertificate = null;
38+
_ = _certificates.TryRemove(certificateName, out _);
39+
_hasBeenUpdated = true;
40+
}
41+
42+
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
43+
{
44+
// Poll every 10 seconds for updates to
45+
while (!stoppingToken.IsCancellationRequested)
46+
{
47+
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
48+
if (_hasBeenUpdated)
49+
{
50+
_hasBeenUpdated = false;
51+
_certificateStore = new CertificateStore(_certificates.Values);
52+
}
53+
}
2654
}
2755
}
56+
57+

src/Kubernetes.Controller/Management/KubernetesReverseProxyServiceCollectionExtensions.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,9 @@ public static IServiceCollection AddKubernetesControllerRuntime(this IServiceCol
9292
services.RegisterResourceInformer<V1Secret, V1SecretResourceInformer>("type=kubernetes.io/tls");
9393

9494
// Add the Ingress/Secret to certificate management
95-
services.AddSingleton<IServerCertificateSelector, ServerCertificateSelector>();
95+
services.AddSingleton<ServerCertificateSelector>();
96+
services.AddHostedService(x => x.GetRequiredService<ServerCertificateSelector>());
97+
services.AddSingleton<IServerCertificateSelector>(x => x.GetRequiredService<ServerCertificateSelector>());
9698
services.AddSingleton<ICertificateHelper, CertificateHelper>();
9799

98100
// ingress status updater

0 commit comments

Comments
 (0)