This is an automated email from the ASF dual-hosted git repository.

piotr pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/iggy.git


The following commit(s) were added to refs/heads/master by this push:
     new 9f8198330 feat(csharp): add support for TLS TCP communication (#2395)
9f8198330 is described below

commit 9f81983301bee4094cfe3306b57ad89374def4b9
Author: Łukasz Zborek <[email protected]>
AuthorDate: Sun Nov 23 21:14:53 2025 +0100

    feat(csharp): add support for TLS TCP communication (#2395)
    
    - Add support for certificates in TCP TLS communication
    - Add docker image building in E2E tests (similar to BDD tests)
---
 .github/actions/csharp-dotnet/pre-merge/action.yml |  27 ++--
 .../utils/docker-build-test-server/action.yml      | 105 ++++++++++++++
 .../Fixtures/IggyServerFixture.cs                  | 156 ++++++++++++---------
 .../Fixtures/IggyTlsServerFixture.cs}              |  33 +++--
 .../Helpers/ResourceMapping.cs}                    |  23 +--
 .../IggyTlsConnectionTests.cs                      | 101 +++++++++++++
 .../Iggy_SDK.Tests.Integration.csproj              |  19 +++
 .../Iggy_SDK/Configuration/TlsConfiguration.cs     |   6 +-
 .../InvalidCertificatePathException.cs}            |  23 ++-
 .../IggyClient/Implementations/TcpMessageStream.cs |  90 +++++++++++-
 foreign/csharp/README.md                           |   2 +-
 11 files changed, 445 insertions(+), 140 deletions(-)

diff --git a/.github/actions/csharp-dotnet/pre-merge/action.yml 
b/.github/actions/csharp-dotnet/pre-merge/action.yml
index 712dda261..ac4d404e9 100644
--- a/.github/actions/csharp-dotnet/pre-merge/action.yml
+++ b/.github/actions/csharp-dotnet/pre-merge/action.yml
@@ -15,8 +15,6 @@
 # specific language governing permissions and limitations
 # under the License.
 
-# TODO(hubcio): Currently, C# tests don't need server. They use testcontainers 
with 'edge' image.
-#               We should change this to use server-start/stop action, so that 
code from PR is tested.
 
 name: csharp-dotnet-pre-merge
 description: .NET pre-merge testing github iggy actions
@@ -40,11 +38,6 @@ runs:
       with:
         cache-targets: false # Only cache registry and git deps, not target 
dir (sccache handles that)
 
-    - name: Install netcat
-      if: inputs.task == 'e2e'
-      run: sudo apt-get update && sudo apt-get install -y netcat-openbsd
-      shell: bash
-
     - name: Restore dependencies
       run: |
         dotnet restore foreign/csharp/Iggy_SDK.sln
@@ -77,27 +70,23 @@ runs:
 
       shell: bash
 
-    - name: Start Iggy server
-      id: iggy
+    - name: Build Iggy server Docker image
+      id: docker_build
       if: inputs.task == 'e2e'
-      uses: ./.github/actions/utils/server-start
-      continue-on-error: true
+      uses: ./.github/actions/utils/docker-build-test-server
+      with:
+        image-tag: "iggy-server:test"
+        libc: "glibc"
+        profile: "debug"
 
     - name: Run integration tests
       if: inputs.task == 'e2e'
       env:
-        IGGY_SERVER_HOST: 127.0.0.1
+        IGGY_SERVER_DOCKER_IMAGE: ${{ steps.docker_build.outputs.docker_image 
}}
       run: |
         dotnet test foreign/csharp/Iggy_SDK.Tests.Integration --no-build 
--verbosity normal
       shell: bash
 
-    - name: Stop Iggy server
-      if: inputs.task == 'e2e'
-      uses: ./.github/actions/utils/server-stop
-      with:
-        pid-file: ${{ steps.iggy.outputs.pid_file }}
-        log-file: ${{ steps.iggy.outputs.log_file }}
-
     - name: Build Release
       if: inputs.task == 'build'
       run: |
