using System.Net; using System.Net.Sockets; namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests; /// /// The smallest broker that can complete an MQTT 3.1.1 handshake: accept TCP, answer CONNECT with /// a CONNACK, answer PINGREQ with PINGRESP, and nothing else. That is enough for MQTTnet to report /// a real connection — which is what makes a real drop () drive a real /// DisconnectedAsync and a real reconnect, rather than a test seam standing in for one. /// /// /// Shared across suites rather than nested in one, because both the connection-level and the /// driver-level rejection tests need it: the driver-level one is the pin for the actual /// operator-visible symptom (a rejected CONNECT must not report Healthy). /// internal sealed class MiniBroker : IDisposable { /// CONNACK "Connection Accepted" (MQTT 3.1.1 return code 0). internal const byte ReturnCodeAccepted = 0x00; /// CONNACK "Identifier rejected" — MQTTnet maps it to ClientIdentifierNotValid. internal const byte ReturnCodeIdentifierRejected = 0x02; /// CONNACK "Server unavailable" — MQTTnet maps it to ServerUnavailable (transient). internal const byte ReturnCodeServerUnavailable = 0x03; /// CONNACK "Bad user name or password" — MQTTnet maps it to BadUserNameOrPassword. internal const byte ReturnCodeBadUserNameOrPassword = 0x04; /// CONNACK "Not authorized" — MQTTnet maps it to NotAuthorized (unrecoverable). internal const byte ReturnCodeNotAuthorized = 0x05; private static readonly byte[] PingResp = [0xD0, 0x00]; private readonly CancellationTokenSource _cts = new(); private readonly HashSet _live = []; private readonly TcpListener _listener; private int _acceptedCount; public MiniBroker() { _listener = new TcpListener(IPAddress.Loopback, 0); _listener.Start(); Port = ((IPEndPoint)_listener.LocalEndpoint).Port; _ = Task.Run(AcceptLoopAsync); } public int Port { get; } /// When set, TCP is still accepted but CONNECT is never answered (frozen peer). public bool Blackhole { get; set; } /// /// The MQTT 3.1.1 return code answered to every CONNECT. Anything but /// is a broker that completes the handshake and then /// refuses — the shape MQTTnet reports as a result rather than an exception, and the one /// a real Mosquitto with the wrong password produces. /// public byte ConnAckReturnCode { get; set; } = ReturnCodeAccepted; /// Total TCP connections accepted since construction — counts reconnect attempts. public int AcceptedCount => Volatile.Read(ref _acceptedCount); /// /// Sockets the broker still has open. Drops to zero only once the peer actually closes, so a /// leaked-but-live client keeps this above zero. /// public int LiveConnections { get { lock (_live) { return _live.Count; } } } /// Kills every established session — the "broker went away" event. public void DropAll() { lock (_live) { foreach (var client in _live) { try { client.Close(); } catch (Exception) { // Already gone. } } _live.Clear(); } } public void Dispose() { _cts.Cancel(); _listener.Stop(); DropAll(); _cts.Dispose(); } private async Task AcceptLoopAsync() { try { while (!_cts.IsCancellationRequested) { var client = await _listener.AcceptTcpClientAsync(_cts.Token); Interlocked.Increment(ref _acceptedCount); lock (_live) { _live.Add(client); } _ = Task.Run(() => ServeAsync(client)); } } catch (Exception) { // Listener stopped / token cancelled — the fixture is going away. } } private async Task ServeAsync(TcpClient client) { var buffer = new byte[1024]; try { var stream = client.GetStream(); while (!_cts.IsCancellationRequested) { var read = await stream.ReadAsync(buffer, _cts.Token); if (read == 0) { break; // peer closed — this is how a disposed client stops counting as live } if (Blackhole) { continue; } var packetType = buffer[0] & 0xF0; if (packetType == 0x10) { await stream.WriteAsync(new byte[] { 0x20, 0x02, 0x00, ConnAckReturnCode }, _cts.Token); // A real broker closes the socket straight after refusing, which is what drives // MQTTnet's DisconnectedAsync. Mirroring it keeps the fake honest. if (ConnAckReturnCode != ReturnCodeAccepted) { break; } } else if (packetType == 0xC0) { await stream.WriteAsync(PingResp, _cts.Token); } } } catch (Exception) { // Socket torn down by either side. } finally { lock (_live) { _live.Remove(client); } try { client.Dispose(); } catch (Exception) { // Already gone. } } } }