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;
- }
- }
- }
-}