diff --git a/src/KurrentDB.Core.XUnit.Tests/Metrics/CertificatesExpirationMetricTest.cs b/src/KurrentDB.Core.XUnit.Tests/Metrics/CertificatesExpirationMetricTest.cs new file mode 100644 index 00000000000..51ec635ccb0 --- /dev/null +++ b/src/KurrentDB.Core.XUnit.Tests/Metrics/CertificatesExpirationMetricTest.cs @@ -0,0 +1,148 @@ +// Copyright (c) Kurrent, Inc and/or licensed to Kurrent, Inc under one or more agreements. +// Kurrent, Inc licenses this file to you under the Kurrent License v1 (see LICENSE.md). + +using System; +using System.Diagnostics.Metrics; +using System.Linq; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using Amazon.SQS.Model; +using KurrentDB.Core.Certificates; +using KurrentDB.Core.Metrics; +using KurrentDB.Core.Time; +using Xunit; + + +namespace KurrentDB.Core.XUnit.Tests.Metrics; + +public class CertificatesMetricTests : IDisposable { + private readonly TestMeterListener _doubleListener; + + private readonly Meter _meter; + private readonly DateTimeOffset _now; + + public CertificatesMetricTests() { + _meter = new Meter($"{typeof(CertificatesMetricTests)}"); + _doubleListener = new TestMeterListener(_meter); + _doubleListener.Observe(); + _now = DateTimeOffset.FromUnixTimeSeconds(Clock.Instance.SecondsSinceEpoch); + } + + public void Dispose() => _doubleListener.Dispose(); + + [Fact] + public void When_Expired() { + + + var b = new CertificateProviderTest(_now.AddDays(-10), _now.AddDays(-9)); + var sut = new CertificatesMetric(_meter, "cert_apocalypse", b); + + + sut.Measure(); + var measurements = _doubleListener.RetrieveMeasurements("cert_apocalypse-day"); + + Assert.Contains( + measurements, + m => { + + Assert.True(Math.Abs(m.Value + 9) < 0.01); + + + Assert.Equal(CertificatesMetric.Tags.All, m.Tags.Select(t => t.Key)); + Assert.Contains( + m.Tags.Single(t => t.Key == CertificatesMetric.Tags.ChainLevel).Value, + CertificatesMetric.ChainLevels.All + ); + Assert.Equal( + CertificatesMetric.ValidityPeriods.Expired, + m.Tags.Single(t => t.Key == CertificatesMetric.Tags.ValidityPeriod).Value); + + + return true; + }); + } + [Fact] + public void When_Ok() { + + + var b = new CertificateProviderTest(_now.AddDays(-10), _now.AddDays(10)); + var sut = new CertificatesMetric(_meter, "cert_will_be_apocalypse", b); + + + sut.Measure(); + var measurements = _doubleListener.RetrieveMeasurements("cert_will_be_apocalypse-day"); + + Assert.Contains( + measurements, + m => { + + Assert.True(Math.Abs(m.Value -10)< 0.01); + + + Assert.Equal(CertificatesMetric.Tags.All, m.Tags.Select(t => t.Key)); + Assert.Contains( + m.Tags.Single(t => t.Key == CertificatesMetric.Tags.ChainLevel).Value, + CertificatesMetric.ChainLevels.All + ); + Assert.Equal( + CertificatesMetric.ValidityPeriods.InPeriod, + m.Tags.Single(t => t.Key == CertificatesMetric.Tags.ValidityPeriod).Value); + + return true; + }); + } + + [Fact] + public void When_Not_Yet_Valid() { + + + var b = new CertificateProviderTest(_now.AddDays(10), _now.AddDays(11)); + var sut = new CertificatesMetric(_meter, "cert_future_apocalypse", b); + + + sut.Measure(); + var measurements = _doubleListener.RetrieveMeasurements("cert_future_apocalypse-day"); + + Assert.Contains( + measurements, + m => { + + Assert.True(Math.Abs(m.Value -11 ) < 0.01); + + Assert.Equal(CertificatesMetric.Tags.All, m.Tags.Select(t => t.Key)); + Assert.Contains( + m.Tags.Single(t => t.Key == CertificatesMetric.Tags.ChainLevel).Value, + CertificatesMetric.ChainLevels.All + ); + Assert.Equal( + CertificatesMetric.ValidityPeriods.NotValidYet, + m.Tags.Single(t => t.Key == CertificatesMetric.Tags.ValidityPeriod).Value); + return true; + }); + } + + + public class CertificateProviderTest : CertificateProvider { + + + public CertificateProviderTest(DateTimeOffset notBefore, DateTimeOffset notAfter) { + using RSA rsa = RSA.Create(); + var request = new CertificateRequest("CN=SelfSignedCert", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + + + Certificate=request.CreateSelfSigned(notBefore, notAfter); + IntermediateCerts = new X509Certificate2Collection( + + request.CreateSelfSigned(notBefore, notAfter) + ); + TrustedRootCerts = new X509Certificate2Collection(request.CreateSelfSigned(notBefore, notAfter)); + } + + public override LoadCertificateResult LoadCertificates(ClusterVNodeOptions options) => + throw new UnsupportedOperationException(""); + + public override string GetReservedNodeCommonName() => + throw new UnsupportedOperationException(""); + } + +} diff --git a/src/KurrentDB.Core/Metrics/CertificatesMetric.cs b/src/KurrentDB.Core/Metrics/CertificatesMetric.cs new file mode 100644 index 00000000000..4d0ee6af21e --- /dev/null +++ b/src/KurrentDB.Core/Metrics/CertificatesMetric.cs @@ -0,0 +1,97 @@ +// Copyright (c) Kurrent, Inc and/or licensed to Kurrent, Inc under one or more agreements. +// Kurrent, Inc licenses this file to you under the Kurrent License v1 (see LICENSE.md). + +using System; +using System.Collections.Generic; +using System.Diagnostics.Metrics; +using System.Security.Cryptography.X509Certificates; +using KurrentDB.Core.Certificates; +using KurrentDB.Core.Time; + +namespace KurrentDB.Core.Metrics; + +public class CertificatesMetric { + private readonly Meter _meter; + private readonly string _name; + private readonly CertificateProvider _certificateProvider; + private readonly List _subMetric= []; + private readonly IClock _clock; + + public static class ChainLevels { + public const string Node="node"; + public const string Intermediate = "intermediate"; + public const string Root = "root"; + public static readonly string[] All = [Node, Intermediate, Root]; + } + public static class Tags { + public const string ChainLevel = "chain_level"; + public const string SerialNumber = "serial_number"; + public const string Thumbprint = "thumbprint"; + public const string ValidityPeriod = "validity_period"; + public static readonly string[] All = [ChainLevel, SerialNumber, Thumbprint, ValidityPeriod]; + } + public static class ValidityPeriods { + public const string InPeriod="0"; + public const string NotValidYet = "1"; + public const string Expired = "-1"; + } + + + + public CertificatesMetric(Meter meter, string name, + CertificateProvider certificateProvider, + IClock clock = null) { + _meter = meter; + _name = name; + _certificateProvider = certificateProvider; + _clock = clock ?? Clock.Instance; + + MeasureAllCerts(meter, name, certificateProvider); + } + + public void Measure() { + foreach (var subMetric in _subMetric) + subMetric.Measure(); + } + + public void Renewed() => MeasureAllCerts(_meter, _name, _certificateProvider); + + private void MeasureAllCerts(Meter meter, string name, CertificateProvider certificateProvider) + { + _subMetric.Clear(); + _subMetric.Add(new CertificateExpiration(certificateProvider.Certificate, ChainLevels.Node, meter, name,_clock)); + foreach (var intermediate in certificateProvider.IntermediateCerts) + _subMetric.Add(new CertificateExpiration(intermediate, ChainLevels.Intermediate, meter, name,_clock)); + + foreach (var root in certificateProvider.TrustedRootCerts) + _subMetric.Add(new CertificateExpiration(root, ChainLevels.Root, meter, name,_clock)); + } + + + public class CertificateExpiration(X509Certificate2 cert, string type, Meter meter, string name, IClock clock ) { + + private readonly Gauge _gauge = meter.CreateGauge(name, "day", "Days before the certificate expires"); + + private readonly KeyValuePair[] _tags = [ + new(Tags.ChainLevel, type), + new(Tags.SerialNumber, cert.GetSerialNumberString()), + new(Tags.Thumbprint, cert.Thumbprint), + new(Tags.ValidityPeriod, ValidityPeriod(DateTimeOffset.FromUnixTimeSeconds(clock.SecondsSinceEpoch).DateTime, cert.NotBefore, cert.NotAfter).ToString()) + + + ]; + + private static string ValidityPeriod(DateTime now, DateTime notBefore, DateTime notAfter) => + notBefore <= now && now <= notAfter + ? ValidityPeriods.InPeriod + : now <= notBefore + ? ValidityPeriods.NotValidYet + : ValidityPeriods.Expired; + + public void Measure() { + var certApocalypseIn = (cert.NotAfter - DateTime.Now).TotalDays; + _gauge.Record(certApocalypseIn, _tags); + + } + } +}