diff --git a/.github/actions/utils/docker-build-test-server/action.yml 
b/.github/actions/utils/docker-build-test-server/action.yml
new file mode 100644
index 000000000..a82ae762a
--- /dev/null
+++ b/.github/actions/utils/docker-build-test-server/action.yml
@@ -0,0 +1,105 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+
+name: docker-build-test-server
+description: Build Iggy server binaries and Docker image with prebuilt binaries
+inputs:
+  dockerfile:
+    description: "Path to Dockerfile"
+    required: false
+    default: "core/server/Dockerfile"
+  dockerfile-target:
+    description: "Dockerfile target stage (e.g., runtime-prebuilt)"
+    required: false
+    default: "runtime-prebuilt"
+  image-tag:
+    description: "Docker image tag (without registry/name)"
+    required: false
+    default: "iggy-server:test"
+  docker-build-context:
+    description: "Docker build context"
+    required: false
+    default: "."
+  prebuilt-server-binary:
+    description: "Path to prebuilt iggy-server binary"
+    required: false
+    default: "target/debug/iggy-server"
+  prebuilt-cli-binary:
+    description: "Path to prebuilt iggy CLI binary"
+    required: false
+    default: "target/debug/iggy"
+  libc:
+    description: "Libc type (musl or glibc)"
+    required: false
+    default: "glibc"
+  profile:
+    description: "Build profile (debug or release)"
+    required: false
+    default: "debug"
+outputs:
+  docker_image:
+    description: "Full Docker image name built"
+    value: ${{ steps.build.outputs.image_tag }}
+runs:
+  using: "composite"
+  steps:
+    - name: Build Iggy server and CLI binaries
+      shell: bash
+      run: |
+        echo "Building Iggy server and CLI binaries with cargo..."
+        cargo build --locked --bin iggy-server --bin iggy
+
+        echo "✅ Binaries built successfully:"
+        ls -lh ${{ inputs.prebuilt-server-binary }} ${{ 
inputs.prebuilt-cli-binary }}
+
+    - name: Build Docker image with prebuilt binaries
+      id: build
+      shell: bash
+      run: |
+        set -euo pipefail
+        DOCKERFILE="${{ inputs.dockerfile }}"
+        DOCKERFILE_TARGET="${{ inputs.dockerfile-target }}"
+        IMAGE_TAG="${{ inputs.image-tag }}"
+        BUILD_CONTEXT="${{ inputs.docker-build-context }}"
+        PREBUILT_IGGY_SERVER="${{ inputs.prebuilt-server-binary }}"
+        PREBUILT_IGGY_CLI="${{ inputs.prebuilt-cli-binary }}"
+        LIBC="${{ inputs.libc }}"
+        PROFILE="${{ inputs.profile }}"
+
+        echo "Building Docker image with prebuilt binaries..."
+        echo "  Dockerfile: $DOCKERFILE"
+        echo "  Target: $DOCKERFILE_TARGET"
+        echo "  Image Tag: $IMAGE_TAG"
+        echo "  Context: $BUILD_CONTEXT"
+        echo "  Server Binary: $PREBUILT_IGGY_SERVER"
+        echo "  CLI Binary: $PREBUILT_IGGY_CLI"
+        echo "  Libc: $LIBC"
+        echo "  Profile: $PROFILE"
+
+        docker build \
+          -f "$DOCKERFILE" \
+          --target "$DOCKERFILE_TARGET" \
+          -t "$IMAGE_TAG" \
+          --build-arg PREBUILT_IGGY_SERVER="$PREBUILT_IGGY_SERVER" \
+          --build-arg PREBUILT_IGGY_CLI="$PREBUILT_IGGY_CLI" \
+          --build-arg LIBC="$LIBC" \
+          --build-arg PROFILE="$PROFILE" \
+          "$BUILD_CONTEXT"
+
+        echo "image_tag=$IMAGE_TAG" >> "$GITHUB_OUTPUT"
+        echo "✅ Docker image built successfully: $IMAGE_TAG"
diff --git 
a/foreign/csharp/Iggy_SDK.Tests.Integration/Fixtures/IggyServerFixture.cs 
b/foreign/csharp/Iggy_SDK.Tests.Integration/Fixtures/IggyServerFixture.cs
index a67b66493..bc81d3087 100644
--- a/foreign/csharp/Iggy_SDK.Tests.Integration/Fixtures/IggyServerFixture.cs
+++ b/foreign/csharp/Iggy_SDK.Tests.Integration/Fixtures/IggyServerFixture.cs
@@ -1,74 +1,109 @@
-// // Licensed to the Apache Software Foundation (ASF) under one
-// // or more contributor license agreements.  See the NOTICE file
-// // distributed with this work for additional information
-// // regarding copyright ownership.  The ASF licenses this file
-// // to you under the Apache License, Version 2.0 (the
-// // "License"); you may not use this file except in compliance
-// // with the License.  You may obtain a copy of the License at
-// //
-// //   http://www.apache.org/licenses/LICENSE-2.0
-// //
-// // Unless required by applicable law or agreed to in writing,
-// // software distributed under the License is distributed on an
-// // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-// // KIND, either express or implied.  See the License for the
-// // specific language governing permissions and limitations
-// // under the License.
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
 
 using Apache.Iggy.Configuration;
 using Apache.Iggy.Enums;
 using Apache.Iggy.Factory;
 using Apache.Iggy.IggyClient;
+using Apache.Iggy.Tests.Integrations.Helpers;
 using DotNet.Testcontainers.Builders;
 using DotNet.Testcontainers.Containers;
 using TUnit.Core.Interfaces;
-using TUnit.Core.Logging;
 
 namespace Apache.Iggy.Tests.Integrations.Fixtures;
 
 public class IggyServerFixture : IAsyncInitializer, IAsyncDisposable
 {
-    private readonly IContainer _iggyContainer = new 
ContainerBuilder().WithImage("apache/iggy:edge")
-        .WithPortBinding(3000, true)
-        .WithPortBinding(8090, true)
-        .WithOutputConsumer(Consume.RedirectStdoutAndStderrToConsole())
-        
.WithWaitStrategy(Wait.ForUnixContainer().UntilInternalTcpPortIsAvailable(8090))
-        .WithName($"{Guid.NewGuid()}")
-        .WithEnvironment("IGGY_ROOT_USERNAME", "iggy")
-        .WithEnvironment("IGGY_ROOT_PASSWORD", "iggy")
-        .WithEnvironment("IGGY_TCP_ADDRESS", "0.0.0.0:8090")
-        .WithEnvironment("IGGY_HTTP_ADDRESS", "0.0.0.0:3000")
-        //.WithEnvironment("IGGY_CLUSTER_ENABLED", "true")
-        // .WithEnvironment("IGGY_CLUSTER_NODES_0_NAME", "iggy-node-1")
-        // .WithEnvironment("IGGY_CLUSTER_NODES_0_ADDRESS", "127.0.0.1:8090")
-        // .WithEnvironment("IGGY_CLUSTER_NODES_1_NAME", "iggy-node-2")
-        // .WithEnvironment("IGGY_CLUSTER_NODES_1_ADDRESS", "127.0.0.1:8092")
-        // .WithEnvironment("IGGY_CLUSTER_ENABLED", "true")
-        // .WithEnvironment("IGGY_CLUSTER_ENABLED", "true")
-        // .WithEnvironment("IGGY_CLUSTER_ENABLED", "true")
-        //.WithEnvironment("IGGY_SYSTEM_LOGGING_LEVEL", "trace")
-        //.WithEnvironment("RUST_LOG", "trace")
-        .WithPrivileged(true)
-        .WithCleanUp(true)
-        .Build();
-
-    private string? _iggyServerHost;
+    protected IContainer? IggyContainer;
+
+    /// <summary>
+    ///     Docker image to use. Can be overridden via 
IGGY_SERVER_DOCKER_IMAGE environment variable
+    ///     or by subclasses. Defaults to apache/iggy:edge if not specified.
+    /// </summary>
+    private string DockerImage =>
+        Environment.GetEnvironmentVariable("IGGY_SERVER_DOCKER_IMAGE") ?? 
"apache/iggy:edge";
+
+    /// <summary>
+    ///     Environment variables for the container. Override in subclasses to 
customize.
+    /// </summary>
+    protected virtual Dictionary<string, string> EnvironmentVariables => new()
+    {
+        { "IGGY_ROOT_USERNAME", "iggy" },
+        { "IGGY_ROOT_PASSWORD", "iggy" },
+        { "IGGY_TCP_ADDRESS", "0.0.0.0:8090" },
+        { "IGGY_HTTP_ADDRESS", "0.0.0.0:3000" }
+    };
+
+    /// <summary>
+    ///     Enables iggy server trace logs.
+    /// </summary>
+    protected bool EnabledServerTraceLogs => false;
+
+    /// <summary>
+    ///     Resource mappings (volumes, etc.) for the container. Override in 
subclasses to add custom mappings.
+    /// </summary>
+    protected virtual ResourceMapping[] ResourceMappings => [];
+
+    public IggyServerFixture()
+    {
+        var builder = new ContainerBuilder()
+            .WithImage(DockerImage)
+            .WithPortBinding(3000, true)
+            .WithPortBinding(8090, true)
+            .WithOutputConsumer(Consume.RedirectStdoutAndStderrToConsole())
+            
.WithWaitStrategy(Wait.ForUnixContainer().UntilInternalTcpPortIsAvailable(8090))
+            .WithName($"{Guid.NewGuid()}")
+            .WithPrivileged(true)
+            .WithCleanUp(true);
+
+        foreach (var (key, value) in EnvironmentVariables)
+        {
+            builder = builder.WithEnvironment(key, value);
+        }
+
+        if (EnabledServerTraceLogs)
+        {
+            builder = builder
+                .WithEnvironment("IGGY_SYSTEM_LOGGING_LEVEL", "trace")
+                .WithEnvironment("RUST_LOG", "trace");
+
+        }
+
+        foreach (var mapping in ResourceMappings)
+        {
+            builder = builder.WithResourceMapping(mapping.Source, 
mapping.Destination);
+        }
+
+        IggyContainer = builder.Build();
+    }
 
     public async ValueTask DisposeAsync()
     {
-        await _iggyContainer.StopAsync();
+        if (IggyContainer == null)
+        {
+            return;
+        }
+
+        await IggyContainer.StopAsync();
     }
 
     public virtual async Task InitializeAsync()
     {
-        var logger = TestContext.Current!.GetDefaultLogger();
-        _iggyServerHost = 
Environment.GetEnvironmentVariable("IGGY_SERVER_HOST");
-
-        await logger.LogInformationAsync($"Iggy server host: 
{_iggyServerHost}");
-        if (string.IsNullOrEmpty(_iggyServerHost))
-        {
-            await _iggyContainer.StartAsync();
-        }
+        await IggyContainer!.StartAsync();
 
         await CreateTcpClient();
         await CreateHttpClient();
@@ -131,22 +166,15 @@ public class IggyServerFixture : IAsyncInitializer, 
IAsyncDisposable
         return client;
     }
 
-    public string GetIggyAddress(Protocol protocol)
+    public virtual string GetIggyAddress(Protocol protocol)
     {
-        if (string.IsNullOrEmpty(_iggyServerHost))
-        {
-            var port = protocol == Protocol.Tcp
-                ? _iggyContainer.GetMappedPublicPort(8090)
-                : _iggyContainer.GetMappedPublicPort(3000);
-
-            return protocol == Protocol.Tcp
-                ? $"127.0.0.1:{port}"
-                : $"http://127.0.0.1:{port}";;
-        }
+        var port = protocol == Protocol.Tcp
+            ? IggyContainer!.GetMappedPublicPort(8090)
+            : IggyContainer!.GetMappedPublicPort(3000);
 
         return protocol == Protocol.Tcp
-            ? $"{_iggyServerHost}:8090"
-            : $"http://{_iggyServerHost}:3000";;
+            ? $"127.0.0.1:{port}"
+            : $"http://127.0.0.1:{port}";;
     }
 
     public static IEnumerable<Func<Protocol>> ProtocolData()
diff --git a/foreign/csharp/Iggy_SDK/Configuration/TlsConfiguration.cs 
b/foreign/csharp/Iggy_SDK.Tests.Integration/Fixtures/IggyTlsServerFixture.cs
similarity index 50%
copy from foreign/csharp/Iggy_SDK/Configuration/TlsConfiguration.cs
copy to 
foreign/csharp/Iggy_SDK.Tests.Integration/Fixtures/IggyTlsServerFixture.cs
index a0adf9ebb..0d7ff109d 100644
--- a/foreign/csharp/Iggy_SDK/Configuration/TlsConfiguration.cs
+++ b/foreign/csharp/Iggy_SDK.Tests.Integration/Fixtures/IggyTlsServerFixture.cs
@@ -15,25 +15,36 @@
 // specific language governing permissions and limitations
 // under the License.
 
-namespace Apache.Iggy.Configuration;
+using Apache.Iggy.Tests.Integrations.Helpers;
+
+namespace Apache.Iggy.Tests.Integrations.Fixtures;
 
 /// <summary>
-///     TLS configuration
+///     Iggy server fixture configured with TLS enabled.
+///     Requires certificates to be mounted in the container.
 /// </summary>
-public class TlsSettings
+public class IggyTlsServerFixture : IggyServerFixture
 {
     /// <summary>
-    ///     Whether TLS is enabled.
+    ///     Environment variables with TLS configuration enabled.
     /// </summary>
-    public bool Enabled { get; set; }
+    protected override Dictionary<string, string> EnvironmentVariables => 
new(base.EnvironmentVariables)
+    {
+        { "IGGY_TCP_TLS_ENABLED", "true" },
+        { "IGGY_TCP_TLS_CERT_FILE", "/app/certs/iggy_cert.pem" },
+        { "IGGY_TCP_TLS_KEY_FILE", "/app/certs/iggy_key.pem" }
+    };
 
     /// <summary>
-    ///     The name of the server that shares ssl stream.
+    ///     Resource mappings for TLS certificates.
     /// </summary>
-    public string Hostname { get; set; } = string.Empty;
+    protected override ResourceMapping[] ResourceMappings =>
+    [
+        new("Certs", "/app/certs/")
+    ];
 
-    /// <summary>
-    ///     Whether to authenticate the client.
-    /// </summary>
-    public bool Authenticate { get; set; }
+    public override async Task InitializeAsync()
+    {
+        await IggyContainer!.StartAsync();
+    }
 }
diff --git a/foreign/csharp/Iggy_SDK/Configuration/TlsConfiguration.cs 
b/foreign/csharp/Iggy_SDK.Tests.Integration/Helpers/ResourceMapping.cs
similarity index 60%
copy from foreign/csharp/Iggy_SDK/Configuration/TlsConfiguration.cs
copy to foreign/csharp/Iggy_SDK.Tests.Integration/Helpers/ResourceMapping.cs
index a0adf9ebb..7325d8423 100644
--- a/foreign/csharp/Iggy_SDK/Configuration/TlsConfiguration.cs
+++ b/foreign/csharp/Iggy_SDK.Tests.Integration/Helpers/ResourceMapping.cs
@@ -15,25 +15,6 @@
 // specific language governing permissions and limitations
 // under the License.
 
-namespace Apache.Iggy.Configuration;
+namespace Apache.Iggy.Tests.Integrations.Helpers;
 
-/// <summary>
-///     TLS configuration
-/// </summary>
-public class TlsSettings
-{
-    /// <summary>
-    ///     Whether TLS is enabled.
-    /// </summary>
-    public bool Enabled { get; set; }
-
-    /// <summary>
-    ///     The name of the server that shares ssl stream.
-    /// </summary>
-    public string Hostname { get; set; } = string.Empty;
-
-    /// <summary>
-    ///     Whether to authenticate the client.
-    /// </summary>
-    public bool Authenticate { get; set; }
-}
+public record ResourceMapping(string Source, string Destination);
diff --git 
a/foreign/csharp/Iggy_SDK.Tests.Integration/IggyTlsConnectionTests.cs 
b/foreign/csharp/Iggy_SDK.Tests.Integration/IggyTlsConnectionTests.cs
new file mode 100644
index 000000000..ab54d354a
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK.Tests.Integration/IggyTlsConnectionTests.cs
@@ -0,0 +1,101 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+using Apache.Iggy.Configuration;
+using Apache.Iggy.Enums;
+using Apache.Iggy.Exceptions;
+using Apache.Iggy.Factory;
+using Apache.Iggy.Tests.Integrations.Fixtures;
+using Shouldly;
+
+namespace Apache.Iggy.Tests.Integrations;
+
+public class IggyTlsConnectionTests
+{
+    [ClassDataSource<IggyTlsServerFixture>(Shared = SharedType.PerAssembly)]
+    public required IggyTlsServerFixture Fixture { get; init; }
+
+    [Test]
+    public async Task Connect_WithTls_Should_Connect_Successfully()
+    {
+        using var client = IggyClientFactory.CreateClient(new 
IggyClientConfigurator
+        {
+            BaseAddress = Fixture.GetIggyAddress(Protocol.Tcp),
+            Protocol = Protocol.Tcp,
+            ReconnectionSettings = new ReconnectionSettings { Enabled = false 
},
+            AutoLoginSettings = new AutoLoginSettings
+            {
+                Enabled = true,
+                Username = "iggy",
+                Password = "iggy"
+            },
+            TlsSettings = new TlsSettings
+            {
+                Enabled = true,
+                Hostname = "localhost",
+                CertificatePath = "Certs/iggy_cert.pem"
+            }
+        });
+
+        await client.ConnectAsync();
+        var loginResult = await client.LoginUser("iggy", "iggy");
+
+        loginResult.ShouldNotBeNull();
+    }
+
+    [Test]
+    public async Task Connect_WithoutTls_Should_Throw_WhenTlsIsRequired()
+    {
+        using var client = IggyClientFactory.CreateClient(new 
IggyClientConfigurator
+        {
+            BaseAddress = Fixture.GetIggyAddress(Protocol.Tcp),
+            Protocol = Protocol.Tcp,
+            ReconnectionSettings = new ReconnectionSettings { Enabled = false }
+        });
+
+        await client.ConnectAsync();
+        await 
Should.ThrowAsync<IggyZeroBytesException>(client.LoginUser("iggy", "iggy"));
+    }
+
+    [Test]
+    public async Task Connect_WithTls_CA_Should_Connect_Successfully()
+    {
+        using var client = IggyClientFactory.CreateClient(new 
IggyClientConfigurator
+        {
+            BaseAddress = Fixture.GetIggyAddress(Protocol.Tcp),
+            Protocol = Protocol.Tcp,
+            ReconnectionSettings = new ReconnectionSettings { Enabled = false 
},
+            AutoLoginSettings = new AutoLoginSettings
+            {
+                Enabled = true,
+                Username = "iggy",
+                Password = "iggy"
+            },
+            TlsSettings = new TlsSettings
+            {
+                Enabled = true,
+                Hostname = "localhost",
+                CertificatePath = "Certs/iggy_ca_cert.pem"
+            }
+        });
+
+        await client.ConnectAsync();
+        var loginResult = await client.LoginUser("iggy", "iggy");
+
+        loginResult.ShouldNotBeNull();
+    }
+}
diff --git 
a/foreign/csharp/Iggy_SDK.Tests.Integration/Iggy_SDK.Tests.Integration.csproj 
b/foreign/csharp/Iggy_SDK.Tests.Integration/Iggy_SDK.Tests.Integration.csproj
index 2eb6524d2..24b2162d9 100644
--- 
a/foreign/csharp/Iggy_SDK.Tests.Integration/Iggy_SDK.Tests.Integration.csproj
+++ 
b/foreign/csharp/Iggy_SDK.Tests.Integration/Iggy_SDK.Tests.Integration.csproj
@@ -26,4 +26,23 @@
         <ProjectReference Include="..\Iggy_SDK\Iggy_SDK.csproj" />
     </ItemGroup>
 
+    <ItemGroup>
+        <Content Include="..\..\..\core\certs\iggy.pfx">
+            <Link>Certs\iggy.pfx</Link>
+            <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+        </Content>
+        <Content Include="..\..\..\core\certs\iggy_ca_cert.pem">
+            <Link>Certs\iggy_ca_cert.pem</Link>
+            <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+        </Content>
+        <Content Include="..\..\..\core\certs\iggy_cert.pem">
+            <Link>Certs\iggy_cert.pem</Link>
+            <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+        </Content>
+        <Content Include="..\..\..\core\certs\iggy_key.pem">
+            <Link>Certs\iggy_key.pem</Link>
+            <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+        </Content>
+    </ItemGroup>
+
 </Project>
diff --git a/foreign/csharp/Iggy_SDK/Configuration/TlsConfiguration.cs 
b/foreign/csharp/Iggy_SDK/Configuration/TlsConfiguration.cs
index a0adf9ebb..b48ee5b9a 100644
--- a/foreign/csharp/Iggy_SDK/Configuration/TlsConfiguration.cs
+++ b/foreign/csharp/Iggy_SDK/Configuration/TlsConfiguration.cs
@@ -28,12 +28,12 @@ public class TlsSettings
     public bool Enabled { get; set; }
 
     /// <summary>
-    ///     The name of the server that shares ssl stream.
+    ///     The name of the server for TLS handshake.
     /// </summary>
     public string Hostname { get; set; } = string.Empty;
 
     /// <summary>
-    ///     Whether to authenticate the client.
+    ///     Path to the certificate (ca/self-signed) file.
     /// </summary>
-    public bool Authenticate { get; set; }
+    public string CertificatePath { get; set; } = string.Empty;
 }
diff --git a/foreign/csharp/Iggy_SDK/Configuration/TlsConfiguration.cs 
b/foreign/csharp/Iggy_SDK/Exceptions/InvalidCertificatePathException.cs
similarity index 64%
copy from foreign/csharp/Iggy_SDK/Configuration/TlsConfiguration.cs
copy to foreign/csharp/Iggy_SDK/Exceptions/InvalidCertificatePathException.cs
index a0adf9ebb..b79e15fe1 100644
--- a/foreign/csharp/Iggy_SDK/Configuration/TlsConfiguration.cs
+++ b/foreign/csharp/Iggy_SDK/Exceptions/InvalidCertificatePathException.cs
@@ -15,25 +15,20 @@
 // specific language governing permissions and limitations
 // under the License.
 
-namespace Apache.Iggy.Configuration;
+namespace Apache.Iggy.Exceptions;
 
 /// <summary>
-///     TLS configuration
+///     Exception thrown when the provided TLS certificate path is invalid.
 /// </summary>
-public class TlsSettings
+public class InvalidCertificatePathException : Exception
 {
     /// <summary>
-    ///     Whether TLS is enabled.
+    ///     Initializes a new instance of the <see 
cref="InvalidCertificatePathException" /> class.
     /// </summary>
-    public bool Enabled { get; set; }
+    /// <param name="tlsCertificatePath">Certificate path</param>
+    public InvalidCertificatePathException(string tlsCertificatePath) : base(
+        $"Invalid TLS certificate path: {tlsCertificatePath}")
 
-    /// <summary>
-    ///     The name of the server that shares ssl stream.
-    /// </summary>
-    public string Hostname { get; set; } = string.Empty;
-
-    /// <summary>
-    ///     Whether to authenticate the client.
-    /// </summary>
-    public bool Authenticate { get; set; }
+    {
+    }
 }
diff --git 
a/foreign/csharp/Iggy_SDK/IggyClient/Implementations/TcpMessageStream.cs 
b/foreign/csharp/Iggy_SDK/IggyClient/Implementations/TcpMessageStream.cs
index 2dc06daa7..4ca8c1351 100644
--- a/foreign/csharp/Iggy_SDK/IggyClient/Implementations/TcpMessageStream.cs
+++ b/foreign/csharp/Iggy_SDK/IggyClient/Implementations/TcpMessageStream.cs
@@ -19,6 +19,7 @@ using System.Buffers;
 using System.Buffers.Binary;
 using System.Net.Security;
 using System.Net.Sockets;
+using System.Security.Cryptography.X509Certificates;
 using System.Text;
 using Apache.Iggy.Configuration;
 using Apache.Iggy.ConnectionStream;
@@ -47,6 +48,7 @@ public sealed class TcpMessageStream : IIggyClient
     private readonly ILogger<TcpMessageStream> _logger;
     private readonly SemaphoreSlim _sendingSemaphore;
     private ClusterNode? _currentLeaderNode;
+    private X509Certificate2Collection _customCaStore = [];
     private bool _isConnecting;
     private DateTimeOffset _lastConnectionTime;
     private ConnectionState _state = ConnectionState.Disconnected;
@@ -823,7 +825,7 @@ public sealed class TcpMessageStream : IIggyClient
 
                 _stream = _configuration.TlsSettings.Enabled switch
                 {
-                    true => CreateSslStreamAndAuthenticate(socket, 
_configuration.TlsSettings),
+                    true => await CreateSslStreamAndAuthenticate(socket, 
_configuration.TlsSettings),
                     false => new TcpConnectionStream(new NetworkStream(socket, 
true))
                 };
 
@@ -911,14 +913,16 @@ public sealed class TcpMessageStream : IIggyClient
         }
     }
 
-    private static TcpConnectionStream CreateSslStreamAndAuthenticate(Socket 
socket, TlsSettings tlsSettings)
+    private async Task<TcpConnectionStream> 
CreateSslStreamAndAuthenticate(Socket socket, TlsSettings tlsSettings)
     {
+        ValidateCertificatePath(tlsSettings.CertificatePath);
+
+        _customCaStore = new X509Certificate2Collection();
+        _customCaStore.ImportFromPemFile(tlsSettings.CertificatePath);
         var stream = new NetworkStream(socket, true);
-        var sslStream = new SslStream(stream);
-        if (tlsSettings.Authenticate)
-        {
-            sslStream.AuthenticateAsClient(tlsSettings.Hostname);
-        }
+        var sslStream = new SslStream(stream, false, 
RemoteCertificateValidationCallback);
+
+        await sslStream.AuthenticateAsClientAsync(tlsSettings.Hostname);
 
         return new TcpConnectionStream(sslStream);
     }
@@ -1095,4 +1099,76 @@ public sealed class TcpMessageStream : IIggyClient
         _logger.LogInformation("Connection state changed: {PreviousState} -> 
{CurrentState}", previousState, newState);
         _connectionEvents.Publish(new 
ConnectionStateChangedEventArgs(previousState, newState));
     }
+
+    private void ValidateCertificatePath(string tlsCertificatePath)
+    {
+        if (string.IsNullOrEmpty(tlsCertificatePath)
+            || !File.Exists(tlsCertificatePath))
+        {
+            throw new InvalidCertificatePathException(tlsCertificatePath);
+        }
+    }
+
+    private bool RemoteCertificateValidationCallback(object sender, 
X509Certificate? certificate, X509Chain? chain,
+        SslPolicyErrors sslPolicyErrors)
+    {
+        if (sslPolicyErrors == SslPolicyErrors.None)
+        {
+            return true;
+        }
+
+        if (certificate is null)
+        {
+            return false;
+        }
+
+        if (certificate is not X509Certificate2 serverCert)
+        {
+            serverCert = new X509Certificate2(certificate);
+        }
+
+        if (_customCaStore.Any(ca => ca.Thumbprint == serverCert.Thumbprint))
+        {
+            if (DateTime.UtcNow <= serverCert.NotAfter && DateTime.UtcNow >= 
serverCert.NotBefore)
+            {
+                return true;
+            }
+
+            _logger.LogError(
+                "Server certificate matches trusted key but is expired. Valid 
from {NotBefore} to {NotAfter}",
+                serverCert.NotBefore, serverCert.NotAfter);
+            return false;
+        }
+
+
+        using var customChain = new X509Chain();
+        customChain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
+        customChain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
+        foreach (var ca in _customCaStore)
+        {
+            customChain.ChainPolicy.CustomTrustStore.Add(ca);
+            customChain.ChainPolicy.ExtraStore.Add(ca);
+        }
+
+        customChain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
+
+        if (customChain.Build(new X509Certificate2(certificate)))
+        {
+            if 
(!sslPolicyErrors.HasFlag(SslPolicyErrors.RemoteCertificateNameMismatch))
+            {
+                return true;
+            }
+
+            _logger.LogError("Custom CA chain is valid, but hostname does not 
match");
+            return false;
+        }
+
+        foreach (var chainStatus in customChain.ChainStatus)
+        {
+            _logger.LogWarning("Certificate validation failed: {ChainStatus} - 
{StatusInformation}", chainStatus.Status,
+                chainStatus.StatusInformation);
+        }
+
+        return false;
+    }
 }
diff --git a/foreign/csharp/README.md b/foreign/csharp/README.md
index 6e4a76228..48fdb1205 100644
--- a/foreign/csharp/README.md
+++ b/foreign/csharp/README.md
@@ -71,7 +71,7 @@ var client = IggyClientFactory.CreateClient(new 
IggyClientConfigurator
     {
         Enabled = true,
         Hostname = "iggy",
-        Authenticate = true
+        CertificatePath = "/path/to/cert"
     },
 
     // Automatic reconnection with exponential backoff

Reply via email to