From 54f5df60ef7e197c42bebb6e2739b50ec1099dcf Mon Sep 17 00:00:00 2001 From: Robin Rexstedt Date: Thu, 2 Oct 2025 10:15:39 +0200 Subject: [PATCH] add separate client cert options to SenderOptions that are used in TcpSender and HttpSender add tests that verify client certificate usage undo accidental formatting change make sure the cert is loaded disable tls_verify in tests --- src/dummy-http-server/DummyHttpServer.cs | 14 +++++- src/net-questdb-client-tests/HttpTests.cs | 46 ++++++++++++++++++- src/net-questdb-client/Senders/HttpSender.cs | 8 +++- src/net-questdb-client/Senders/TcpSender.cs | 9 +++- src/net-questdb-client/Utils/SenderOptions.cs | 26 ++++++++++- 5 files changed, 97 insertions(+), 6 deletions(-) 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, + }; + } +}