diff --git a/src/dummy-http-server/DummyHttpServer.cs b/src/dummy-http-server/DummyHttpServer.cs index e11ae42..2dd36f6 100644 --- a/src/dummy-http-server/DummyHttpServer.cs +++ b/src/dummy-http-server/DummyHttpServer.cs @@ -29,6 +29,7 @@ using System.Text; using FastEndpoints; using FastEndpoints.Security; +using Microsoft.AspNetCore.Server.Kestrel.Https; namespace dummy_http_server; @@ -42,7 +43,7 @@ public class DummyHttpServer : IDisposable private readonly TimeSpan? _withStartDelay; public DummyHttpServer(bool withTokenAuth = false, bool withBasicAuth = false, bool withRetriableError = false, - bool withErrorMessage = false, TimeSpan? withStartDelay = null) + bool withErrorMessage = false, TimeSpan? withStartDelay = null, bool requireClientCert = false) { var bld = WebApplication.CreateBuilder(); @@ -71,6 +72,15 @@ public DummyHttpServer(bool withTokenAuth = false, bool withBasicAuth = false, b bld.Services.AddHealthChecks(); bld.WebHost.ConfigureKestrel(o => { + if (requireClientCert) + { + o.ConfigureHttpsDefaults(https => + { + https.ClientCertificateMode = ClientCertificateMode.RequireCertificate; + https.AllowAnyClientCertificate(); + }); + } + o.Limits.MaxRequestBodySize = 1073741824; o.ListenLocalhost(29474, options => { options.UseHttps(); }); @@ -253,4 +263,4 @@ public string PrintBuffer() sb.Append(Encoding.UTF8.GetString(bytes, lastAppend, i - lastAppend)); return sb.ToString(); } -} \ No newline at end of file +} diff --git a/src/net-questdb-client-tests/HttpTests.cs b/src/net-questdb-client-tests/HttpTests.cs index 3e76c0c..580438b 100644 --- a/src/net-questdb-client-tests/HttpTests.cs +++ b/src/net-questdb-client-tests/HttpTests.cs @@ -24,6 +24,7 @@ ******************************************************************************/ +using System.Security.Cryptography.X509Certificates; using System.Text; using dummy_http_server; using NUnit.Framework; @@ -1624,4 +1625,47 @@ await sender.Table("table name") // ReSharper disable once DisposeOnUsingVariable srv.Dispose(); } -} \ No newline at end of file + + [Test] + public async Task SendWithCert() + { +#if NET9_0_OR_GREATER + using var cert = X509CertificateLoader.LoadPkcs12FromFile("certificate.pfx", null); +#else + using var cert = new X509Certificate2("certificate.pfx", (string?)null); +#endif + + Assert.NotNull(cert); + + using var server = new DummyHttpServer(requireClientCert: true); + await server.StartAsync(HttpsPort); + using var sender = Sender.Configure($"https::addr=localhost:{HttpsPort};tls_verify=unsafe_off;") + .WithClientCert(cert) + .Build(); + + await sender.Table("metrics") + .Symbol("tag", "value") + .Column("number", 12.2) + .AtAsync(new DateTime(1970, 01, 01, 0, 0, 1)); + + await sender.SendAsync(); + Assert.That( + server.PrintBuffer(), + Is.EqualTo("metrics,tag=value number=12.2 1000000000\n")); + await server.StopAsync(); + } + + [Test] + public async Task FailsWhenExpectingCert() + { + using var server = new DummyHttpServer(requireClientCert: true); + await server.StartAsync(HttpsPort); + + Assert.That( + () => Sender.Configure($"https::addr=localhost:{HttpsPort};tls_verify=unsafe_off;").Build(), + Throws.TypeOf().With.Message.Contains("ServerFlushError") + ); + + await server.StopAsync(); + } +} diff --git a/src/net-questdb-client/Senders/HttpSender.cs b/src/net-questdb-client/Senders/HttpSender.cs index 2ed207f..da4e641 100644 --- a/src/net-questdb-client/Senders/HttpSender.cs +++ b/src/net-questdb-client/Senders/HttpSender.cs @@ -114,6 +114,12 @@ private void Build() _handler.SslOptions.ClientCertificates.Add( X509Certificate2.CreateFromPemFile(Options.tls_roots!, Options.tls_roots_password)); } + + if (Options.client_cert is not null) + { + _handler.SslOptions.ClientCertificates ??= new X509Certificate2Collection(); + _handler.SslOptions.ClientCertificates.Add(Options.client_cert); + } } _handler.ConnectTimeout = Options.auth_timeout; @@ -620,4 +626,4 @@ public override void Dispose() Buffer.Clear(); Buffer.TrimExcessBuffers(); } -} \ No newline at end of file +} diff --git a/src/net-questdb-client/Senders/TcpSender.cs b/src/net-questdb-client/Senders/TcpSender.cs index a6189bd..dad37d5 100644 --- a/src/net-questdb-client/Senders/TcpSender.cs +++ b/src/net-questdb-client/Senders/TcpSender.cs @@ -27,6 +27,7 @@ using System.Buffers.Text; using System.Net.Security; using System.Net.Sockets; +using System.Security.Cryptography.X509Certificates; using QuestDB.Enums; using QuestDB.Utils; using ProtocolType = QuestDB.Enums.ProtocolType; @@ -83,6 +84,12 @@ private void Build() Options.tls_verify == TlsVerifyType.unsafe_off ? AllowAllCertCallback : null, }; + if (Options.client_cert is not null) + { + sslOptions.ClientCertificates ??= new X509CertificateCollection(); + sslOptions.ClientCertificates.Add(Options.client_cert); + } + sslStream.AuthenticateAsClient(sslOptions); if (!sslStream.IsEncrypted) { @@ -253,4 +260,4 @@ public override void Dispose() Buffer.Clear(); Buffer.TrimExcessBuffers(); } -} \ No newline at end of file +} diff --git a/src/net-questdb-client/Utils/SenderOptions.cs b/src/net-questdb-client/Utils/SenderOptions.cs index 5b68161..38dcc88 100644 --- a/src/net-questdb-client/Utils/SenderOptions.cs +++ b/src/net-questdb-client/Utils/SenderOptions.cs @@ -28,6 +28,7 @@ using System.Data.Common; using System.Reflection; using System.Runtime.CompilerServices; +using System.Security.Cryptography.X509Certificates; using System.Text.Json.Serialization; using QuestDB.Enums; using QuestDB.Senders; @@ -81,6 +82,7 @@ public record SenderOptions private string? _tokenX; private string? _tokenY; private string? _username; + private X509Certificate2? _clientCert; /// /// Construct a object with default values. @@ -473,6 +475,15 @@ public int Port } } + /// + /// Specifies a client certificate to be used for TLS authentication. + /// + public X509Certificate2? client_cert + { + get => _clientCert; + set => _clientCert = value; + } + private void ParseIntWithDefault(string name, string defaultValue, out int field) { if (!int.TryParse(ReadOptionFromBuilder(name) ?? defaultValue, out field)) @@ -648,4 +659,17 @@ public ISender Build() { return Sender.New(this); } -} \ No newline at end of file + + /// + /// Sets a client certificate to be used for TLS authentication. + /// + /// + /// + public SenderOptions WithClientCert(X509Certificate2 cert) + { + return this with + { + _clientCert = cert, + }; + } +}