Add support for pluggable stream provider to enable SOCKS5 proxies (#2251)

* Add SOCKS5 proxy support via pluggable stream provider

Introduces MQTTnet.Extensions.Socks5, enabling SOCKS5 proxy support for MQTTnet TCP transport. Adds IMqttClientStreamProvider interface, Socks5StreamProvider implementation, and related configuration options. Updates MqttTcpChannel to support stream providers and TLS layering. Includes unit tests with a fake SOCKS5 server and usage samples. Solution and project references updated accordingly.

* Add SOCKS5 options validation for invalid configurations

* Tighten SOCKS5 RFC compliance checks

---------

Co-authored-by: Copilot <Copilot@users.noreply.github.com>
This commit is contained in:
Alexander Köpke
2026-06-18 17:51:52 +02:00
committed by GitHub
co-authored by Copilot
parent c1b46b0ae7
commit 68da92fd4b
24 changed files with 2726 additions and 49 deletions
@@ -0,0 +1,5 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
[assembly: DoNotParallelize]
@@ -0,0 +1,529 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace MQTTnet.Tests.Extensions.Socks5;
/// <summary>
/// Scriptable in-process SOCKS5 server used by the test suite.
/// Listens on 127.0.0.1 on a random port and runs a single, configurable handshake per
/// accepted connection. On a successful CONNECT, it opens a TCP connection to the destination
/// described by the CONNECT request and bidirectionally relays bytes between the client and
/// the destination, allowing tests to point the SOCKS5 client at the in-memory MQTTnet
/// broker.
/// </summary>
internal sealed class FakeSocks5Server : IAsyncDisposable
{
readonly TcpListener _listener;
readonly CancellationTokenSource _cts = new();
readonly Task _acceptLoop;
readonly List<Task> _connectionTasks = new();
readonly object _connectionsLock = new();
readonly List<RecordedConnect> _recordedConnects = new();
readonly object _recordedLock = new();
public FakeSocks5Server(FakeSocks5ServerOptions options)
{
Options = options ?? throw new ArgumentNullException(nameof(options));
_listener = new TcpListener(IPAddress.Loopback, 0);
_listener.Start();
Port = ((IPEndPoint)_listener.LocalEndpoint).Port;
_acceptLoop = Task.Run(AcceptLoopAsync);
}
public int Port { get; }
public FakeSocks5ServerOptions Options { get; }
public IReadOnlyList<RecordedConnect> RecordedConnects
{
get
{
lock (_recordedLock)
{
return _recordedConnects.ToArray();
}
}
}
public async ValueTask DisposeAsync()
{
try
{
_cts.Cancel();
}
catch (ObjectDisposedException)
{
}
try
{
_listener.Stop();
}
catch (Exception)
{
// ignore
}
try
{
await _acceptLoop.ConfigureAwait(false);
}
catch (Exception)
{
// ignore
}
Task[] pending;
lock (_connectionsLock)
{
pending = _connectionTasks.ToArray();
}
try
{
await Task.WhenAll(pending).ConfigureAwait(false);
}
catch (Exception)
{
// ignore
}
_cts.Dispose();
}
async Task AcceptLoopAsync()
{
try
{
while (!_cts.IsCancellationRequested)
{
TcpClient client;
try
{
client = await _listener.AcceptTcpClientAsync(_cts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
return;
}
catch (SocketException)
{
return;
}
catch (ObjectDisposedException)
{
return;
}
var task = Task.Run(() => HandleClientAsync(client));
lock (_connectionsLock)
{
_connectionTasks.Add(task);
}
}
}
catch (Exception)
{
// swallow — diagnostic-only
}
}
async Task HandleClientAsync(TcpClient client)
{
using (client)
using (var clientStream = client.GetStream())
{
try
{
if (Options.DropAfterAccept)
{
return;
}
// Greeting: VER NMETHODS METHODS[]
var versionAndCount = new byte[2];
await ReadExactlyAsync(clientStream, versionAndCount, _cts.Token).ConfigureAwait(false);
if (versionAndCount[0] != 0x05)
{
return;
}
var methods = new byte[versionAndCount[1]];
await ReadExactlyAsync(clientStream, methods, _cts.Token).ConfigureAwait(false);
if (Options.GreetingResponseOverride != null)
{
await clientStream.WriteAsync(Options.GreetingResponseOverride, _cts.Token).ConfigureAwait(false);
if (Options.DropAfterGreetingResponse)
{
return;
}
}
else
{
byte method = SelectMethod(methods);
await clientStream.WriteAsync(new byte[] { 0x05, method }, _cts.Token).ConfigureAwait(false);
if (method == 0xFF)
{
return;
}
if (method == 0x02)
{
if (!await HandleAuthAsync(clientStream).ConfigureAwait(false))
{
return;
}
}
}
// CONNECT request: VER CMD RSV ATYP DST.ADDR DST.PORT
var header = new byte[4];
await ReadExactlyAsync(clientStream, header, _cts.Token).ConfigureAwait(false);
if (header[0] != 0x05)
{
// RFC 1928 §4: VER must be X'05' in every message.
return;
}
if (header[1] != 0x01)
{
// RFC 1928 §4: only CONNECT (0x01) is supported by this fake server. BIND (0x02)
// and UDP ASSOCIATE (0x03) are signaled as unsupported per spec via REP=0x07.
await WriteReplyAsync(clientStream, 0x07, Options.ReplyReservedByte, Options.ReplyAddressType, Options.ReplyBindHost, Options.ReplyBindPort).ConfigureAwait(false);
return;
}
string targetHost;
IPAddress targetIp = null;
byte atyp = header[3];
switch (atyp)
{
case 0x01:
{
var addr = new byte[4];
await ReadExactlyAsync(clientStream, addr, _cts.Token).ConfigureAwait(false);
targetIp = new IPAddress(addr);
targetHost = targetIp.ToString();
break;
}
case 0x03:
{
var lenBuf = new byte[1];
await ReadExactlyAsync(clientStream, lenBuf, _cts.Token).ConfigureAwait(false);
var hostBytes = new byte[lenBuf[0]];
await ReadExactlyAsync(clientStream, hostBytes, _cts.Token).ConfigureAwait(false);
targetHost = Encoding.UTF8.GetString(hostBytes);
break;
}
case 0x04:
{
var addr = new byte[16];
await ReadExactlyAsync(clientStream, addr, _cts.Token).ConfigureAwait(false);
targetIp = new IPAddress(addr);
targetHost = targetIp.ToString();
break;
}
default:
await WriteReplyAsync(clientStream, 0x08, Options.ReplyReservedByte, Options.ReplyAddressType, Options.ReplyBindHost, Options.ReplyBindPort).ConfigureAwait(false);
return;
}
var portBuf = new byte[2];
await ReadExactlyAsync(clientStream, portBuf, _cts.Token).ConfigureAwait(false);
var targetPort = BinaryPrimitives.ReadUInt16BigEndian(portBuf);
lock (_recordedLock)
{
_recordedConnects.Add(new RecordedConnect(atyp, targetHost, targetPort));
}
if (Options.ConnectReplyCode != 0x00)
{
await WriteReplyAsync(clientStream, Options.ConnectReplyCode, Options.ReplyReservedByte, Options.ReplyAddressType, Options.ReplyBindHost, Options.ReplyBindPort).ConfigureAwait(false);
return;
}
// Open the destination connection and start relaying.
TcpClient upstream;
try
{
upstream = new TcpClient();
if (targetIp != null)
{
await upstream.ConnectAsync(targetIp, targetPort, _cts.Token).ConfigureAwait(false);
}
else
{
await upstream.ConnectAsync(targetHost, targetPort, _cts.Token).ConfigureAwait(false);
}
}
catch (Exception)
{
await WriteReplyAsync(clientStream, 0x04, Options.ReplyReservedByte, Options.ReplyAddressType, Options.ReplyBindHost, Options.ReplyBindPort).ConfigureAwait(false);
return;
}
using (upstream)
using (var upstreamStream = upstream.GetStream())
{
await WriteReplyAsync(clientStream, 0x00, Options.ReplyReservedByte, Options.ReplyAddressType, Options.ReplyBindHost, Options.ReplyBindPort).ConfigureAwait(false);
var c2u = RelayAsync(clientStream, upstreamStream, _cts.Token);
var u2c = RelayAsync(upstreamStream, clientStream, _cts.Token);
await Task.WhenAny(c2u, u2c).ConfigureAwait(false);
}
}
catch (Exception)
{
// swallow — diagnostic-only
}
}
}
byte SelectMethod(byte[] offered)
{
var allowedMethods = Options.AllowedMethods;
foreach (var m in offered)
{
foreach (var allowed in allowedMethods)
{
if (m == allowed)
{
return m;
}
}
}
return 0xFF;
}
async Task<bool> HandleAuthAsync(NetworkStream stream)
{
var header = new byte[2];
await ReadExactlyAsync(stream, header, _cts.Token).ConfigureAwait(false);
if (header[0] != 0x01)
{
return false;
}
var ulen = header[1];
var userBytes = new byte[ulen];
await ReadExactlyAsync(stream, userBytes, _cts.Token).ConfigureAwait(false);
var plenBuf = new byte[1];
await ReadExactlyAsync(stream, plenBuf, _cts.Token).ConfigureAwait(false);
var passBytes = new byte[plenBuf[0]];
await ReadExactlyAsync(stream, passBytes, _cts.Token).ConfigureAwait(false);
var user = Encoding.UTF8.GetString(userBytes);
var pass = Encoding.UTF8.GetString(passBytes);
var ok = Options.ExpectedUsername == user && Options.ExpectedPassword == pass;
if (Options.ForceAuthFailure)
{
ok = false;
}
await stream.WriteAsync(new byte[] { 0x01, ok ? (byte)0x00 : (byte)0x01 }, _cts.Token).ConfigureAwait(false);
return ok;
}
static async Task WriteReplyAsync(NetworkStream stream, byte rep, byte replyReserved, byte replyAtyp, string replyHost, ushort replyPort)
{
// VER REP RSV ATYP BND.ADDR BND.PORT
//
// RFC 1928 §6 says BND.ADDR contains the "associated IP address" (i.e. the server-side
// endpoint it used to reach the destination). Most real SOCKS5 servers reply with
// ATYP=0x01 (IPv4) + a zeroed address when they have nothing meaningful to report,
// which is the safe default we use here. Tests can override the reply ATYP via
// FakeSocks5ServerOptions.ReplyAddressType to exercise the client's reply parser.
switch (replyAtyp)
{
case 0x04:
{
var reply = new byte[4 + 16 + 2];
reply[0] = 0x05;
reply[1] = rep;
reply[2] = replyReserved;
reply[3] = 0x04;
if (!string.IsNullOrEmpty(replyHost) && IPAddress.TryParse(replyHost, out var ip) && ip.AddressFamily == AddressFamily.InterNetworkV6)
{
var bytes = ip.GetAddressBytes();
Buffer.BlockCopy(bytes, 0, reply, 4, 16);
}
BinaryPrimitives.WriteUInt16BigEndian(reply.AsSpan(20, 2), replyPort);
await stream.WriteAsync(reply).ConfigureAwait(false);
return;
}
case 0x03:
{
var host = string.IsNullOrEmpty(replyHost) ? "0.0.0.0" : replyHost;
var hostBytes = Encoding.UTF8.GetBytes(host);
if (hostBytes.Length > 255)
{
Array.Resize(ref hostBytes, 255);
}
var reply = new byte[4 + 1 + hostBytes.Length + 2];
reply[0] = 0x05;
reply[1] = rep;
reply[2] = replyReserved;
reply[3] = 0x03;
reply[4] = (byte)hostBytes.Length;
Buffer.BlockCopy(hostBytes, 0, reply, 5, hostBytes.Length);
BinaryPrimitives.WriteUInt16BigEndian(reply.AsSpan(5 + hostBytes.Length, 2), replyPort);
await stream.WriteAsync(reply).ConfigureAwait(false);
return;
}
default:
{
var reply = new byte[4 + 4 + 2];
reply[0] = 0x05;
reply[1] = rep;
reply[2] = replyReserved;
reply[3] = 0x01;
if (!string.IsNullOrEmpty(replyHost) && IPAddress.TryParse(replyHost, out var ip) && ip.AddressFamily == AddressFamily.InterNetwork)
{
var bytes = ip.GetAddressBytes();
Buffer.BlockCopy(bytes, 0, reply, 4, 4);
}
BinaryPrimitives.WriteUInt16BigEndian(reply.AsSpan(8, 2), replyPort);
await stream.WriteAsync(reply).ConfigureAwait(false);
return;
}
}
}
static async Task RelayAsync(NetworkStream source, NetworkStream destination, CancellationToken cancellationToken)
{
var buffer = new byte[16 * 1024];
try
{
while (true)
{
var read = await source.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
if (read <= 0)
{
return;
}
await destination.WriteAsync(buffer.AsMemory(0, read), cancellationToken).ConfigureAwait(false);
}
}
catch (Exception)
{
// ignore
}
}
static async Task ReadExactlyAsync(NetworkStream stream, byte[] buffer, CancellationToken cancellationToken)
{
var offset = 0;
while (offset < buffer.Length)
{
var read = await stream.ReadAsync(buffer.AsMemory(offset, buffer.Length - offset), cancellationToken).ConfigureAwait(false);
if (read <= 0)
{
throw new EndOfStreamException();
}
offset += read;
}
}
public sealed record RecordedConnect(byte AddressType, string Host, int Port);
}
internal sealed class FakeSocks5ServerOptions
{
/// <summary>
/// The SOCKS5 auth methods the server will accept, in preference order matched against the
/// client's offered methods. Defaults to no-auth only.
/// </summary>
public byte[] AllowedMethods { get; set; } = [0x00];
public string ExpectedUsername { get; set; }
public string ExpectedPassword { get; set; }
/// <summary>
/// Forces the username/password sub-negotiation to return STATUS=0x01 regardless of the
/// supplied credentials. Useful for tests that exercise the auth-failure path.
/// </summary>
public bool ForceAuthFailure { get; set; }
/// <summary>
/// SOCKS5 reply code for the CONNECT response. <c>0x00</c> means "succeeded" and the
/// server will additionally bridge bytes to the destination.
/// </summary>
public byte ConnectReplyCode { get; set; }
/// <summary>
/// Reserved byte included in the CONNECT reply. Defaults to <c>0x00</c> per RFC 1928.
/// </summary>
public byte ReplyReservedByte { get; set; }
/// <summary>
/// When set, the server sends this byte sequence instead of the regular greeting response.
/// Used to simulate malformed responses (e.g. wrong version).
/// </summary>
public byte[] GreetingResponseOverride { get; set; }
/// <summary>
/// If <c>true</c>, closes the connection right after writing the greeting response
/// override. Used together with <see cref="GreetingResponseOverride"/> for protocol-error
/// tests.
/// </summary>
public bool DropAfterGreetingResponse { get; set; }
/// <summary>
/// If <c>true</c>, the server closes the connection immediately after accepting (before
/// reading the greeting). Used to simulate proxy mid-handshake disconnects.
/// </summary>
public bool DropAfterAccept { get; set; }
/// <summary>
/// Address type used when constructing CONNECT replies. Defaults to <c>0x01</c> (IPv4),
/// which matches the behavior of real SOCKS5 servers — they always reply with a resolved
/// address, regardless of how the client expressed the destination. Override to
/// <c>0x03</c> or <c>0x04</c> to exercise the client's reply parser.
/// </summary>
public byte ReplyAddressType { get; set; } = 0x01;
/// <summary>
/// Bound host included in the CONNECT reply (BND.ADDR). Format depends on
/// <see cref="ReplyAddressType"/>: IPv4/IPv6 textual representation for ATYP 0x01/0x04,
/// a domain name for ATYP 0x03. When <c>null</c> or empty the server writes a zeroed
/// address (0.0.0.0, ::, or "0.0.0.0" respectively).
/// </summary>
public string ReplyBindHost { get; set; }
/// <summary>
/// Bound port included in the CONNECT reply (BND.PORT). Defaults to <c>0</c>, which
/// is what real SOCKS5 servers return when no meaningful port is available.
/// </summary>
public ushort ReplyBindPort { get; set; }
}
@@ -0,0 +1,5 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
global using Microsoft.VisualStudio.TestTools.UnitTesting;
@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<IsPackable>false</IsPackable>
<LangVersion>default</LangVersion>
<Nullable>disable</Nullable>
<NoWarn>$(NoWarn);CA1707;CA1859</NoWarn>
<EnableMSTestRunner>true</EnableMSTestRunner>
<RootNamespace>MQTTnet.Tests.Extensions.Socks5</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MSTest" Version="4.0.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MQTTnet\MQTTnet.csproj" />
<ProjectReference Include="..\MQTTnet.Server\MQTTnet.Server.csproj" />
<ProjectReference Include="..\MQTTnet.Extensions.Socks5\MQTTnet.Extensions.Socks5.csproj" />
</ItemGroup>
<ItemGroup>
<None Include=".\..\..\global.json"
Link="global.json"
CopyToOutputDirectory="PreserveNewest"/>
</ItemGroup>
</Project>
@@ -0,0 +1,566 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Buffers;
using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using MQTTnet.Exceptions;
using MQTTnet.Extensions.Socks5;
using MQTTnet.Server;
namespace MQTTnet.Tests.Extensions.Socks5;
[TestClass]
public sealed class Socks5MqttClient_Tests
{
[TestMethod]
public async Task Connect_Tcp_Via_Socks5_No_Auth_PubSub_Works()
{
Socks5TestHelper.BrokerHandle broker = null;
FakeSocks5Server proxy = null;
IMqttClient client = null;
try
{
broker = await Socks5TestHelper.StartBrokerAsync();
var brokerPort = Socks5TestHelper.GetBrokerPort(broker);
proxy = new FakeSocks5Server(new FakeSocks5ServerOptions());
client = new MqttClientFactory().CreateMqttClient();
var receivedTopic = string.Empty;
var received = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
client.ApplicationMessageReceivedAsync += e =>
{
receivedTopic = e.ApplicationMessage.Topic;
received.TrySetResult(Encoding.UTF8.GetString(e.ApplicationMessage.Payload.ToArray()));
return Task.CompletedTask;
};
var options = new MqttClientOptionsBuilder()
.WithTcpServer("127.0.0.1", brokerPort)
.WithSocks5Proxy(p => p.WithHost("127.0.0.1").WithPort(proxy.Port))
.Build();
await client.ConnectAsync(options, CancellationToken.None);
Assert.IsTrue(client.IsConnected);
await client.SubscribeAsync("topic/socks5");
await client.PublishStringAsync("topic/socks5", "hello-socks5");
var payload = await received.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.AreEqual("topic/socks5", receivedTopic);
Assert.AreEqual("hello-socks5", payload);
Assert.HasCount(1, proxy.RecordedConnects);
Assert.AreEqual(brokerPort, proxy.RecordedConnects[0].Port);
}
finally
{
if (client != null)
{
try { await client.DisconnectAsync(); } catch { /* ignore */ }
client.Dispose();
}
if (proxy != null)
{
await proxy.DisposeAsync();
}
if (broker != null)
{
await broker.Server.StopAsync();
broker.Server.Dispose();
}
}
}
[TestMethod]
public async Task Connect_Tcp_Via_Socks5_With_UsernamePassword_Works()
{
var broker = await Socks5TestHelper.StartBrokerAsync();
var brokerPort = Socks5TestHelper.GetBrokerPort(broker);
await using var proxy = new FakeSocks5Server(new FakeSocks5ServerOptions
{
AllowedMethods = [0x02],
ExpectedUsername = "alice",
ExpectedPassword = "s3cr3t"
});
using var client = new MqttClientFactory().CreateMqttClient();
var options = new MqttClientOptionsBuilder()
.WithTcpServer("127.0.0.1", brokerPort)
.WithSocks5Proxy(p => p.WithHost("127.0.0.1").WithPort(proxy.Port).WithCredentials("alice", "s3cr3t"))
.Build();
try
{
await client.ConnectAsync(options, CancellationToken.None);
Assert.IsTrue(client.IsConnected);
}
finally
{
try { await client.DisconnectAsync(); } catch { /* ignore */ }
await broker.Server.StopAsync();
broker.Server.Dispose();
}
}
[TestMethod]
public async Task Connect_Tcp_Via_Socks5_With_Wrong_Credentials_Throws_AuthFailed()
{
var broker = await Socks5TestHelper.StartBrokerAsync();
var brokerPort = Socks5TestHelper.GetBrokerPort(broker);
await using var proxy = new FakeSocks5Server(new FakeSocks5ServerOptions
{
AllowedMethods = [0x02],
ForceAuthFailure = true
});
using var client = new MqttClientFactory().CreateMqttClient();
var options = new MqttClientOptionsBuilder()
.WithTcpServer("127.0.0.1", brokerPort)
.WithSocks5Proxy(p => p.WithHost("127.0.0.1").WithPort(proxy.Port).WithCredentials("alice", "wrong"))
.Build();
try
{
var ex = await Assert.ThrowsExactlyAsync<MqttProxyException>(() => client.ConnectAsync(options));
Assert.AreEqual(MqttProxyErrorCode.ProxyAuthFailed, ex.ErrorCode);
}
finally
{
await broker.Server.StopAsync();
broker.Server.Dispose();
}
}
[TestMethod]
public async Task Connect_Tcp_Via_Socks5_When_Method_Rejected_Throws_AuthMethodRejected()
{
var broker = await Socks5TestHelper.StartBrokerAsync();
var brokerPort = Socks5TestHelper.GetBrokerPort(broker);
await using var proxy = new FakeSocks5Server(new FakeSocks5ServerOptions
{
AllowedMethods = [0x01] // GSSAPI which the client never offers.
});
using var client = new MqttClientFactory().CreateMqttClient();
var options = new MqttClientOptionsBuilder()
.WithTcpServer("127.0.0.1", brokerPort)
.WithSocks5Proxy(p => p.WithHost("127.0.0.1").WithPort(proxy.Port))
.Build();
try
{
var ex = await Assert.ThrowsExactlyAsync<MqttProxyException>(() => client.ConnectAsync(options));
Assert.AreEqual(MqttProxyErrorCode.ProxyAuthMethodRejected, ex.ErrorCode);
}
finally
{
await broker.Server.StopAsync();
broker.Server.Dispose();
}
}
[TestMethod]
public async Task Connect_Tcp_Via_Socks5_When_Broker_Host_Unreachable_Maps_To_HostUnreachable()
{
await using var proxy = new FakeSocks5Server(new FakeSocks5ServerOptions
{
ConnectReplyCode = 0x04
});
using var client = new MqttClientFactory().CreateMqttClient();
var options = new MqttClientOptionsBuilder()
.WithTcpServer("broker.invalid", 1883)
.WithSocks5Proxy(p => p.WithHost("127.0.0.1").WithPort(proxy.Port))
.Build();
var ex = await Assert.ThrowsExactlyAsync<MqttProxyException>(() => client.ConnectAsync(options));
Assert.AreEqual(MqttProxyErrorCode.ProxyHostUnreachable, ex.ErrorCode);
}
[TestMethod]
public async Task Connect_Tcp_Via_Socks5_When_Broker_Connection_Refused_Maps_To_ConnectionRefused()
{
await using var proxy = new FakeSocks5Server(new FakeSocks5ServerOptions
{
ConnectReplyCode = 0x05
});
using var client = new MqttClientFactory().CreateMqttClient();
var options = new MqttClientOptionsBuilder()
.WithTcpServer("127.0.0.1", 1)
.WithSocks5Proxy(p => p.WithHost("127.0.0.1").WithPort(proxy.Port))
.Build();
var ex = await Assert.ThrowsExactlyAsync<MqttProxyException>(() => client.ConnectAsync(options));
Assert.AreEqual(MqttProxyErrorCode.ProxyConnectionRefused, ex.ErrorCode);
}
[TestMethod]
public async Task Connect_Tcp_Via_Socks5_When_Proxy_Port_Closed_Throws_ProxyUnreachable()
{
// Bind a temporary listener just to grab a free port and immediately release it.
var probe = new TcpListener(IPAddress.Loopback, 0);
probe.Start();
var freePort = ((IPEndPoint)probe.LocalEndpoint).Port;
probe.Stop();
using var client = new MqttClientFactory().CreateMqttClient();
var options = new MqttClientOptionsBuilder()
.WithTcpServer("127.0.0.1", 1883)
.WithSocks5Proxy(p => p.WithHost("127.0.0.1").WithPort(freePort).WithHandshakeTimeout(TimeSpan.FromSeconds(5)))
.Build();
var ex = await Assert.ThrowsExactlyAsync<MqttProxyException>(() => client.ConnectAsync(options));
Assert.AreEqual(MqttProxyErrorCode.ProxyUnreachable, ex.ErrorCode);
}
[TestMethod]
public async Task Connect_Tcp_Via_Socks5_When_Proxy_Host_Does_Not_Resolve_Throws_ProxyUnreachable()
{
using var client = new MqttClientFactory().CreateMqttClient();
var options = new MqttClientOptionsBuilder()
.WithTcpServer("127.0.0.1", 1883)
.WithSocks5Proxy(p => p.WithHost("nonexistent.proxy.invalid").WithPort(1080).WithHandshakeTimeout(TimeSpan.FromSeconds(5)))
.Build();
var ex = await Assert.ThrowsExactlyAsync<MqttProxyException>(() => client.ConnectAsync(options));
Assert.AreEqual(MqttProxyErrorCode.ProxyUnreachable, ex.ErrorCode);
}
[TestMethod]
public async Task Remote_Dns_Enabled_Sends_DomainName_To_Proxy()
{
var broker = await Socks5TestHelper.StartBrokerAsync();
var brokerPort = Socks5TestHelper.GetBrokerPort(broker);
await using var proxy = new FakeSocks5Server(new FakeSocks5ServerOptions());
using var client = new MqttClientFactory().CreateMqttClient();
var options = new MqttClientOptionsBuilder()
.WithTcpServer("localhost", brokerPort)
.WithSocks5Proxy(p => p.WithHost("127.0.0.1").WithPort(proxy.Port).WithResolveDnsRemotely(true))
.Build();
try
{
await client.ConnectAsync(options);
Assert.IsTrue(client.IsConnected);
Assert.HasCount(1, proxy.RecordedConnects);
Assert.AreEqual<byte>(0x03, proxy.RecordedConnects[0].AddressType);
Assert.AreEqual("localhost", proxy.RecordedConnects[0].Host);
}
finally
{
try { await client.DisconnectAsync(); } catch { /* ignore */ }
await broker.Server.StopAsync();
broker.Server.Dispose();
}
}
[TestMethod]
public async Task Remote_Dns_Disabled_Sends_Ipv4_To_Proxy()
{
var broker = await Socks5TestHelper.StartBrokerAsync();
var brokerPort = Socks5TestHelper.GetBrokerPort(broker);
await using var proxy = new FakeSocks5Server(new FakeSocks5ServerOptions());
using var client = new MqttClientFactory().CreateMqttClient();
var options = new MqttClientOptionsBuilder()
.WithTcpServer("localhost", brokerPort)
.WithSocks5Proxy(p => p
.WithHost("127.0.0.1")
.WithPort(proxy.Port)
.WithResolveDnsRemotely(false)
.WithAddressFamily(AddressFamily.InterNetwork))
.Build();
try
{
await client.ConnectAsync(options);
Assert.IsTrue(client.IsConnected);
Assert.HasCount(1, proxy.RecordedConnects);
Assert.AreEqual<byte>(0x01, proxy.RecordedConnects[0].AddressType);
Assert.AreEqual("127.0.0.1", proxy.RecordedConnects[0].Host);
}
finally
{
try { await client.DisconnectAsync(); } catch { /* ignore */ }
await broker.Server.StopAsync();
broker.Server.Dispose();
}
}
[TestMethod]
public async Task Ipv6_IpEndPoint_Sends_Ipv6_AddressType()
{
var broker = await Socks5TestHelper.StartBrokerAsync();
var brokerPort = Socks5TestHelper.GetBrokerPort(broker);
await using var proxy = new FakeSocks5Server(new FakeSocks5ServerOptions());
using var client = new MqttClientFactory().CreateMqttClient();
// Loopback (::1) is reachable on practically every CI runner.
var options = new MqttClientOptionsBuilder()
.WithEndPoint(new IPEndPoint(IPAddress.IPv6Loopback, brokerPort))
.WithSocks5Proxy(p => p.WithHost("127.0.0.1").WithPort(proxy.Port))
.Build();
try
{
await client.ConnectAsync(options);
Assert.IsTrue(client.IsConnected);
Assert.HasCount(1, proxy.RecordedConnects);
Assert.AreEqual<byte>(0x04, proxy.RecordedConnects[0].AddressType);
}
finally
{
try { await client.DisconnectAsync(); } catch { /* ignore */ }
await broker.Server.StopAsync();
broker.Server.Dispose();
}
}
[TestMethod]
public async Task Cancellation_During_Handshake_Throws_OperationCanceled()
{
// Proxy that accepts the TCP connection but never replies to the greeting.
await using var proxy = new FakeSocks5Server(new FakeSocks5ServerOptions
{
DropAfterAccept = true
});
using var client = new MqttClientFactory().CreateMqttClient();
using var cts = new CancellationTokenSource();
await cts.CancelAsync();
var options = new MqttClientOptionsBuilder()
.WithTcpServer("127.0.0.1", 1883)
.WithSocks5Proxy(p => p.WithHost("127.0.0.1").WithPort(proxy.Port))
.Build();
await Assert.ThrowsAsync<OperationCanceledException>(() => client.ConnectAsync(options, cts.Token));
}
[TestMethod]
public async Task Greeting_Returns_Wrong_Version_Throws_ProtocolError()
{
await using var proxy = new FakeSocks5Server(new FakeSocks5ServerOptions
{
GreetingResponseOverride = [0x04, 0x00],
DropAfterGreetingResponse = true
});
using var client = new MqttClientFactory().CreateMqttClient();
var options = new MqttClientOptionsBuilder()
.WithTcpServer("127.0.0.1", 1883)
.WithSocks5Proxy(p => p.WithHost("127.0.0.1").WithPort(proxy.Port))
.Build();
var ex = await Assert.ThrowsExactlyAsync<MqttProxyException>(() => client.ConnectAsync(options));
Assert.AreEqual(MqttProxyErrorCode.ProxyProtocolError, ex.ErrorCode);
}
[TestMethod]
public async Task Connect_Response_With_NonZero_Reserved_Byte_Throws_ProtocolError()
{
await using var proxy = new FakeSocks5Server(new FakeSocks5ServerOptions
{
ReplyReservedByte = 0x01
});
using var client = new MqttClientFactory().CreateMqttClient();
var options = new MqttClientOptionsBuilder()
.WithTcpServer("127.0.0.1", 1883)
.WithSocks5Proxy(p => p.WithHost("127.0.0.1").WithPort(proxy.Port))
.Build();
var ex = await Assert.ThrowsExactlyAsync<MqttProxyException>(() => client.ConnectAsync(options));
Assert.AreEqual(MqttProxyErrorCode.ProxyProtocolError, ex.ErrorCode);
}
[TestMethod]
public async Task Reconnect_After_Proxy_Disconnect_Works()
{
var broker = await Socks5TestHelper.StartBrokerAsync();
var brokerPort = Socks5TestHelper.GetBrokerPort(broker);
await using var proxy = new FakeSocks5Server(new FakeSocks5ServerOptions());
using var client = new MqttClientFactory().CreateMqttClient();
var options = new MqttClientOptionsBuilder()
.WithTcpServer("127.0.0.1", brokerPort)
.WithSocks5Proxy(p => p.WithHost("127.0.0.1").WithPort(proxy.Port))
.Build();
try
{
await client.ConnectAsync(options);
Assert.IsTrue(client.IsConnected);
// Force the broker side to drop the client by stopping & restarting it (proxy
// forwarding closes when the upstream connection closes).
await broker.Server.StopAsync();
broker.Server.Dispose();
await Task.Delay(500);
broker = await Socks5TestHelper.StartBrokerAsync();
var newPort = Socks5TestHelper.GetBrokerPort(broker);
options = new MqttClientOptionsBuilder()
.WithTcpServer("127.0.0.1", newPort)
.WithSocks5Proxy(p => p.WithHost("127.0.0.1").WithPort(proxy.Port))
.Build();
await client.ConnectAsync(options);
Assert.IsTrue(client.IsConnected);
// Two distinct CONNECT requests should have hit the proxy.
Assert.IsGreaterThanOrEqualTo(2, proxy.RecordedConnects.Count);
}
finally
{
try { await client.DisconnectAsync(); } catch { /* ignore */ }
await broker.Server.StopAsync();
broker.Server.Dispose();
}
}
[TestMethod]
public async Task Connect_Tls_Via_Socks5_PubSub_Works()
{
var tlsBroker = await Socks5TestHelper.StartTlsBrokerAsync();
try
{
using (tlsBroker.Certificate)
{
var brokerPort = Socks5TestHelper.GetTlsBrokerPort(tlsBroker);
await using var proxy = new FakeSocks5Server(new FakeSocks5ServerOptions());
using var client = new MqttClientFactory().CreateMqttClient();
var receivedTcs = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
client.ApplicationMessageReceivedAsync += e =>
{
receivedTcs.TrySetResult(Encoding.UTF8.GetString(e.ApplicationMessage.Payload.ToArray()));
return Task.CompletedTask;
};
var options = new MqttClientOptionsBuilder()
.WithTcpServer("localhost", brokerPort)
.WithTlsOptions(o => o
.WithSslProtocols(System.Security.Authentication.SslProtocols.Tls12)
.WithCertificateValidationHandler(_ => true))
.WithSocks5Proxy(p => p.WithHost("127.0.0.1").WithPort(proxy.Port))
.Build();
await client.ConnectAsync(options);
Assert.IsTrue(client.IsConnected);
await client.SubscribeAsync("tls/socks5");
await client.PublishStringAsync("tls/socks5", "tls-hello");
var payload = await receivedTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.AreEqual("tls-hello", payload);
// Validate that the proxy saw a CONNECT with domain forwarding.
Assert.HasCount(1, proxy.RecordedConnects);
Assert.AreEqual<byte>(0x03, proxy.RecordedConnects[0].AddressType);
Assert.AreEqual("localhost", proxy.RecordedConnects[0].Host);
try { await client.DisconnectAsync(); } catch { /* ignore */ }
}
}
finally
{
await tlsBroker.Server.StopAsync();
tlsBroker.Server.Dispose();
}
}
[TestMethod]
public async Task Connect_Tcp_Via_Socks5_When_Command_Not_Supported_Maps_To_CommandNotSupported()
{
// Server replies with REP=0x07 ("Command not supported / protocol error") per RFC 1928 §6.
await using var proxy = new FakeSocks5Server(new FakeSocks5ServerOptions
{
ConnectReplyCode = 0x07
});
using var client = new MqttClientFactory().CreateMqttClient();
var options = new MqttClientOptionsBuilder()
.WithTcpServer("broker.example", 1883)
.WithSocks5Proxy(p => p.WithHost("127.0.0.1").WithPort(proxy.Port))
.Build();
var ex = await Assert.ThrowsExactlyAsync<MqttProxyException>(() => client.ConnectAsync(options));
Assert.AreEqual(MqttProxyErrorCode.ProxyCommandNotSupported, ex.ErrorCode);
}
[TestMethod]
public async Task Connect_Tcp_Via_Socks5_Parses_DomainName_BindAddress_In_Reply()
{
// RFC 1928 §6 allows ATYP=0x03 (DOMAINNAME) in the CONNECT reply with a variable-length
// BND.ADDR. Verify the client correctly parses the length-prefixed bind address and
// completes the handshake even when the server uses a non-IP BND.ADDR.
var broker = await Socks5TestHelper.StartBrokerAsync();
var brokerPort = Socks5TestHelper.GetBrokerPort(broker);
await using var proxy = new FakeSocks5Server(new FakeSocks5ServerOptions
{
ReplyAddressType = 0x03,
ReplyBindHost = "proxy.example.internal",
ReplyBindPort = 4242
});
using var client = new MqttClientFactory().CreateMqttClient();
var connectOptions = new MqttClientOptionsBuilder()
.WithTcpServer("127.0.0.1", brokerPort)
.WithSocks5Proxy(p => p.WithHost("127.0.0.1").WithPort(proxy.Port))
.Build();
try
{
await client.ConnectAsync(connectOptions);
Assert.IsTrue(client.IsConnected);
}
finally
{
try { await client.DisconnectAsync(); } catch { /* ignore */ }
await broker.Server.StopAsync();
broker.Server.Dispose();
}
}
}
@@ -0,0 +1,112 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using MQTTnet.Extensions.Socks5;
namespace MQTTnet.Tests.Extensions.Socks5;
[TestClass]
public sealed class Socks5ProxyOptionsValidation_Tests
{
[TestMethod]
public void Builder_Build_Without_Host_Throws()
{
var builder = new Socks5ProxyOptionsBuilder();
Assert.ThrowsExactly<InvalidOperationException>(() => builder.Build());
}
[TestMethod]
public void Builder_Build_With_Invalid_Port_Throws()
{
var builder = new Socks5ProxyOptionsBuilder()
.WithHost("127.0.0.1")
.WithPort(0);
Assert.ThrowsExactly<InvalidOperationException>(() => builder.Build());
}
[TestMethod]
public void Builder_Build_With_NonPositive_HandshakeTimeout_Throws()
{
var builder = new Socks5ProxyOptionsBuilder()
.WithHost("127.0.0.1")
.WithHandshakeTimeout(TimeSpan.Zero);
Assert.ThrowsExactly<InvalidOperationException>(() => builder.Build());
}
[TestMethod]
public void Builder_Build_With_Password_But_Without_Username_Throws()
{
var builder = new Socks5ProxyOptionsBuilder()
.WithHost("127.0.0.1")
.WithCredentials(null, new byte[] { 0x01, 0x02 });
Assert.ThrowsExactly<InvalidOperationException>(() => builder.Build());
}
[TestMethod]
public void Builder_Build_With_Username_But_Without_Password_Throws()
{
var builder = new Socks5ProxyOptionsBuilder()
.WithHost("127.0.0.1")
.WithCredentials("alice", Array.Empty<byte>());
Assert.ThrowsExactly<InvalidOperationException>(() => builder.Build());
}
[TestMethod]
public void StreamProvider_Ctor_With_Invalid_Port_Throws()
{
var options = new Socks5ProxyOptions
{
Host = "127.0.0.1",
Port = 70000
};
Assert.ThrowsExactly<ArgumentOutOfRangeException>(() => _ = new Socks5StreamProvider(options));
}
[TestMethod]
public void StreamProvider_Ctor_With_NonPositive_HandshakeTimeout_Throws()
{
var options = new Socks5ProxyOptions
{
Host = "127.0.0.1",
Port = 1080,
HandshakeTimeout = TimeSpan.Zero
};
Assert.ThrowsExactly<ArgumentOutOfRangeException>(() => _ = new Socks5StreamProvider(options));
}
[TestMethod]
public void StreamProvider_Ctor_With_Password_But_Without_Username_Throws()
{
var options = new Socks5ProxyOptions
{
Host = "127.0.0.1",
Port = 1080,
Password = new byte[] { 0x01 }
};
Assert.ThrowsExactly<ArgumentException>(() => _ = new Socks5StreamProvider(options));
}
[TestMethod]
public void StreamProvider_Ctor_With_Username_But_Without_Password_Throws()
{
var options = new Socks5ProxyOptions
{
Host = "127.0.0.1",
Port = 1080,
Username = "alice",
Password = Array.Empty<byte>()
};
Assert.ThrowsExactly<ArgumentException>(() => _ = new Socks5StreamProvider(options));
}
}
@@ -0,0 +1,97 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.IO;
using System.Net;
using System.Net.Security;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Threading.Tasks;
using MQTTnet.Certificates;
using MQTTnet.Server;
namespace MQTTnet.Tests.Extensions.Socks5;
internal static class Socks5TestHelper
{
public static async Task<BrokerHandle> StartBrokerAsync()
{
var factory = new MqttServerFactory();
var options = factory.CreateServerOptionsBuilder()
.WithDefaultEndpoint()
.WithDefaultEndpointPort(0)
.Build();
var server = factory.CreateMqttServer(options);
await server.StartAsync();
return new BrokerHandle(server, options);
}
public static int GetBrokerPort(BrokerHandle handle)
{
return handle.Options.DefaultEndpointOptions.Port;
}
public static async Task<TlsBrokerHandle> StartTlsBrokerAsync()
{
var certificate = CreateSelfSignedServerCertificate();
var factory = new MqttServerFactory();
var options = factory.CreateServerOptionsBuilder()
.WithoutDefaultEndpoint()
.WithEncryptedEndpoint()
.WithEncryptedEndpointPort(0)
.WithEncryptionCertificate(new InMemoryCertificateProvider(certificate))
.Build();
var server = factory.CreateMqttServer(options);
await server.StartAsync();
return new TlsBrokerHandle(server, options, certificate);
}
public static int GetTlsBrokerPort(TlsBrokerHandle handle)
{
return handle.Options.TlsEndpointOptions.Port;
}
public static X509Certificate2 CreateSelfSignedServerCertificate()
{
var sanBuilder = new SubjectAlternativeNameBuilder();
sanBuilder.AddIpAddress(IPAddress.Loopback);
sanBuilder.AddIpAddress(IPAddress.IPv6Loopback);
sanBuilder.AddDnsName("localhost");
using var rsa = RSA.Create(2048);
var certRequest = new CertificateRequest("CN=localhost", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
certRequest.CertificateExtensions.Add(
new X509KeyUsageExtension(X509KeyUsageFlags.DataEncipherment | X509KeyUsageFlags.KeyEncipherment | X509KeyUsageFlags.DigitalSignature, false));
certRequest.CertificateExtensions.Add(sanBuilder.Build());
using var cert = certRequest.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-10), DateTimeOffset.UtcNow.AddMinutes(60));
#pragma warning disable SYSLIB0057
return new X509Certificate2(
cert.Export(X509ContentType.Pfx),
(string)null,
X509KeyStorageFlags.MachineKeySet | X509KeyStorageFlags.Exportable);
#pragma warning restore SYSLIB0057
}
internal sealed record BrokerHandle(MqttServer Server, MqttServerOptions Options);
internal sealed record TlsBrokerHandle(MqttServer Server, MqttServerOptions Options, X509Certificate2 Certificate);
sealed class InMemoryCertificateProvider : ICertificateProvider
{
readonly X509Certificate2 _certificate;
public InMemoryCertificateProvider(X509Certificate2 certificate)
{
_certificate = certificate;
}
public X509Certificate2 GetCertificate() => _certificate;
}
}