diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index e0c0049..60bae6c 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -15,14 +15,21 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Setup .NET - uses: actions/setup-dotnet@v3 + uses: actions/setup-dotnet@v4 with: - dotnet-version: 9.0.x + dotnet-version: 10.0.x + - name: Write test appsettings + env: + APPSETTINGS: ${{ secrets.APPSETTINGS }} + run: | + if [ -n "$APPSETTINGS" ]; then + echo "$APPSETTINGS" > HybridConnectionClientProxy.Tests/appsettings.Test.json + fi - name: Restore dependencies - run: dotnet restore + run: dotnet restore HybridConnectionClientProxy.slnx - name: Build - run: dotnet build --no-restore + run: dotnet build HybridConnectionClientProxy.slnx --no-restore - name: Test - run: dotnet test --no-build --verbosity normal + run: dotnet test HybridConnectionClientProxy.slnx --no-restore --verbosity normal diff --git a/HybridConnectionClientProxy.Tests/HybridConnectionClientProxy.Tests.csproj b/HybridConnectionClientProxy.Tests/HybridConnectionClientProxy.Tests.csproj new file mode 100644 index 0000000..c211636 --- /dev/null +++ b/HybridConnectionClientProxy.Tests/HybridConnectionClientProxy.Tests.csproj @@ -0,0 +1,34 @@ + + + + net10.0 + enable + enable + false + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + PreserveNewest + + + + diff --git a/HybridConnectionClientProxy.Tests/src/AzureIntegrationTests.cs b/HybridConnectionClientProxy.Tests/src/AzureIntegrationTests.cs new file mode 100644 index 0000000..ab907cb --- /dev/null +++ b/HybridConnectionClientProxy.Tests/src/AzureIntegrationTests.cs @@ -0,0 +1,158 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Text.Json; + +using Xunit; + +namespace HybridConnectionClientProxy.Tests; + +/// +/// Integration tests that run against a real Azure Hybrid Connection endpoint. +/// +/// These tests require appsettings.Test.json to be present alongside the test +/// assembly, containing valid Azure Relay credentials in the same format as +/// appsettings.json. When the file is absent (fork PRs, local runs without +/// credentials) every test is skipped automatically. +/// +/// The endpoint referenced by the first proxy in the config must have a listener +/// registered and running for data-flow assertions to pass. +/// +public class AzureIntegrationTests +{ + private const int TimeoutMs = 30_000; + + // ------------------------------------------------------------------------- + // Tests + // ------------------------------------------------------------------------- + + [Fact] + public async Task AzureProxy_WhenClientConnects_ConnectionEstablishedWithoutError() + { + var connStr = GetConnectionString(); + if( connStr is null ) Assert.Skip( "No Azure credentials in appsettings.Test.json" ); + + using var cts = new CancellationTokenSource( TimeoutMs ); + + var provider = new HybridConnectionClientProvider( connStr! ); + var (proxyListener, proxyPort) = StartListener(); + var proxyTask = ClientProxy.Create( provider, proxyListener, cts.Token ); + + using var client = new TcpClient(); + await client.ConnectAsync( IPAddress.Loopback, proxyPort, cts.Token ); + + // Give the relay a moment to confirm the connection is live + await Task.Delay( 1_000, cts.Token ); + + await cts.CancelAsync(); + proxyListener.Stop(); + await proxyTask; + } + + [Fact] + public async Task AzureProxy_WhenClientDisconnects_CleansUpPromptly() + { + var connStr = GetConnectionString(); + if( connStr is null ) Assert.Skip( "No Azure credentials in appsettings.Test.json" ); + + using var cts = new CancellationTokenSource( TimeoutMs ); + + var provider = new HybridConnectionClientProvider( connStr! ); + var (proxyListener, proxyPort) = StartListener(); + var proxyTask = ClientProxy.Create( provider, proxyListener, cts.Token ); + + var client = new TcpClient(); + await client.ConnectAsync( IPAddress.Loopback, proxyPort, cts.Token ); + await Task.Delay( 500, cts.Token ); // let the relay connection settle + + // Disconnect the client — this is the scenario that used to hang + client.Close(); + + // The proxy should clean up within a few seconds; if it hangs the test + // times out and fails, confirming the regression is present. + await Task.Delay( 3_000, cts.Token ); + + await cts.CancelAsync(); + proxyListener.Stop(); + await proxyTask; + } + + [Fact] + public async Task AzureProxy_SmtpEhlo_ServerResponds250() + { + var connStr = GetConnectionString(); + if( connStr is null ) Assert.Skip( "No Azure credentials in appsettings.Test.json" ); + + using var cts = new CancellationTokenSource( TimeoutMs ); + + var provider = new HybridConnectionClientProvider( connStr! ); + var (proxyListener, proxyPort) = StartListener(); + _ = ClientProxy.Create( provider, proxyListener, cts.Token ); + + using var client = new TcpClient(); + await client.ConnectAsync( IPAddress.Loopback, proxyPort, cts.Token ); + using var reader = new StreamReader( client.GetStream(), Encoding.ASCII, leaveOpen: true ); + using var writer = new StreamWriter( client.GetStream(), Encoding.ASCII, leaveOpen: true ) { NewLine = "\r\n", AutoFlush = true }; + + // Server must send a 220 greeting + var greeting = await ReadSmtpResponseAsync( reader, cts.Token ); + Assert.StartsWith( "220", greeting ); + + // Send EHLO and expect 250 + await writer.WriteLineAsync( "EHLO test" ); + var response = await ReadSmtpResponseAsync( reader, cts.Token ); + Assert.StartsWith( "250", response ); + + // Quit cleanly + await writer.WriteLineAsync( "QUIT" ); + await ReadSmtpResponseAsync( reader, cts.Token ); + + await cts.CancelAsync(); + proxyListener.Stop(); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private static string? GetConnectionString() + { + var path = Path.Combine( AppContext.BaseDirectory, "appsettings.Test.json" ); + if( !File.Exists( path ) ) return null; + + try + { + using var doc = JsonDocument.Parse( File.ReadAllText( path ) ); + var proxies = doc.RootElement + .GetProperty( "AppSettings" ) + .GetProperty( "Proxies" ); + if( proxies.GetArrayLength() == 0 ) return null; + return proxies[0].GetProperty( "HybridConnectionString" ).GetString(); + } + catch { return null; } + } + + private static (TcpListener listener, int port) StartListener() + { + var listener = new TcpListener( IPAddress.Loopback, 0 ); + listener.Start(); + return (listener, ((IPEndPoint) listener.LocalEndpoint).Port); + } + + /// + /// Reads a (possibly multi-line) SMTP response and returns the final line. + /// Multi-line responses use "NNN-text" for continuation lines and "NNN text" for the last. + /// + private static async Task ReadSmtpResponseAsync( StreamReader reader, CancellationToken ct ) + { + string? line; + do + { + line = await reader.ReadLineAsync( ct ); + if( line is null ) throw new EndOfStreamException( "SMTP server closed the connection." ); + } + while( line.Length >= 4 && line[3] == '-' ); // continuation line + + return line; + } +} diff --git a/HybridConnectionClientProxy.Tests/src/ClientProxyIntegrationTests.cs b/HybridConnectionClientProxy.Tests/src/ClientProxyIntegrationTests.cs new file mode 100644 index 0000000..6e05b86 --- /dev/null +++ b/HybridConnectionClientProxy.Tests/src/ClientProxyIntegrationTests.cs @@ -0,0 +1,250 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; + +using Xunit; + +namespace HybridConnectionClientProxy.Tests; + +/// +/// End-to-end integration tests for the proxy pipeline using loopback TCP connections. +/// +/// Topology: +/// [Test client] --TCP--> [ClientProxy listener] --[TcpHybridConnectionProvider]--> [Backend server] +/// +/// No Azure credentials are required; the TcpHybridConnectionProvider forwards each connection +/// to a local TCP server that plays the role of the on-premises backend. +/// +public class ClientProxyIntegrationTests +{ + private const int TestTimeoutMs = 10_000; + + /// + /// Binds to port 0, starts the listener, and returns it together with the OS-assigned port. + /// + private static (TcpListener listener, int port) StartListener() + { + var listener = new TcpListener( IPAddress.Loopback, 0 ); + listener.Start(); + var port = ( (IPEndPoint) listener.LocalEndpoint ).Port; + return (listener, port); + } + + [Fact] + public async Task Proxy_ClientToBackend_DataForwardedCorrectly() + { + using var cts = new CancellationTokenSource( TestTimeoutMs ); + + // Backend — simulates on-premises server + var (backendListener, backendPort) = StartListener(); + + // Proxy — listens for client connections, forwards via mock provider to backend + var (proxyListener, proxyPort) = StartListener(); + var mockProvider = new TcpHybridConnectionProvider( backendPort ); + var proxyTask = ClientProxy.Create( mockProvider, proxyListener, cts.Token ); + + // Connect client to proxy + using var clientTcp = new TcpClient(); + await clientTcp.ConnectAsync( IPAddress.Loopback, proxyPort, cts.Token ); + var clientStream = clientTcp.GetStream(); + + // Accept the corresponding backend connection + using var backendTcp = await backendListener.AcceptTcpClientAsync( cts.Token ); + var backendStream = backendTcp.GetStream(); + + // Send data client → proxy → backend + var sent = "hello from client"u8.ToArray(); + await clientStream.WriteAsync( sent, cts.Token ); + await clientStream.FlushAsync( cts.Token ); + + var received = new byte[sent.Length]; + await ReadExactAsync( backendStream, received, cts.Token ); + + Assert.Equal( sent, received ); + + await cts.CancelAsync(); + backendListener.Stop(); + proxyListener.Stop(); + await proxyTask; + } + + [Fact] + public async Task Proxy_BackendToClient_DataForwardedCorrectly() + { + using var cts = new CancellationTokenSource( TestTimeoutMs ); + + var (backendListener, backendPort) = StartListener(); + var (proxyListener, proxyPort) = StartListener(); + + var mockProvider = new TcpHybridConnectionProvider( backendPort ); + var proxyTask = ClientProxy.Create( mockProvider, proxyListener, cts.Token ); + + using var clientTcp = new TcpClient(); + await clientTcp.ConnectAsync( IPAddress.Loopback, proxyPort, cts.Token ); + var clientStream = clientTcp.GetStream(); + + using var backendTcp = await backendListener.AcceptTcpClientAsync( cts.Token ); + var backendStream = backendTcp.GetStream(); + + // Send data backend → proxy → client + var sent = "hello from backend"u8.ToArray(); + await backendStream.WriteAsync( sent, cts.Token ); + await backendStream.FlushAsync( cts.Token ); + + var received = new byte[sent.Length]; + await ReadExactAsync( clientStream, received, cts.Token ); + + Assert.Equal( sent, received ); + + await cts.CancelAsync(); + backendListener.Stop(); + proxyListener.Stop(); + await proxyTask; + } + + [Fact] + public async Task Proxy_Bidirectional_BothDirectionsWork() + { + using var cts = new CancellationTokenSource( TestTimeoutMs ); + + var (backendListener, backendPort) = StartListener(); + var (proxyListener, proxyPort) = StartListener(); + + var mockProvider = new TcpHybridConnectionProvider( backendPort ); + var proxyTask = ClientProxy.Create( mockProvider, proxyListener, cts.Token ); + + using var clientTcp = new TcpClient(); + await clientTcp.ConnectAsync( IPAddress.Loopback, proxyPort, cts.Token ); + var clientStream = clientTcp.GetStream(); + + using var backendTcp = await backendListener.AcceptTcpClientAsync( cts.Token ); + var backendStream = backendTcp.GetStream(); + + // Client → Backend + var request = "REQUEST"u8.ToArray(); + await clientStream.WriteAsync( request, cts.Token ); + await clientStream.FlushAsync( cts.Token ); + + var requestReceived = new byte[request.Length]; + await ReadExactAsync( backendStream, requestReceived, cts.Token ); + Assert.Equal( request, requestReceived ); + + // Backend → Client + var response = "RESPONSE"u8.ToArray(); + await backendStream.WriteAsync( response, cts.Token ); + await backendStream.FlushAsync( cts.Token ); + + var responseReceived = new byte[response.Length]; + await ReadExactAsync( clientStream, responseReceived, cts.Token ); + Assert.Equal( response, responseReceived ); + + await cts.CancelAsync(); + backendListener.Stop(); + proxyListener.Stop(); + await proxyTask; + } + + [Fact] + public async Task Proxy_MultipleSequentialConnections_AllForwardedCorrectly() + { + using var cts = new CancellationTokenSource( TestTimeoutMs ); + + var (backendListener, backendPort) = StartListener(); + var (proxyListener, proxyPort) = StartListener(); + + var mockProvider = new TcpHybridConnectionProvider( backendPort ); + var proxyTask = ClientProxy.Create( mockProvider, proxyListener, cts.Token ); + + for( int i = 0; i < 3; i++ ) + { + using var clientTcp = new TcpClient(); + await clientTcp.ConnectAsync( IPAddress.Loopback, proxyPort, cts.Token ); + var clientStream = clientTcp.GetStream(); + + using var backendTcp = await backendListener.AcceptTcpClientAsync( cts.Token ); + var backendStream = backendTcp.GetStream(); + + var message = Encoding.UTF8.GetBytes( $"message-{i}" ); + await clientStream.WriteAsync( message, cts.Token ); + await clientStream.FlushAsync( cts.Token ); + + var buffer = new byte[message.Length]; + await ReadExactAsync( backendStream, buffer, cts.Token ); + Assert.Equal( message, buffer ); + } + + await cts.CancelAsync(); + backendListener.Stop(); + proxyListener.Stop(); + await proxyTask; + } + + [Fact] + public async Task Proxy_WhenClientDisconnects_ConnectionCleanedUpGracefully() + { + using var cts = new CancellationTokenSource( TestTimeoutMs ); + + var (backendListener, backendPort) = StartListener(); + var (proxyListener, proxyPort) = StartListener(); + + var mockProvider = new TcpHybridConnectionProvider( backendPort ); + var proxyTask = ClientProxy.Create( mockProvider, proxyListener, cts.Token ); + + var clientTcp = new TcpClient(); + await clientTcp.ConnectAsync( IPAddress.Loopback, proxyPort, cts.Token ); + + using var backendTcp = await backendListener.AcceptTcpClientAsync( cts.Token ); + var backendStream = backendTcp.GetStream(); + + // Close client — backend should observe EOF without throwing + clientTcp.Close(); + + // Backend should receive EOF (ReadAsync returns 0) without hanging + var readTask = backendStream.ReadAsync( new byte[1], cts.Token ).AsTask(); + var completed = await Task.WhenAny( readTask, Task.Delay( 3_000, cts.Token ) ); + Assert.Equal( readTask, completed ); + Assert.Equal( 0, await readTask ); + + await cts.CancelAsync(); + backendListener.Stop(); + proxyListener.Stop(); + await proxyTask; + } + + [Fact] + public async Task Proxy_WhenCancelled_StopsAcceptingNewConnections() + { + using var cts = new CancellationTokenSource( TestTimeoutMs ); + + var (proxyListener, proxyPort) = StartListener(); + + // Backend is never used here — we just want to confirm the proxy stops + var (backendListener, backendPort) = StartListener(); + var mockProvider = new TcpHybridConnectionProvider( backendPort ); + var proxyTask = ClientProxy.Create( mockProvider, proxyListener, cts.Token ); + + // Cancel immediately + await cts.CancelAsync(); + proxyListener.Stop(); + backendListener.Stop(); + + // Proxy task should complete cleanly (not throw) + await proxyTask; + } + + /// + /// Reads exactly .Length bytes, retrying partial reads as needed. + /// This mirrors real network behaviour where data may arrive in multiple TCP segments. + /// + private static async Task ReadExactAsync( Stream stream, byte[] buffer, CancellationToken ct ) + { + int totalRead = 0; + while( totalRead < buffer.Length ) + { + int n = await stream.ReadAsync( buffer.AsMemory( totalRead ), ct ); + if( n == 0 ) + throw new EndOfStreamException( $"Stream ended after {totalRead}/{buffer.Length} bytes." ); + totalRead += n; + } + } +} diff --git a/HybridConnectionClientProxy.Tests/src/ConfigurationLocatorTests.cs b/HybridConnectionClientProxy.Tests/src/ConfigurationLocatorTests.cs new file mode 100644 index 0000000..ea6ccc6 --- /dev/null +++ b/HybridConnectionClientProxy.Tests/src/ConfigurationLocatorTests.cs @@ -0,0 +1,47 @@ +using Xunit; + +namespace HybridConnectionClientProxy.Tests; + +public class ConfigurationLocatorTests +{ + [Fact] + public void DefaultsFile_IsLowercaseAppsettingsJson() + { + // Verifies the case-safe lowercase filename convention (capital S caused failures on Linux) + Assert.Equal( "appsettings.json", ConfigurationLocator.DefaultsFile ); + } + + [Fact] + public void OverlayFileName_ContainsMachineName() + { + var fileName = ConfigurationLocator.OverlayFileName; + Assert.Contains( Environment.MachineName, fileName ); + } + + [Fact] + public void OverlayFileName_HasJsonExtension() + { + Assert.EndsWith( ".json", ConfigurationLocator.OverlayFileName ); + } + + [Fact] + public void OverlayFileName_StartsWithAppsettingsDot() + { + Assert.StartsWith( "appsettings.", ConfigurationLocator.OverlayFileName ); + } + + [Fact] + public void OverlayFilePath_EndsWithConfig() + { + var path = ConfigurationLocator.OverlayFilePath; + var leaf = Path.GetFileName( path ); + Assert.Equal( "config", leaf ); + } + + [Fact] + public void OverlayFile_CombinesOverlayFilePathAndOverlayFileName() + { + var expected = Path.Combine( ConfigurationLocator.OverlayFilePath, ConfigurationLocator.OverlayFileName ); + Assert.Equal( expected, ConfigurationLocator.OverlayFile ); + } +} diff --git a/HybridConnectionClientProxy.Tests/src/Settings/ProxySettingsTests.cs b/HybridConnectionClientProxy.Tests/src/Settings/ProxySettingsTests.cs new file mode 100644 index 0000000..c9bc494 --- /dev/null +++ b/HybridConnectionClientProxy.Tests/src/Settings/ProxySettingsTests.cs @@ -0,0 +1,72 @@ +using System.Net; + +using HybridConnectionClientProxy.Settings; +using Xunit; + +namespace HybridConnectionClientProxy.Tests.Settings; + +public class ProxySettingsTests +{ + [Fact] + public void ListenAddress_WhenListenIPAddressIsNull_ReturnsLoopback() + { + var proxy = new Proxy { ListenIPAddress = null }; + Assert.Equal( IPAddress.Loopback, proxy.ListenAddress ); + } + + [Fact] + public void ListenAddress_WhenListenIPAddressIsEmpty_ReturnsLoopback() + { + var proxy = new Proxy { ListenIPAddress = string.Empty }; + Assert.Equal( IPAddress.Loopback, proxy.ListenAddress ); + } + + [Fact] + public void ListenAddress_WhenListenIPAddressIsInvalid_ReturnsLoopback() + { + var proxy = new Proxy { ListenIPAddress = "not-an-ip" }; + Assert.Equal( IPAddress.Loopback, proxy.ListenAddress ); + } + + [Fact] + public void ListenAddress_WhenListenIPAddressIsLoopback_ReturnsLoopback() + { + var proxy = new Proxy { ListenIPAddress = "127.0.0.1" }; + Assert.Equal( IPAddress.Loopback, proxy.ListenAddress ); + } + + [Fact] + public void ListenAddress_WhenListenIPAddressIsAny_ReturnsAny() + { + var proxy = new Proxy { ListenIPAddress = "0.0.0.0" }; + Assert.Equal( IPAddress.Any, proxy.ListenAddress ); + } + + [Fact] + public void ListenAddress_WhenListenIPAddressIsIPv6Loopback_ReturnsIPv6Loopback() + { + var proxy = new Proxy { ListenIPAddress = "::1" }; + Assert.Equal( IPAddress.IPv6Loopback, proxy.ListenAddress ); + } + + [Fact] + public void ListenAddress_WhenListenIPAddressIsArbitraryIPv4_ReturnsCorrectAddress() + { + var proxy = new Proxy { ListenIPAddress = "192.168.1.100" }; + Assert.Equal( IPAddress.Parse( "192.168.1.100" ), proxy.ListenAddress ); + } + + [Fact] + public void Proxy_DefaultListenPort_IsZero() + { + var proxy = new Proxy(); + Assert.Equal( 0, proxy.ListenPort ); + } + + [Fact] + public void Proxy_DefaultProxies_IsEmptyArray() + { + var settings = new AppSettings(); + Assert.Empty( settings.Proxies ); + } +} diff --git a/HybridConnectionClientProxy.Tests/src/TcpHybridConnectionProvider.cs b/HybridConnectionClientProxy.Tests/src/TcpHybridConnectionProvider.cs new file mode 100644 index 0000000..1c8fd60 --- /dev/null +++ b/HybridConnectionClientProxy.Tests/src/TcpHybridConnectionProvider.cs @@ -0,0 +1,28 @@ +using System.Net; +using System.Net.Sockets; + +using HybridConnectionClientProxy; + +namespace HybridConnectionClientProxy.Tests; + +/// +/// Test double for that connects to a local TCP server +/// instead of Azure Service Bus. This lets integration tests verify the full proxy pipeline +/// without requiring live Azure credentials. +/// +internal sealed class TcpHybridConnectionProvider : IHybridConnectionProvider +{ + private readonly int _targetPort; + + public TcpHybridConnectionProvider( int targetPort ) + { + _targetPort = targetPort; + } + + public async Task CreateConnectionAsync( CancellationToken cancellationToken = default ) + { + var client = new TcpClient(); + await client.ConnectAsync( IPAddress.Loopback, _targetPort, cancellationToken ); + return client.GetStream(); + } +} diff --git a/HybridConnectionClientProxy.sln b/HybridConnectionClientProxy.sln deleted file mode 100644 index 0212d68..0000000 --- a/HybridConnectionClientProxy.sln +++ /dev/null @@ -1,25 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.8.34330.188 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HybridConnectionClientProxy", "HybridConnectionClientProxy.csproj", "{5129B416-0657-4D5E-AEEB-186E536E6DCC}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {5129B416-0657-4D5E-AEEB-186E536E6DCC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {5129B416-0657-4D5E-AEEB-186E536E6DCC}.Debug|Any CPU.Build.0 = Debug|Any CPU - {5129B416-0657-4D5E-AEEB-186E536E6DCC}.Release|Any CPU.ActiveCfg = Release|Any CPU - {5129B416-0657-4D5E-AEEB-186E536E6DCC}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {26499249-4A8E-4641-93E2-B6803FF3DAA4} - EndGlobalSection -EndGlobal diff --git a/HybridConnectionClientProxy.slnx b/HybridConnectionClientProxy.slnx new file mode 100644 index 0000000..42abd8c --- /dev/null +++ b/HybridConnectionClientProxy.slnx @@ -0,0 +1,4 @@ + + + + diff --git a/HybridConnectionClientProxy.csproj b/HybridConnectionClientProxy/HybridConnectionClientProxy.csproj similarity index 57% rename from HybridConnectionClientProxy.csproj rename to HybridConnectionClientProxy/HybridConnectionClientProxy.csproj index 10e10e1..9f472a5 100644 --- a/HybridConnectionClientProxy.csproj +++ b/HybridConnectionClientProxy/HybridConnectionClientProxy.csproj @@ -1,43 +1,50 @@ - + Exe - net9.0 + net10.0 enable enable eb380b7a-0bcd-4399-84ef-0d994ab63de3 + - + + <_Parameter1>HybridConnectionClientProxy.Tests + - - - - - - - - - - - - + + + + + + + + + + + + + + + + - + - + PreserveNewest - + diff --git a/appSettings.json b/HybridConnectionClientProxy/appsettings.json similarity index 93% rename from appSettings.json rename to HybridConnectionClientProxy/appsettings.json index d996b03..971d840 100644 --- a/appSettings.json +++ b/HybridConnectionClientProxy/appsettings.json @@ -27,5 +27,8 @@ "Properties": { "Application": "HybridConnectionClientProxy" } + }, + "AppSettings": { + "Proxies": [] } } \ No newline at end of file diff --git a/config/appsettings.template.json b/HybridConnectionClientProxy/config/appsettings.template.json similarity index 100% rename from config/appsettings.template.json rename to HybridConnectionClientProxy/config/appsettings.template.json diff --git a/HybridConnectionClientProxy/src/ClientProxy.cs b/HybridConnectionClientProxy/src/ClientProxy.cs new file mode 100644 index 0000000..a4cea44 --- /dev/null +++ b/HybridConnectionClientProxy/src/ClientProxy.cs @@ -0,0 +1,75 @@ +using System.Net; +using System.Net.Sockets; + +using Serilog; + +namespace HybridConnectionClientProxy; + +public class ClientProxy +{ + private ClientProxy() { } + + private async Task Run( + IHybridConnectionProvider hycoProvider, + TcpListener tcpListener, + bool ownsListener, + CancellationToken cancellation ) + { + // CancellationTokenSource is created and disposed within Run, so no leak + using var cts = CancellationTokenSource.CreateLinkedTokenSource( cancellation ); + + if( ownsListener ) + tcpListener.Start(); + + try + { + while( !cts.IsCancellationRequested ) + { + var tcpClient = await tcpListener.AcceptTcpClientAsync( cts.Token ); + _ = ClientProxyConnection.Create( tcpClient, hycoProvider, cts.Token ); + } + } + catch( OperationCanceledException ) + { + // quiet — expected on shutdown + } + catch( Exception ex ) + { + Log.Error( ex, "ClientProxy encountered an unexpected error" ); + } + finally + { + if( ownsListener ) + tcpListener.Stop(); + } + } + + /// + /// Creates and starts a proxy that listens on : + /// and forwards each accepted connection through the given Azure Hybrid Connection string. + /// + public static Task Create( + string hycoConnectionString, + IPAddress listenAddress, + int listenPort, + CancellationToken cancellation = default ) + { + var proxy = new ClientProxy(); + var provider = new HybridConnectionClientProvider( hycoConnectionString ); + var listener = new TcpListener( listenAddress, listenPort ); + return proxy.Run( provider, listener, ownsListener: true, cancellation ); + } + + /// + /// Internal overload used by tests: caller owns the lifecycle + /// (Start/Stop), allowing the port to be inspected before passing it in. + /// + internal static Task Create( + IHybridConnectionProvider hycoProvider, + TcpListener tcpListener, + CancellationToken cancellation = default ) + { + var proxy = new ClientProxy(); + return proxy.Run( hycoProvider, tcpListener, ownsListener: false, cancellation ); + } +} diff --git a/HybridConnectionClientProxy/src/ClientProxyConnection.cs b/HybridConnectionClientProxy/src/ClientProxyConnection.cs new file mode 100644 index 0000000..e5e0580 --- /dev/null +++ b/HybridConnectionClientProxy/src/ClientProxyConnection.cs @@ -0,0 +1,57 @@ +using System.Net.Sockets; + +using Serilog; + +namespace HybridConnectionClientProxy; + +internal class ClientProxyConnection +{ + private ClientProxyConnection() { } + + private async Task Run( TcpClient tcpClient, IHybridConnectionProvider hycoProvider, CancellationToken cancellation ) + { + // Both the CancellationTokenSource and the TcpClient are disposed here, + // eliminating the leaks present in the original design where Create() discarded the instance. + using var cts = CancellationTokenSource.CreateLinkedTokenSource( cancellation ); + using var _ = tcpClient; + + try + { + using var tcpStream = tcpClient.GetStream(); + using var hycoStream = await hycoProvider.CreateConnectionAsync( cts.Token ); + + var sendPump = tcpStream.CopyToAsync( hycoStream, cts.Token ); + var receivePump = hycoStream.CopyToAsync( tcpStream, cts.Token ); + + // Wait for whichever side closes first, then cancel the other pump + // so it is not left running indefinitely (original bug: orphaned task). + await Task.WhenAny( sendPump, receivePump ); + await cts.CancelAsync(); + + // HybridConnectionStream.ReadAsync does not reliably unblock when its + // CancellationToken is cancelled, so explicitly close both streams to + // force any blocked reads to throw immediately rather than hanging. + try { tcpStream.Close(); } catch { } + try { hycoStream.Close(); } catch { } + + // Await both pumps to ensure exceptions are observed and resources released. + // Any exception here is expected - both streams have been explicitly closed. + try { await sendPump; } catch { } + try { await receivePump; } catch { } + } + catch( OperationCanceledException ) + { + // quiet - expected on shutdown or when the remote side closes first + } + catch( Exception ex ) + { + Log.Error( ex, "ClientProxyConnection encountered an unexpected error" ); + } + } + + internal static Task Create( TcpClient tcpClient, IHybridConnectionProvider hycoProvider, CancellationToken cancellation ) + { + var connection = new ClientProxyConnection(); + return connection.Run( tcpClient, hycoProvider, cancellation ); + } +} diff --git a/HybridConnectionClientProxy/src/ConfigurationLocator.cs b/HybridConnectionClientProxy/src/ConfigurationLocator.cs new file mode 100644 index 0000000..3f6c325 --- /dev/null +++ b/HybridConnectionClientProxy/src/ConfigurationLocator.cs @@ -0,0 +1,37 @@ +namespace HybridConnectionClientProxy; + +internal static class ConfigurationLocator +{ + /// + /// Lowercase convention matches ASP.NET Core and is case-safe on Linux. + /// Previously "appSettings.json" (capital S) would silently fail on case-sensitive file systems. + /// + public const string DefaultsFile = "appsettings.json"; + + public static string OverlayFileName + => $"appsettings.{Environment.MachineName}.json"; + + public static string OverlayFilePath + { + get + { + var segments = Environment.CurrentDirectory.Split( Path.DirectorySeparatorChar ); + int binpos = segments.Length - 1; + + for( int i = segments.Length - 1; i >= 0; i-- ) + { + if( StringComparer.InvariantCultureIgnoreCase.Equals( segments[i], "bin" ) ) + { + binpos = i; + break; + } + } + + var parentPath = string.Join( Path.DirectorySeparatorChar, segments.Take( binpos ) ); + return Path.Combine( parentPath, "config" ); + } + } + + public static string OverlayFile + => Path.Combine( OverlayFilePath, OverlayFileName ); +} diff --git a/HybridConnectionClientProxy/src/HybridConnectionClientProvider.cs b/HybridConnectionClientProxy/src/HybridConnectionClientProvider.cs new file mode 100644 index 0000000..3770dd7 --- /dev/null +++ b/HybridConnectionClientProxy/src/HybridConnectionClientProvider.cs @@ -0,0 +1,19 @@ +using Microsoft.Azure.Relay; + +namespace HybridConnectionClientProxy; + +/// +/// Production implementation of backed by . +/// +internal sealed class HybridConnectionClientProvider : IHybridConnectionProvider +{ + private readonly HybridConnectionClient _client; + + public HybridConnectionClientProvider( string connectionString ) + { + _client = new HybridConnectionClient( connectionString ); + } + + public async Task CreateConnectionAsync( CancellationToken cancellationToken = default ) + => await _client.CreateConnectionAsync(); +} diff --git a/HybridConnectionClientProxy/src/IHybridConnectionProvider.cs b/HybridConnectionClientProxy/src/IHybridConnectionProvider.cs new file mode 100644 index 0000000..cb5d42c --- /dev/null +++ b/HybridConnectionClientProxy/src/IHybridConnectionProvider.cs @@ -0,0 +1,9 @@ +namespace HybridConnectionClientProxy; + +/// +/// Abstracts the creation of a Hybrid Connection stream, enabling unit testing without live Azure credentials. +/// +internal interface IHybridConnectionProvider +{ + Task CreateConnectionAsync( CancellationToken cancellationToken = default ); +} diff --git a/src/Program.cs b/HybridConnectionClientProxy/src/Program.cs similarity index 62% rename from src/Program.cs rename to HybridConnectionClientProxy/src/Program.cs index 6f2d17d..f2ca621 100644 --- a/src/Program.cs +++ b/HybridConnectionClientProxy/src/Program.cs @@ -1,13 +1,12 @@ -using HybridConnectionClientProxy; +using HybridConnectionClientProxy; using HybridConnectionClientProxy.Settings; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.FileProviders; using Serilog; -using Serilog.Events; -// Bootstrap logger +// Bootstrap logger — replaced after full configuration is loaded Log.Logger = new LoggerConfiguration() .Enrich.FromLogContext() .WriteTo.Console() @@ -17,9 +16,9 @@ Log.Information( "Reading Configuration" ); var configurationManager = new ConfigurationManager(); configurationManager.AddJsonFile( ConfigurationLocator.DefaultsFile ); -configurationManager.AddJsonFile( ConfigurationLocator.OverlayFile, true ); +configurationManager.AddJsonFile( ConfigurationLocator.OverlayFile, optional: true ); #if DEBUG -configurationManager.AddUserSecrets( typeof(Program).Assembly, true ); +configurationManager.AddUserSecrets( typeof( Program ).Assembly, optional: true ); #endif configurationManager.AddEnvironmentVariables(); configurationManager.AddCommandLine( args ); @@ -28,7 +27,7 @@ .ReadFrom.Configuration( configurationManager ) .CreateLogger(); -Log.Information( "Started in {workingDirectory}", Environment.CurrentDirectory ); +Log.Information( "Started in {WorkingDirectory}", Environment.CurrentDirectory ); var files = configurationManager.GetFileProvider(); Log.Debug( @@ -44,7 +43,7 @@ foreach( var entry in configurationManager.AsEnumerable().OrderBy( pair => pair.Key ) ) { - Log.Verbose( "{config} = {value}", entry.Key, entry.Value ); + Log.Verbose( "{Config} = {Value}", entry.Key, entry.Value ); } var configurationSection = configurationManager.GetRequiredSection( AppSettings.Section ); @@ -52,36 +51,30 @@ ConfigurationBinder.Bind( configurationSection, appSettings ); Log.Information( "Starting" ); -var cts = new CancellationTokenSource(); + +if( appSettings.Proxies.Length == 0 ) + throw new InvalidOperationException( "You must specify at least one proxy in the configuration." ); + +using var cts = new CancellationTokenSource(); var proxyTasks = new List(); -if( appSettings.Proxies == null || appSettings.Proxies.Length == 0 ) -{ - throw new Exception( "You must specify at least one proxy in the configuration." ); -} -else + +foreach( var proxy in appSettings.Proxies ) { - foreach( var proxy in appSettings.Proxies ) - { - if( proxy.HybridConnectionString == null ) - { - throw new Exception( "Every proxy in configuration must have a HybridConnectionString." ); - } - - if( proxy.ListenPort == 0 ) - { - throw new Exception( "Every proxy in configuration must have a ListenPort." ); - } - - Log.Debug( "Adding proxy {proxyName}", proxy.Name ); - proxyTasks.Add( - ClientProxy.Create( - proxy.HybridConnectionString, - proxy.ListenAddress, - proxy.ListenPort, - cts.Token - ) - ); - } + if( proxy.HybridConnectionString is null ) + throw new InvalidOperationException( "Every proxy in configuration must have a HybridConnectionString." ); + + if( proxy.ListenPort == 0 ) + throw new InvalidOperationException( "Every proxy in configuration must have a ListenPort." ); + + Log.Debug( "Adding proxy {ProxyName}", proxy.Name ); + proxyTasks.Add( + ClientProxy.Create( + proxy.HybridConnectionString, + proxy.ListenAddress, + proxy.ListenPort, + cts.Token + ) + ); } Log.Information( "Running" ); @@ -89,7 +82,7 @@ Console.ReadKey( true ); Log.Information( "Stopping" ); -cts.Cancel(); +await cts.CancelAsync(); await Task.WhenAll( proxyTasks ); Log.Information( "Stopped" ); diff --git a/HybridConnectionClientProxy/src/Settings/AppSettings.cs b/HybridConnectionClientProxy/src/Settings/AppSettings.cs new file mode 100644 index 0000000..5de0b69 --- /dev/null +++ b/HybridConnectionClientProxy/src/Settings/AppSettings.cs @@ -0,0 +1,8 @@ +namespace HybridConnectionClientProxy.Settings; + +public class AppSettings +{ + public const string Section = "AppSettings"; + + public Proxy[] Proxies { get; set; } = []; +} diff --git a/HybridConnectionClientProxy/src/Settings/Proxy.cs b/HybridConnectionClientProxy/src/Settings/Proxy.cs new file mode 100644 index 0000000..e3ee3b7 --- /dev/null +++ b/HybridConnectionClientProxy/src/Settings/Proxy.cs @@ -0,0 +1,14 @@ +using System.Net; + +namespace HybridConnectionClientProxy.Settings; + +public class Proxy +{ + public string? Name { get; set; } // Purely decorative + public string? HybridConnectionString { get; set; } + public string? ListenIPAddress { get; set; } + public int ListenPort { get; set; } + + public IPAddress ListenAddress + => IPAddress.TryParse( ListenIPAddress, out var ip ) ? ip : IPAddress.Loopback; +} diff --git a/src/ClientProxy.cs b/src/ClientProxy.cs deleted file mode 100644 index 0382390..0000000 --- a/src/ClientProxy.cs +++ /dev/null @@ -1,83 +0,0 @@ -using System.Net; -using System.Net.Sockets; - -using Microsoft.Azure.Relay; - -namespace HybridConnectionClientProxy -{ - public class ClientProxy - : IDisposable - { - private bool disposedValue; - - protected CancellationTokenSource? _cts; - - protected ClientProxy() - { - } - - protected async Task Run( String hycoConnectionString, IPAddress listenAddress, int listenPort, CancellationToken cancellation = default ) - { - _cts = CancellationTokenSource.CreateLinkedTokenSource( cancellation ); - using var tcpListener = new TcpListener( listenAddress, listenPort ); - var hycoClient = new HybridConnectionClient( hycoConnectionString ); - tcpListener.Start(); - - try - { - while( !_cts.IsCancellationRequested ) - { - var tcpClient = await tcpListener.AcceptTcpClientAsync( _cts.Token ); - var _ = ClientProxyConnection.Create( tcpClient, hycoClient, _cts.Token ); - } - } - catch( OperationCanceledException ) - { - // quiet - } - catch( Exception ex ) - { - Console.WriteLine( ex.ToString() ); - } - } - - public static Task Create( String hycoConnectionString, IPAddress listenAddress, int listenPort, CancellationToken cancellation = default ) - { - var clientProxy = new ClientProxy(); - return clientProxy.Run( hycoConnectionString, listenAddress, listenPort, cancellation ); - } - - protected virtual void Dispose( bool disposing ) - { - if( !disposedValue ) - { - if( disposing ) - { - // TODO: dispose managed state (managed objects) - if( _cts != null && _cts.Token.CanBeCanceled ) - { - _cts.Cancel(); - } - } - - // TODO: free unmanaged resources (unmanaged objects) and override finalizer - // TODO: set large fields to null - disposedValue = true; - } - } - - // // TODO: override finalizer only if 'Dispose(bool disposing)' has code to free unmanaged resources - // ~ClientProxy() - // { - // // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method - // Dispose(disposing: false); - // } - - public void Dispose() - { - // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method - Dispose( disposing: true ); - GC.SuppressFinalize( this ); - } - } -} diff --git a/src/ClientProxyConnection.cs b/src/ClientProxyConnection.cs deleted file mode 100644 index 2b6ed52..0000000 --- a/src/ClientProxyConnection.cs +++ /dev/null @@ -1,82 +0,0 @@ -using System.Net.Sockets; - -using Microsoft.Azure.Relay; - -namespace HybridConnectionClientProxy -{ - public class ClientProxyConnection - : IDisposable - { - private bool disposedValue; - protected CancellationTokenSource? _cts; - - protected ClientProxyConnection() - { - } - - protected async Task Run( TcpClient tcpClient, HybridConnectionClient hycoClient, CancellationToken cancellation ) - { - try - { - _cts = CancellationTokenSource.CreateLinkedTokenSource( cancellation ); - using var tcpStream = tcpClient.GetStream(); - using var hycoStream = await hycoClient.CreateConnectionAsync(); - - var sendPump = tcpStream.CopyToAsync( hycoStream, _cts.Token ); - var receivePump = hycoStream.CopyToAsync( tcpStream, _cts.Token ); - await Task.WhenAny( sendPump, receivePump ); - await Task.WhenAll( - tcpStream.FlushAsync(), - hycoStream.FlushAsync() - ); - } - catch( OperationCanceledException ) - { - // quiet - } - catch( Exception ex ) - { - Console.WriteLine( ex.ToString() ); - } - } - - public static Task Create( TcpClient tcpClient, HybridConnectionClient hycoClient, CancellationToken cancellation ) - { - var connection = new ClientProxyConnection(); - return connection.Run( tcpClient, hycoClient, cancellation ); - } - - protected virtual void Dispose( bool disposing ) - { - if( !disposedValue ) - { - if( disposing ) - { - // TODO: dispose managed state (managed objects) - if( _cts != null && _cts.Token.CanBeCanceled ) - { - _cts.Cancel(); - } - } - - // TODO: free unmanaged resources (unmanaged objects) and override finalizer - // TODO: set large fields to null - disposedValue = true; - } - } - - // // TODO: override finalizer only if 'Dispose(bool disposing)' has code to free unmanaged resources - // ~ClientProxyConnection() - // { - // // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method - // Dispose(disposing: false); - // } - - public void Dispose() - { - // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method - Dispose( disposing: true ); - GC.SuppressFinalize( this ); - } - } -} diff --git a/src/ConfigurationLocator.cs b/src/ConfigurationLocator.cs deleted file mode 100644 index 7f9c340..0000000 --- a/src/ConfigurationLocator.cs +++ /dev/null @@ -1,50 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace HybridConnectionClientProxy -{ - internal class ConfigurationLocator - { - public const String DefaultsFile - = "appsettings.json"; - - public static String OverlayFileName - => $"appsettings.{Environment.MachineName}.json"; - - public static String OverlayFilePath - { - get - { - var segments = Environment.CurrentDirectory.Split( Path.DirectorySeparatorChar ); - Int32 binpos = segments.Length - 1; - - for( int i = segments.Length - 1; i >= 0; i-- ) - { - if( StringComparer.InvariantCultureIgnoreCase.Equals( segments[i], "bin" ) ) - { - binpos = i; - break; - } - } - - var parentpath = String.Join( Path.DirectorySeparatorChar, segments.Take( binpos ) ); - // var parentpath = string.Join( Path.DirectorySeparatorChar, Enumerable.Repeat( "..", segments.Length - binpos ) ); - - return - Path.Combine( - parentpath, - "config" - ); - } - } - - public static String OverlayFile - = Path.Combine( - OverlayFilePath, - OverlayFileName - ); - } -} diff --git a/src/Settings/AppSettings.cs b/src/Settings/AppSettings.cs deleted file mode 100644 index dde696b..0000000 --- a/src/Settings/AppSettings.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace HybridConnectionClientProxy.Settings -{ - public class AppSettings - { - public const String Section = "AppSettings"; - - public Proxy[]? Proxies { get; set; } = Array.Empty(); - } -} diff --git a/src/Settings/Proxy.cs b/src/Settings/Proxy.cs deleted file mode 100644 index 885ca75..0000000 --- a/src/Settings/Proxy.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Text; -using System.Threading.Tasks; - -namespace HybridConnectionClientProxy.Settings -{ - public class Proxy - { - public String? Name { get; set; } // Purely decorative - public String? HybridConnectionString { get; set; } - public String? ListenIPAddress { get; set; } - public int ListenPort { get; set; } - - public IPAddress ListenAddress - { - get - { - IPAddress? ip; - if( !IPAddress.TryParse( ListenIPAddress, out ip ) ) - { - ip = IPAddress.Loopback; - } - - return ip; - } - } - } -}