-
Notifications
You must be signed in to change notification settings - Fork 876
Implement a fast lookup for wildcard certificates #2815
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
arontsang
wants to merge
5
commits into
dotnet:main
Choose a base branch
from
arontsang:feature/fast-wildcard-certificate-lookup
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
3efadeb
Implement a fast lookup for wildcard certificates
arontsang 6b3455b
Refactor for Unit Tests
arontsang d979058
Fix code and add Unit Tests for CertificateCache
arontsang 3448610
Fix test
arontsang e56abc3
Switch to using record struct for better cache locality
arontsang File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
121 changes: 121 additions & 0 deletions
121
src/Kubernetes.Controller/Certificates/ImmutableCertificateCache.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,121 @@ | ||
// 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<TCert> where TCert : class | ||
{ | ||
private readonly List<WildCardDomain> _wildCardDomains = new(); | ||
private readonly Dictionary<string, TCert> _certificates = new(StringComparer.OrdinalIgnoreCase); | ||
|
||
public ImmutableCertificateCache(IEnumerable<TCert> certificates, Func<TCert, IEnumerable<string>> 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<WildCardDomain> WildcardCertificates => _wildCardDomains; | ||
|
||
protected IReadOnlyDictionary<string, TCert> 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; | ||
} | ||
|
||
/// <summary> | ||
/// Sorts domain names right to left. | ||
/// This allows us to use a Binary Search to achieve a suffix | ||
/// search. | ||
/// </summary> | ||
private class DomainNameComparer : IComparer<WildCardDomain> | ||
{ | ||
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<char> x, ReadOnlySpan<char> y) | ||
{ | ||
|
||
var length = Math.Min(x.Length, y.Length); | ||
|
||
for (var i = 1; i <= length; i++) | ||
{ | ||
var charA = x[^i] & 0x5F; | ||
var charB = y[^i] & 0x5F; | ||
Comment on lines
+107
to
+108
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You compare it backwards? |
||
|
||
if (charA == charB) | ||
{ | ||
continue; | ||
} | ||
|
||
return charB - charA; | ||
} | ||
|
||
return 0; | ||
} | ||
} | ||
} |
61 changes: 61 additions & 0 deletions
61
src/Kubernetes.Controller/Certificates/ServerCertificateSelector.CertificateCache.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,61 @@ | ||
// 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; | ||
|
||
namespace Yarp.Kubernetes.Controller.Certificates; | ||
|
||
internal partial class ServerCertificateSelector | ||
{ | ||
private class ImmutableX509CertificateCache(IEnumerable<X509Certificate2> certificates) | ||
: ImmutableCertificateCache<X509Certificate2>(certificates, GetDomains) | ||
{ | ||
protected override X509Certificate2? GetDefaultCertificate() | ||
{ | ||
if (WildcardCertificates.Count != 0) | ||
{ | ||
return WildcardCertificates[0].Certificate; | ||
} | ||
return Certificates.Values.FirstOrDefault(); | ||
} | ||
} | ||
|
||
private static IEnumerable<string> 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; | ||
} | ||
|
||
} | ||
|
||
|
||
|
||
} |
40 changes: 35 additions & 5 deletions
40
src/Kubernetes.Controller/Certificates/ServerCertificateSelector.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,27 +1,57 @@ | ||
// 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.Collections.Generic; | ||
using System.Security.Cryptography.X509Certificates; | ||
using System.Threading; | ||
using System.Threading.Tasks; | ||
using System.Timers; | ||
using Microsoft.AspNetCore.Connections; | ||
using Microsoft.Extensions.Hosting; | ||
|
||
namespace Yarp.Kubernetes.Controller.Certificates; | ||
|
||
internal class ServerCertificateSelector : IServerCertificateSelector | ||
internal partial class ServerCertificateSelector | ||
: BackgroundService | ||
, IServerCertificateSelector | ||
{ | ||
private X509Certificate2 _defaultCertificate; | ||
private readonly ConcurrentDictionary<NamespacedName, X509Certificate2> _certificates = new(); | ||
private bool _hasBeenUpdated; | ||
|
||
private ImmutableX509CertificateCache _certificateStore = new(Array.Empty<X509Certificate2>()); | ||
|
||
public void AddCertificate(NamespacedName certificateName, X509Certificate2 certificate) | ||
{ | ||
_defaultCertificate = certificate; | ||
_certificates[certificateName] = certificate; | ||
_hasBeenUpdated = true; | ||
} | ||
|
||
public X509Certificate2 GetCertificate(ConnectionContext connectionContext, string domainName) | ||
{ | ||
return _defaultCertificate; | ||
return _certificateStore.GetCertificate(domainName); | ||
} | ||
|
||
public void RemoveCertificate(NamespacedName certificateName) | ||
{ | ||
_defaultCertificate = null; | ||
_ = _certificates.TryRemove(certificateName, out _); | ||
_hasBeenUpdated = true; | ||
} | ||
|
||
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; | ||
_certificateStore = new ImmutableX509CertificateCache(_certificates.Values); | ||
} | ||
} | ||
} | ||
} | ||
|
||
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
56 changes: 56 additions & 0 deletions
56
test/Kubernetes.Tests/Certificates/CertificateCacheTests.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<FakeCertificate> certificates) | ||
: ImmutableCertificateCache<FakeCertificate>(certificates, static cert => cert.Domains) | ||
{ | ||
protected override FakeCertificate? GetDefaultCertificate() | ||
{ | ||
return null; | ||
} | ||
} | ||
} | ||
|
||
|
||
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So the JIT can't elide the bound-check for indexing into the spans.
I think it's better to slice the spans to the common (= min) length and use one span in the loop, in order to elide at least one bound check (the JIT should handle the downward loop (see other comment) in a recent version).
Roughly: