From ab6ebaa39c76dea8544e7e67eb1921b7ae77cb86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Sim=C3=B5es?= Date: Fri, 10 Jul 2026 15:10:49 +0100 Subject: [PATCH 1/3] Add companion app to fully test sockets in unit tests - Add .NET 10 console app. - Add documentation for companion app. - Add socket round trip unit tests. Assisted by Claude Sonnet 4.6 --- .gitignore | 3 + .runsettings | 6 + Tests/NetworkTestCompanion/CommandServer.cs | 275 ++++++++++++++++++ Tests/NetworkTestCompanion/FirewallHelper.cs | 170 +++++++++++ .../NetworkTestCompanion.csproj | 14 + Tests/NetworkTestCompanion/Program.cs | 95 ++++++ Tests/NetworkTestCompanion/TcpEchoServer.cs | 82 ++++++ Tests/NetworkTestCompanion/UdpEchoServer.cs | 68 +++++ Tests/README.md | 128 ++++++++ Tests/SocketTests/CompanionClient.cs | 84 ++++++ Tests/SocketTests/SocketPair.cs | 4 + Tests/SocketTests/SocketRoundTripTests.cs | 214 ++++++++++++++ Tests/SocketTests/SocketTests.nfproj | 53 ++++ Tests/SocketTests/SocketTools.cs | 8 + Tests/SocketTests/packages.config | 1 + Tests/SocketTests/packages.lock.json | 6 + nanoFramework.System.Net.sln | 3 + 17 files changed, 1214 insertions(+) create mode 100644 Tests/NetworkTestCompanion/CommandServer.cs create mode 100644 Tests/NetworkTestCompanion/FirewallHelper.cs create mode 100644 Tests/NetworkTestCompanion/NetworkTestCompanion.csproj create mode 100644 Tests/NetworkTestCompanion/Program.cs create mode 100644 Tests/NetworkTestCompanion/TcpEchoServer.cs create mode 100644 Tests/NetworkTestCompanion/UdpEchoServer.cs create mode 100644 Tests/README.md create mode 100644 Tests/SocketTests/CompanionClient.cs create mode 100644 Tests/SocketTests/SocketRoundTripTests.cs diff --git a/.gitignore b/.gitignore index 36fda64c..2f5890b0 100644 --- a/.gitignore +++ b/.gitignore @@ -254,3 +254,6 @@ paket-files/ #SoundCloud *.sonarqube/ .sonarlint + +# Auto-generated test configuration (produced from .runsettings at build time) +Tests/SocketTests/TestConfiguration.cs diff --git a/.runsettings b/.runsettings index ed33e902..0acfbb03 100644 --- a/.runsettings +++ b/.runsettings @@ -7,6 +7,12 @@ net48 x64 + + + + + None False diff --git a/Tests/NetworkTestCompanion/CommandServer.cs b/Tests/NetworkTestCompanion/CommandServer.cs new file mode 100644 index 00000000..9c066745 --- /dev/null +++ b/Tests/NetworkTestCompanion/CommandServer.cs @@ -0,0 +1,275 @@ +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace NetworkTestCompanion; + +/// +/// Control channel (default port 11000). Accepts newline-delimited JSON commands from MCU tests +/// and manages the lifecycle of TCP/UDP echo servers on demand. +/// +/// Supported commands: +/// { "cmd": "ping" } +/// { "cmd": "start_tcp_echo", "port": N } +/// { "cmd": "start_udp_echo", "port": N } +/// { "cmd": "stop", "port": N } +/// { "cmd": "stop_all" } +/// { "cmd": "connect_to", "host": "...", "port": N } +/// +internal sealed class CommandServer : IDisposable +{ + private readonly TcpListener _listener; + private readonly IPAddress _bindAddress; + private readonly CancellationTokenSource _cts = new(); + private readonly Dictionary _activeServers = []; + private readonly Lock _lock = new(); + private Task? _acceptLoop; + + internal CommandServer(IPAddress bindAddress, int port) + { + _bindAddress = bindAddress; + _listener = new TcpListener(bindAddress, port); + } + + internal void Start() + { + _listener.Start(); + _acceptLoop = AcceptLoopAsync(_cts.Token); + } + + private async Task AcceptLoopAsync(CancellationToken ct) + { + while (!ct.IsCancellationRequested) + { + TcpClient client; + try + { + client = await _listener.AcceptTcpClientAsync(ct); + } + catch (OperationCanceledException) + { + break; + } + catch (Exception ex) + { + Console.Error.WriteLine($"[CMD] Accept error: {ex.Message}"); + continue; + } + + _ = HandleClientAsync(client, ct); + } + } + + private async Task HandleClientAsync(TcpClient client, CancellationToken ct) + { + using (client) + { + using var reader = new StreamReader(client.GetStream(), Encoding.UTF8, leaveOpen: true); + using var writer = new StreamWriter(client.GetStream(), Encoding.UTF8, leaveOpen: true) { AutoFlush = true }; + + try + { + string? line; + while ((line = await reader.ReadLineAsync(ct)) != null) + { + var response = ProcessCommand(line.Trim()); + await writer.WriteLineAsync(response); + } + } + catch (OperationCanceledException) { } + catch (IOException) { } + catch (Exception ex) + { + Console.Error.WriteLine($"[CMD] Handler error: {ex.Message}"); + } + } + } + + private string ProcessCommand(string json) + { + JsonNode? node; + try + { + node = JsonNode.Parse(json); + if (node == null) return Error("empty command"); + } + catch + { + return Error("invalid JSON"); + } + + var cmd = node["cmd"]?.GetValue(); + return cmd switch + { + "ping" => Ok(new { ip = _bindAddress.ToString() }), + "start_tcp_echo" => StartTcpEcho(node), + "start_udp_echo" => StartUdpEcho(node), + "stop" => Stop(node), + "stop_all" => StopAll(), + "connect_to" => ConnectTo(node), + _ => Error($"unknown command: {cmd}") + }; + } + + private string StartTcpEcho(JsonNode node) + { + if (!TryGetPort(node, out var port, out var err)) return err!; + + lock (_lock) + { + if (_activeServers.ContainsKey(port)) + return Error($"port {port} already in use"); + + var server = new TcpEchoServer(_bindAddress, port); + try + { + server.Start(); + } + catch (Exception ex) + { + server.Dispose(); + return Error(ex.Message); + } + + _activeServers[port] = server; + } + + Console.WriteLine($"[CMD] TCP echo started on port {port}"); + return Ok(); + } + + private string StartUdpEcho(JsonNode node) + { + if (!TryGetPort(node, out var port, out var err)) return err!; + + lock (_lock) + { + if (_activeServers.ContainsKey(port)) + return Error($"port {port} already in use"); + + var server = new UdpEchoServer(_bindAddress, port); + try + { + server.Start(); + } + catch (Exception ex) + { + server.Dispose(); + return Error(ex.Message); + } + + _activeServers[port] = server; + } + + Console.WriteLine($"[CMD] UDP echo started on port {port}"); + return Ok(); + } + + private string Stop(JsonNode node) + { + if (!TryGetPort(node, out var port, out var err)) return err!; + + lock (_lock) + { + if (!_activeServers.TryGetValue(port, out var server)) + return Error($"no server on port {port}"); + + server.Dispose(); + _activeServers.Remove(port); + } + + Console.WriteLine($"[CMD] Stopped server on port {port}"); + return Ok(); + } + + private string StopAll() + { + lock (_lock) + { + foreach (var server in _activeServers.Values) + server.Dispose(); + _activeServers.Clear(); + } + + Console.WriteLine("[CMD] All servers stopped"); + return Ok(); + } + + private string ConnectTo(JsonNode node) + { + var host = node["host"]?.GetValue(); + if (string.IsNullOrEmpty(host)) return Error("missing 'host'"); + if (!TryGetPort(node, out var port, out var err)) return err!; + + // Connect synchronously so the connection is in the MCU's listen backlog + // before we return ok and the MCU calls Accept(). + // The client is kept alive asynchronously so the connection isn't torn down + // before the MCU has had time to accept - a successful Accept() is sufficient + // proof of connectivity; no probe exchange is needed. + TcpClient? connectClient = null; + try + { + connectClient = new TcpClient(); + if (!connectClient.ConnectAsync(host, port).Wait(TimeSpan.FromSeconds(5))) + { + connectClient.Dispose(); + return Error($"connect to {host}:{port} timed out"); + } + + Console.WriteLine($"[CMD] connect_to {host}:{port} succeeded"); + + // Close after the MCU has had time to call Accept(). + var clientToClose = connectClient; + _ = Task.Delay(2000).ContinueWith(_ => clientToClose.Dispose()); + + return Ok(); + } + catch (Exception ex) + { + connectClient?.Dispose(); + Console.Error.WriteLine($"[CMD] connect_to {host}:{port} failed: {ex.Message}"); + return Error(ex.Message); + } + } + + private static bool TryGetPort(JsonNode node, out int port, out string? error) + { + port = 0; + error = null; + var portNode = node["port"]; + if (portNode == null) { error = Error("missing 'port'"); return false; } + try { port = portNode.GetValue(); return true; } + catch { error = Error("'port' must be an integer"); return false; } + } + + private static string Ok(object? extra = null) + { + if (extra == null) return "{\"ok\":true}"; + var extraJson = JsonSerializer.Serialize(extra); + // Merge { "ok": true } with the extra object + var merged = $"{{\"ok\":true,{extraJson.TrimStart('{').TrimEnd('}')}}}"; + return merged.Replace(",}", "}"); + } + + private static string Error(string message) => + JsonSerializer.Serialize(new { ok = false, error = message }); + + public void Dispose() + { + _cts.Cancel(); + _listener.Stop(); + lock (_lock) + { + foreach (var server in _activeServers.Values) + server.Dispose(); + _activeServers.Clear(); + } + _acceptLoop?.Wait(TimeSpan.FromSeconds(2)); + _cts.Dispose(); + } +} diff --git a/Tests/NetworkTestCompanion/FirewallHelper.cs b/Tests/NetworkTestCompanion/FirewallHelper.cs new file mode 100644 index 00000000..c86bb4f7 --- /dev/null +++ b/Tests/NetworkTestCompanion/FirewallHelper.cs @@ -0,0 +1,170 @@ +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Security.Principal; + +namespace NetworkTestCompanion; + +internal static class FirewallHelper +{ + private const string RulePrefix = "nF-TestCompanion"; + + internal static void Setup(int[] tcpPorts, int[] udpPorts) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + SetupWindows(tcpPorts, udpPorts); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + PrintMacOsGuidance(); + } + else + { + SetupLinux(tcpPorts, udpPorts); + } + } + + internal static void Remove() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + RemoveWindows(); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + Console.WriteLine("macOS: no rules were added automatically - nothing to remove."); + } + else + { + Console.WriteLine("Linux: remove any ufw rules you added manually with 'sudo ufw delete allow /tcp'."); + } + } + + [System.Runtime.Versioning.SupportedOSPlatform("windows")] + private static void SetupWindows(int[] tcpPorts, int[] udpPorts) + { + if (!IsElevatedWindows()) + { + Console.Error.WriteLine("ERROR: --setup-firewall requires administrator privileges on Windows."); + Console.Error.WriteLine("Re-run this command from an elevated prompt:"); + Console.Error.WriteLine($" runas /user:Administrator \"{Environment.ProcessPath}\" --setup-firewall"); + Environment.Exit(1); + } + + foreach (var port in tcpPorts) + { + RunNetsh($"advfirewall firewall add rule name=\"{RulePrefix}-TCP-{port}\" dir=in action=allow protocol=TCP localport={port}"); + } + + foreach (var port in udpPorts) + { + RunNetsh($"advfirewall firewall add rule name=\"{RulePrefix}-UDP-{port}\" dir=in action=allow protocol=UDP localport={port}"); + } + + Console.WriteLine($"Windows firewall rules added for TCP ports [{string.Join(", ", tcpPorts)}] and UDP ports [{string.Join(", ", udpPorts)}]."); + Console.WriteLine($"Remove them later with: --remove-firewall"); + } + + [System.Runtime.Versioning.SupportedOSPlatform("windows")] + private static void RemoveWindows() + { + if (!IsElevatedWindows()) + { + Console.Error.WriteLine("ERROR: --remove-firewall requires administrator privileges on Windows."); + Environment.Exit(1); + } + + RunNetsh($"advfirewall firewall delete rule name=\"{RulePrefix}\""); + Console.WriteLine("Windows firewall rules removed."); + } + + private static void PrintMacOsGuidance() + { + Console.WriteLine("macOS: The Application Firewall will prompt you to Allow or Deny when the companion"); + Console.WriteLine("first starts listening. Click 'Allow' to permit inbound connections from MCU devices."); + Console.WriteLine("No automated action is needed."); + } + + private static void SetupLinux(int[] tcpPorts, int[] udpPorts) + { + var ufwActive = IsUfwActive(); + if (!ufwActive) + { + Console.WriteLine("Linux: no active ufw firewall detected - inbound connections should work without changes."); + return; + } + + Console.WriteLine("Linux: ufw is active. Run the following commands to open the required ports:"); + + foreach (var port in tcpPorts) + { + Console.WriteLine($" sudo ufw allow {port}/tcp"); + } + + foreach (var port in udpPorts) + { + Console.WriteLine($" sudo ufw allow {port}/udp"); + } + + Console.WriteLine("NOTE: for iptables/nftables environments, see Tests/README.md for equivalent rules."); + } + + private static bool IsUfwActive() + { + try + { + var psi = new ProcessStartInfo("ufw", "status") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + }; + + using var proc = Process.Start(psi); + + if (proc == null) + { + return false; + } + + var output = proc.StandardOutput.ReadToEnd(); + proc.WaitForExit(); + + return output.Contains("Status: active", StringComparison.OrdinalIgnoreCase); + } + catch + { + return false; + } + } + + private static void RunNetsh(string args) + { + var psi = new ProcessStartInfo("netsh", args) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + }; + + using var proc = Process.Start(psi) ?? throw new InvalidOperationException("Failed to start netsh."); + proc.WaitForExit(); + + if (proc.ExitCode != 0) + { + var err = proc.StandardError.ReadToEnd().Trim(); + throw new InvalidOperationException($"netsh failed (exit {proc.ExitCode}): {err}"); + } + } + + [System.Runtime.Versioning.SupportedOSPlatform("windows")] + private static bool IsElevatedWindows() + { + using var identity = WindowsIdentity.GetCurrent(); + var principal = new WindowsPrincipal(identity); + return principal.IsInRole(WindowsBuiltInRole.Administrator); + } +} diff --git a/Tests/NetworkTestCompanion/NetworkTestCompanion.csproj b/Tests/NetworkTestCompanion/NetworkTestCompanion.csproj new file mode 100644 index 00000000..7d7040b1 --- /dev/null +++ b/Tests/NetworkTestCompanion/NetworkTestCompanion.csproj @@ -0,0 +1,14 @@ + + + + Exe + net10.0 + enable + enable + NetworkTestCompanion + NetworkTestCompanion + false + false + + + diff --git a/Tests/NetworkTestCompanion/Program.cs b/Tests/NetworkTestCompanion/Program.cs new file mode 100644 index 00000000..bd9e3d7f --- /dev/null +++ b/Tests/NetworkTestCompanion/Program.cs @@ -0,0 +1,95 @@ +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Net; +using System.Net.NetworkInformation; +using System.Net.Sockets; +using NetworkTestCompanion; + +const int DefaultControlPort = 11000; +int[] WellKnownTcpPorts = [DefaultControlPort, 7, 8, 9, 10, 80, 8080]; +int[] WellKnownUdpPorts = [7, 8, 9]; + +// Argument parsing +var cliArgs = Environment.GetCommandLineArgs().Skip(1).ToArray(); +bool setupFirewall = cliArgs.Contains("--setup-firewall"); +bool removeFirewall = cliArgs.Contains("--remove-firewall"); +int controlPort = DefaultControlPort; + +// Listener binds to Any so it accepts both LAN (MCU) and loopback (emulator) connections. +// --ip overrides the bind address AND the reported LAN IP. +IPAddress bindAddress = IPAddress.Any; +IPAddress lanIp = ResolveLanIp(); + +for (int i = 0; i < cliArgs.Length; i++) +{ + if (cliArgs[i] == "--control-port" && i + 1 < cliArgs.Length) + { + controlPort = int.Parse(cliArgs[++i]); + } + else if (cliArgs[i] == "--ip" && i + 1 < cliArgs.Length) + { + bindAddress = IPAddress.Parse(cliArgs[++i]); + lanIp = bindAddress; + } +} + +// Firewall helpers +if (setupFirewall) +{ + FirewallHelper.Setup(WellKnownTcpPorts, WellKnownUdpPorts); + return; +} + +if (removeFirewall) +{ + FirewallHelper.Remove(); + return; +} + +// Normal run: start control channel +Console.WriteLine($".NET nanoFramework Network Test Companion"); +Console.WriteLine($" LAN IP : {lanIp} (use this in TestConfiguration.CompanionIP for real hardware)"); +Console.WriteLine($" Control port: {controlPort}"); +Console.WriteLine($" Press Ctrl+C to stop."); +Console.WriteLine(); + +using var cts = new CancellationTokenSource(); +Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); }; + +using var commandServer = new CommandServer(bindAddress, controlPort); +commandServer.Start(); + +Console.WriteLine($"READY ip={lanIp} port={controlPort}"); + +try +{ + await Task.Delay(Timeout.Infinite, cts.Token); +} +catch (OperationCanceledException) { } + +Console.WriteLine("Shutting down."); + +static IPAddress ResolveLanIp() +{ + foreach (var iface in NetworkInterface.GetAllNetworkInterfaces()) + { + if (iface.OperationalStatus != OperationalStatus.Up) + { + continue; + } + + if (iface.NetworkInterfaceType is NetworkInterfaceType.Loopback or NetworkInterfaceType.Tunnel) continue; + { + foreach (var addr in iface.GetIPProperties().UnicastAddresses) + { + if (addr.Address.AddressFamily == AddressFamily.InterNetwork) + { + return addr.Address; + } + } + } + } + + return IPAddress.Loopback; +} diff --git a/Tests/NetworkTestCompanion/TcpEchoServer.cs b/Tests/NetworkTestCompanion/TcpEchoServer.cs new file mode 100644 index 00000000..3ed931cf --- /dev/null +++ b/Tests/NetworkTestCompanion/TcpEchoServer.cs @@ -0,0 +1,82 @@ +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Net; +using System.Net.Sockets; + +namespace NetworkTestCompanion; + +/// +/// Listens on a TCP port and echoes all received bytes back to the sender. +/// +internal sealed class TcpEchoServer : IDisposable +{ + private readonly TcpListener _listener; + private readonly CancellationTokenSource _cts = new(); + private Task? _acceptLoop; + + internal int Port { get; } + + internal TcpEchoServer(IPAddress bindAddress, int port) + { + Port = port; + _listener = new TcpListener(bindAddress, port); + } + + internal void Start() + { + _listener.Start(); + _acceptLoop = AcceptLoopAsync(_cts.Token); + } + + private async Task AcceptLoopAsync(CancellationToken ct) + { + while (!ct.IsCancellationRequested) + { + TcpClient client; + try + { + client = await _listener.AcceptTcpClientAsync(ct); + } + catch (OperationCanceledException) + { + break; + } + catch (Exception ex) + { + Console.Error.WriteLine($"[TCP:{Port}] Accept error: {ex.Message}"); + continue; + } + + _ = HandleClientAsync(client, ct); + } + } + + private static async Task HandleClientAsync(TcpClient client, CancellationToken ct) + { + using (client) + { + var stream = client.GetStream(); + var buf = new byte[4096]; + try + { + int read; + + while ((read = await stream.ReadAsync(buf, ct)) > 0) + { + await stream.WriteAsync(buf.AsMemory(0, read), ct); + } + } + catch (OperationCanceledException) { } + catch (IOException) { } + } + } + + public void Dispose() + { + _cts.Cancel(); + _listener.Stop(); + _acceptLoop?.Wait(TimeSpan.FromSeconds(2)); + _cts.Dispose(); + } +} diff --git a/Tests/NetworkTestCompanion/UdpEchoServer.cs b/Tests/NetworkTestCompanion/UdpEchoServer.cs new file mode 100644 index 00000000..7dabf410 --- /dev/null +++ b/Tests/NetworkTestCompanion/UdpEchoServer.cs @@ -0,0 +1,68 @@ +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Net; +using System.Net.Sockets; + +namespace NetworkTestCompanion; + +/// +/// Listens on a UDP port and echoes each received datagram back to its sender. +/// +internal sealed class UdpEchoServer : IDisposable +{ + private readonly UdpClient _udpClient; + private readonly CancellationTokenSource _cts = new(); + private Task? _receiveLoop; + + internal int Port { get; } + + internal UdpEchoServer(IPAddress bindAddress, int port) + { + Port = port; + _udpClient = new UdpClient(new IPEndPoint(bindAddress, port)); + } + + internal void Start() + { + _receiveLoop = ReceiveLoopAsync(_cts.Token); + } + + private async Task ReceiveLoopAsync(CancellationToken ct) + { + while (!ct.IsCancellationRequested) + { + UdpReceiveResult result; + try + { + result = await _udpClient.ReceiveAsync(ct); + } + catch (OperationCanceledException) + { + break; + } + catch (Exception ex) + { + Console.Error.WriteLine($"[UDP:{Port}] Receive error: {ex.Message}"); + continue; + } + + try + { + await _udpClient.SendAsync(result.Buffer.AsMemory(), result.RemoteEndPoint, ct); + } + catch (Exception ex) + { + Console.Error.WriteLine($"[UDP:{Port}] Send error: {ex.Message}"); + } + } + } + + public void Dispose() + { + _cts.Cancel(); + _udpClient.Close(); + _receiveLoop?.Wait(TimeSpan.FromSeconds(2)); + _cts.Dispose(); + } +} diff --git a/Tests/README.md b/Tests/README.md new file mode 100644 index 00000000..fbc72008 --- /dev/null +++ b/Tests/README.md @@ -0,0 +1,128 @@ +# Running the Unit Tests + +## Overview + +The tests target nanoFramework MCU devices (or the Win32 nanoCLR emulator). Some tests require a host-side peer - the **Network Test Companion** - which acts as a TCP/UDP echo server and accepts connections from the MCU under test. + +## Projects + +| Project | Needs companion | +|---|---| +| `IPAddressTests` | No - self-contained data-structure tests | +| `NetworkHelperTests` | No - tests IP acquisition only | +| `SocketTests` | Loopback-only tests: No. Future round-trip tests: Yes | + +--- + +## Network Test Companion + +The companion is a .NET 10 console app in `Tests/NetworkTestCompanion/`. It runs on the developer's PC and exposes: + +- A **control channel** (TCP, default port **11000**) that accepts newline-delimited JSON commands from MCU tests. +- Dynamic **TCP/UDP echo servers** spun up per test request. + +### Build + +```sh +dotnet build Tests/NetworkTestCompanion +``` + +### Run + +```sh +dotnet run --project Tests/NetworkTestCompanion +``` + +The companion prints its bound IP and port on startup: + +``` +READY ip=192.168.1.10 port=11000 +``` + +Use `--ip ` to override the bind address or `--control-port ` to change the control port. + +### Control channel commands + +Send newline-terminated JSON; receive a JSON response on the same connection. + +| Command | Description | +|---|---| +| `{"cmd":"ping"}` | Health check - returns `{"ok":true,"ip":""}` | +| `{"cmd":"start_tcp_echo","port":N}` | Start a TCP echo server on port N | +| `{"cmd":"start_udp_echo","port":N}` | Start a UDP echo server on port N | +| `{"cmd":"stop","port":N}` | Stop the server on port N | +| `{"cmd":"stop_all"}` | Stop all active servers | +| `{"cmd":"connect_to","host":"...","port":N}` | Companion connects as TCP client (MCU acts as server) | + +--- + +## Firewall configuration + +The companion must be reachable from MCU devices on the local network. Run the following **once** to open the required ports. + +### Windows (requires an elevated prompt) + +```bat +dotnet run --project Tests/NetworkTestCompanion -- --setup-firewall +``` + +This adds inbound firewall rules named `nF-TestCompanion-TCP-*` / `nF-TestCompanion-UDP-*`. To remove them: + +```bat +dotnet run --project Tests/NetworkTestCompanion -- --remove-firewall +``` + +### macOS + +The macOS Application Firewall will prompt you to **Allow** the companion when it first starts listening. Click **Allow**. No further action is needed. + +### Linux (ufw) + +If `ufw` is active, open the ports manually: + +```sh +sudo ufw allow 11000/tcp # control channel +sudo ufw allow 7/tcp +sudo ufw allow 7/udp +# add others as needed +``` + +### Linux (iptables / nftables) + +For environments without ufw, add equivalent rules: + +```sh +# iptables example +sudo iptables -A INPUT -p tcp --dport 11000 -j ACCEPT +sudo iptables -A INPUT -p tcp --dport 7 -j ACCEPT +sudo iptables -A INPUT -p udp --dport 7 -j ACCEPT + +# nftables example +sudo nft add rule inet filter input tcp dport 11000 accept +sudo nft add rule inet filter input tcp dport 7 accept +sudo nft add rule inet filter input udp dport 7 accept +``` + +CI pipeline authors must open the required ports in their runner configuration. + +--- + +## `.runsettings` parameters + +The `.runsettings` file at the repo root contains two parameters for the companion: + +| Parameter | Default | Description | +|---|---|---| +| `CompanionIP` | `127.0.0.1` | IP address of the PC running the companion | +| `CompanionControlPort` | `11000` | TCP port of the control channel | + +For **virtual device** (emulator) runs the defaults work as-is. + +For **real hardware** runs, set `CompanionIP` to the PC's LAN address - the address printed by the companion on startup: + +```xml + + + + +``` diff --git a/Tests/SocketTests/CompanionClient.cs b/Tests/SocketTests/CompanionClient.cs new file mode 100644 index 00000000..b93c03c4 --- /dev/null +++ b/Tests/SocketTests/CompanionClient.cs @@ -0,0 +1,84 @@ +// +// Copyright (c) .NET Foundation and Contributors +// See LICENSE file in the project root for full license information. +// + +using System; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading; + +namespace NFUnitTestSocketTests +{ + /// + /// Thin client for the Network Test Companion control channel. + /// Sends newline-terminated JSON commands and reads the JSON response. + /// Must use synchronous Socket calls (nanoFramework has no Task/async). + /// + internal sealed class CompanionClient : IDisposable + { + private readonly Socket _socket; + private readonly byte[] _recvBuf = new byte[128]; + + internal CompanionClient() + { + _socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + _socket.Connect(new IPEndPoint( + IPAddress.Parse(TestConfiguration.CompanionIP), + TestConfiguration.CompanionControlPort)); + + // Brief settle time so the companion is ready to read + Thread.Sleep(50); + } + + /// Returns true when the companion responds with {"ok":true}. + internal bool Ping() + { + var response = SendCommand("{\"cmd\":\"ping\"}"); + return response.IndexOf("\"ok\":true") >= 0; + } + + /// Asks the companion to start a TCP echo server on the given port. + internal bool StartTcpEcho(int port) + { + var response = SendCommand("{\"cmd\":\"start_tcp_echo\",\"port\":" + port + "}"); + return response.IndexOf("\"ok\":true") >= 0; + } + + /// Asks the companion to start a UDP echo server on the given port. + internal bool StartUdpEcho(int port) + { + var response = SendCommand("{\"cmd\":\"start_udp_echo\",\"port\":" + port + "}"); + return response.IndexOf("\"ok\":true") >= 0; + } + + /// Stops the echo server the companion is running on the given port. + internal bool Stop(int port) + { + var response = SendCommand("{\"cmd\":\"stop\",\"port\":" + port + "}"); + return response.IndexOf("\"ok\":true") >= 0; + } + + /// Asks the companion to open a TCP connection to the MCU acting as server. + internal bool ConnectTo(string host, int port) + { + var response = SendCommand("{\"cmd\":\"connect_to\",\"host\":\"" + host + "\",\"port\":" + port + "}"); + return response.IndexOf("\"ok\":true") >= 0; + } + + private string SendCommand(string json) + { + byte[] cmd = Encoding.UTF8.GetBytes(json + "\n"); + _socket.Send(cmd); + + // Give the companion time to act and respond + Thread.Sleep(100); + + int received = _socket.Receive(_recvBuf); + return new string(Encoding.UTF8.GetChars(_recvBuf, 0, received)); + } + + public void Dispose() => _socket.Close(); + } +} diff --git a/Tests/SocketTests/SocketPair.cs b/Tests/SocketTests/SocketPair.cs index c16e05e5..71c8bb44 100644 --- a/Tests/SocketTests/SocketPair.cs +++ b/Tests/SocketTests/SocketPair.cs @@ -79,12 +79,16 @@ private void CloseSocket(ref Socket socket) public void AssertDataReceived(int cBytes) { if (cBytes != bufSend.Length) + { throw new Exception("Recieve failed, wrong size " + cBytes + " " + bufSend.Length); + } for (int i = 0; i < bufReceive.Length; i++) { if (bufSend[i] != bufReceive[i]) + { throw new Exception("Receive failed, wrong data"); + } } } } diff --git a/Tests/SocketTests/SocketRoundTripTests.cs b/Tests/SocketTests/SocketRoundTripTests.cs new file mode 100644 index 00000000..0fb419af --- /dev/null +++ b/Tests/SocketTests/SocketRoundTripTests.cs @@ -0,0 +1,214 @@ +// +// Copyright (c) .NET Foundation and Contributors +// See LICENSE file in the project root for full license information. +// + +using nanoFramework.Networking; +using nanoFramework.TestFramework; +using System; +using System.Net; +using System.Net.NetworkInformation; +using System.Net.Sockets; +using System.Threading; + +namespace NFUnitTestSocketTests +{ + /// + /// Round-trip socket tests that require the Network Test Companion to be running + /// on the host PC. Start it with: + /// dotnet run --project Tests/NetworkTestCompanion + /// and update TestConfiguration.CompanionIP when testing on real hardware. + /// + [TestClass] + public class SocketRoundTripTests + { + private static bool _networkInitialized = false; + + [Setup] + public void Setup() + { + // Comment the next line to run these tests on real hardware with the companion running + Assert.SkipTest("Skipping round-trip tests: companion required - run on real hardware only"); + + // Bring up the network before any socket operation. + CancellationTokenSource cs = new CancellationTokenSource(30000); + bool connected = NetworkHelper.SetupAndConnectNetwork(requiresDateTime: false, token: cs.Token); + + if (!connected) + { + Assert.SkipTest($"Network not available ({NetworkHelper.Status}) - skipping round-trip tests"); + } + + _networkInitialized = true; + OutputHelper.WriteLine($"Network ready, status: {NetworkHelper.Status}"); + } + + [Cleanup] + public void Cleanup() + { + if (_networkInitialized) + { + NetworkHelper.Reset(); + _networkInitialized = false; + } + } + + [TestMethod] + public void RoundTrip_Tcp_SendReceive_Echo() + { + const int echoPort = 7001; + + using (var companion = new CompanionClient()) + { + Assert.IsTrue(companion.Ping(), "Companion not reachable"); + Assert.IsTrue(companion.StartTcpEcho(echoPort), "Failed to start TCP echo"); + + // Give the companion time to bind the listener + Thread.Sleep(100); + + Socket client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + client.Connect(new IPEndPoint(IPAddress.Parse(TestConfiguration.CompanionIP), echoPort)); + + byte[] sent = new byte[] { 0xAB, 0xCD, 0xEF }; + client.Send(sent); + + byte[] received = new byte[sent.Length]; + int totalRead = 0; + while (totalRead < sent.Length) + { + int n = client.Receive(received, totalRead, sent.Length - totalRead, SocketFlags.None); + Assert.IsTrue(n > 0, "Connection closed before all bytes received"); + totalRead += n; + } + + client.Close(); + + Assert.IsTrue(SocketTools.ArrayEquals(sent, received), "Echoed bytes do not match sent bytes"); + + companion.Stop(echoPort); + } + } + + [TestMethod] + public void RoundTrip_Tcp_LargeBuffer_Echo() + { + const int echoPort = 7002; + const int bufferSize = 1024; + + using (var companion = new CompanionClient()) + { + Assert.IsTrue(companion.Ping(), "Companion not reachable"); + Assert.IsTrue(companion.StartTcpEcho(echoPort), "Failed to start TCP echo"); + + Thread.Sleep(100); + + Socket client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + client.Connect(new IPEndPoint(IPAddress.Parse(TestConfiguration.CompanionIP), echoPort)); + + byte[] sent = new byte[bufferSize]; + + for (int i = 0; i < bufferSize; i++) + { + sent[i] = (byte)(i & 0xFF); + } + + client.Send(sent); + + byte[] received = new byte[bufferSize]; + int totalRead = 0; + while (totalRead < bufferSize) + { + int n = client.Receive(received, totalRead, bufferSize - totalRead, SocketFlags.None); + Assert.IsTrue(n > 0, "Connection closed before all bytes received"); + totalRead += n; + } + + client.Close(); + + Assert.IsTrue(SocketTools.ArrayEquals(sent, received), "Large buffer echo mismatch"); + + companion.Stop(echoPort); + } + } + + [TestMethod] + public void RoundTrip_Udp_SendReceive_Echo() + { + const int echoPort = 7003; + + using (var companion = new CompanionClient()) + { + Assert.IsTrue(companion.Ping(), "Companion not reachable"); + Assert.IsTrue(companion.StartUdpEcho(echoPort), "Failed to start UDP echo"); + + Thread.Sleep(100); + + Socket client = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); + client.Bind(new IPEndPoint(IPAddress.Any, 0)); + + EndPoint serverEndPoint = new IPEndPoint(IPAddress.Parse(TestConfiguration.CompanionIP), echoPort); + + byte[] sent = new byte[] { 0x11, 0x22, 0x33 }; + client.SendTo(sent, serverEndPoint); + + byte[] received = new byte[16]; + EndPoint remoteEndPoint = new IPEndPoint(IPAddress.Any, 0); + int n = client.ReceiveFrom(received, ref remoteEndPoint); + + client.Close(); + + Assert.AreEqual(sent.Length, n, "UDP echo returned wrong byte count"); + for (int i = 0; i < sent.Length; i++) + { + Assert.AreEqual(sent[i], received[i], $"UDP echo mismatch at byte {i}"); + } + + companion.Stop(echoPort); + } + } + + [TestMethod] + public void RoundTrip_Tcp_McuAsServer_CompanionConnects() + { + const int mcuListenPort = 7004; + + Socket server = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + server.Bind(new IPEndPoint(IPAddress.Any, mcuListenPort)); + server.Listen(1); + + using (var companion = new CompanionClient()) + { + Assert.IsTrue(companion.Ping(), "Companion not reachable"); + + // Resolve the MCU's own IP - the companion must connect back to it. + string mcuIp = GetLocalIp(); + Assert.IsTrue(companion.ConnectTo(mcuIp, mcuListenPort), "ConnectTo command failed"); + + // ConnectTo is synchronous on the companion side: by the time we get ok, + // the connection is already sitting in the listen backlog. + // Poll with 10 s timeout (microseconds) to avoid blocking forever. + Assert.IsTrue(server.Poll(10 * 1000 * 1000, SelectMode.SelectRead), "No connection received from companion within timeout"); + Socket accepted = server.Accept(); + + Assert.IsNotNull(accepted, "Accept returned null"); + + accepted.Close(); + } + + server.Close(); + } + + private static string GetLocalIp() + { + foreach (NetworkInterface ni in NetworkInterface.GetAllNetworkInterfaces()) + { + if (ni.IPv4Address != null && ni.IPv4Address != "0.0.0.0") + { + return ni.IPv4Address; + } + } + + return "0.0.0.0"; + } + } +} diff --git a/Tests/SocketTests/SocketTests.nfproj b/Tests/SocketTests/SocketTests.nfproj index 20a6ebbc..a5b9095d 100644 --- a/Tests/SocketTests/SocketTests.nfproj +++ b/Tests/SocketTests/SocketTests.nfproj @@ -26,11 +26,14 @@ + + + @@ -48,6 +51,9 @@ ..\..\packages\nanoFramework.System.IO.Streams.1.1.96\lib\System.IO.Streams.dll + + ..\..\packages\nanoFramework.System.Threading.1.1.52\lib\System.Threading.dll + @@ -56,6 +62,53 @@ + + + + + + + + + + + + <_CompanionIP>@(_CompanionIPItems) + <_CompanionPort>@(_CompanionPortItems) + <_CompanionIP Condition="'$(_CompanionIP)' == ''">127.0.0.1 + <_CompanionPort Condition="'$(_CompanionPort)' == ''">11000 + + + + <_TestConfigLines Include="// Auto-generated from .runsettings - do not edit directly." /> + <_TestConfigLines Include="//" /> + <_TestConfigLines Include="// Copyright (c) .NET Foundation and Contributors" /> + <_TestConfigLines Include="// See LICENSE file in the project root for full license information." /> + <_TestConfigLines Include="//" /> + <_TestConfigLines Include="namespace NFUnitTestSocketTests" /> + <_TestConfigLines Include="{" /> + <_TestConfigLines Include="%20%20%20%20internal static class TestConfiguration" /> + <_TestConfigLines Include="%20%20%20%20{" /> + <_TestConfigLines Include="%20%20%20%20%20%20%20%20internal const string CompanionIP = "$(_CompanionIP)"%3B" /> + <_TestConfigLines Include="%20%20%20%20%20%20%20%20internal const int CompanionControlPort = $(_CompanionPort)%3B" /> + <_TestConfigLines Include="%20%20%20%20}" /> + <_TestConfigLines Include="}" /> + + + + + + + diff --git a/Tests/SocketTests/SocketTools.cs b/Tests/SocketTests/SocketTools.cs index b822c5cf..c0b47116 100644 --- a/Tests/SocketTests/SocketTools.cs +++ b/Tests/SocketTests/SocketTools.cs @@ -32,7 +32,9 @@ static public long DottedDecimalToIp(byte a1, byte a2, byte a3, byte a4) static public IPAddress ParseAddress(string ipString) { if (ipString == null) + { throw new ArgumentNullException("WsdIPAddress.ipString must not be null."); + } ulong ipAddress = 0L; int lastIndex = 0; @@ -45,6 +47,7 @@ static public IPAddress ParseAddress(string ipString) { // Parse to '.' or end of IP address if (ipString[i] == '.' || i == length - 1) + { // If the IP starts with a '.' // or a segment is longer than 3 characters or shiftIndex > // last bit position throw. @@ -62,6 +65,7 @@ static public IPAddress ParseAddress(string ipString) shiftIndex += 8; mask <<= 8; } + } } return new IPAddress((long)ipAddress); @@ -77,7 +81,9 @@ static public bool ArrayEquals(bool[] array1, bool[] array2) for (int i = 0; i < array1.Length; i++) { if (array1[i] != array2[i]) + { return false; + } } return true; @@ -93,7 +99,9 @@ static public bool ArrayEquals(byte[] array1, byte[] array2) for (int i = 0; i < array1.Length; i++) { if (array1[i] != array2[i]) + { return false; + } } return true; diff --git a/Tests/SocketTests/packages.config b/Tests/SocketTests/packages.config index d98ca017..440a5fdc 100644 --- a/Tests/SocketTests/packages.config +++ b/Tests/SocketTests/packages.config @@ -3,5 +3,6 @@ + \ No newline at end of file diff --git a/Tests/SocketTests/packages.lock.json b/Tests/SocketTests/packages.lock.json index 2e756514..07b30599 100644 --- a/Tests/SocketTests/packages.lock.json +++ b/Tests/SocketTests/packages.lock.json @@ -20,6 +20,12 @@ "resolved": "1.3.42", "contentHash": "68HPjhersNpssbmEMUHdMw3073MHfGTfrkbRk9eILKbNPFfPFck7m4y9BlAi6DaguUJaeKxgyIojXF3SQrF8/A==" }, + "nanoFramework.System.Threading": { + "type": "Direct", + "requested": "[1.1.52, 1.1.52]", + "resolved": "1.1.52", + "contentHash": "kv+US/+7QKV1iT/snxBh032vwZ+3krJ4vujlSsvmS2nNj/nK64R3bq/ST3bCFquxHDD0mog8irtCBCsFazr4kA==" + }, "nanoFramework.TestFramework": { "type": "Direct", "requested": "[3.0.80, 3.0.80]", diff --git a/nanoFramework.System.Net.sln b/nanoFramework.System.Net.sln index c1615af2..e0b83847 100644 --- a/nanoFramework.System.Net.sln +++ b/nanoFramework.System.Net.sln @@ -14,6 +14,9 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{C9AF0EE0-09CD-4836-9881-9E62766D1CC4}" + ProjectSection(SolutionItems) = preProject + Tests\README.md = Tests\README.md + EndProjectSection EndProject Project("{11A8DD76-328B-46DF-9F39-F559912D0360}") = "NetworkHelperTests", "Tests\NetworkHelperTests\NetworkHelperTests.nfproj", "{07D7468C-F619-4E73-A431-0B47D450462B}" EndProject From 90267d364951a70110ad83c1601685e3b032c051 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Sim=C3=B5es?= Date: Fri, 10 Jul 2026 16:09:51 +0100 Subject: [PATCH 2/3] Fixes/changes following code review --- Tests/NetworkTestCompanion/FirewallHelper.cs | 60 +++------- Tests/NetworkTestCompanion/Program.cs | 2 +- Tests/README.md | 21 ++-- Tests/SocketTests/SocketRoundTripTests.cs | 116 +++++++++++-------- Tests/SocketTests/SocketTests.nfproj | 3 +- 5 files changed, 102 insertions(+), 100 deletions(-) diff --git a/Tests/NetworkTestCompanion/FirewallHelper.cs b/Tests/NetworkTestCompanion/FirewallHelper.cs index c86bb4f7..4d3f832d 100644 --- a/Tests/NetworkTestCompanion/FirewallHelper.cs +++ b/Tests/NetworkTestCompanion/FirewallHelper.cs @@ -27,11 +27,11 @@ internal static void Setup(int[] tcpPorts, int[] udpPorts) } } - internal static void Remove() + internal static void Remove(int[] tcpPorts, int[] udpPorts) { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - RemoveWindows(); + RemoveWindows(tcpPorts, udpPorts); } else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) { @@ -69,7 +69,7 @@ private static void SetupWindows(int[] tcpPorts, int[] udpPorts) } [System.Runtime.Versioning.SupportedOSPlatform("windows")] - private static void RemoveWindows() + private static void RemoveWindows(int[] tcpPorts, int[] udpPorts) { if (!IsElevatedWindows()) { @@ -77,8 +77,17 @@ private static void RemoveWindows() Environment.Exit(1); } - RunNetsh($"advfirewall firewall delete rule name=\"{RulePrefix}\""); - Console.WriteLine("Windows firewall rules removed."); + foreach (var port in tcpPorts) + { + RunNetsh($"advfirewall firewall delete rule name=\"{RulePrefix}-TCP-{port}\""); + } + + foreach (var port in udpPorts) + { + RunNetsh($"advfirewall firewall delete rule name=\"{RulePrefix}-UDP-{port}\""); + } + + Console.WriteLine($"Windows firewall rules removed for TCP ports [{string.Join(", ", tcpPorts)}] and UDP ports [{string.Join(", ", udpPorts)}]."); } private static void PrintMacOsGuidance() @@ -90,15 +99,11 @@ private static void PrintMacOsGuidance() private static void SetupLinux(int[] tcpPorts, int[] udpPorts) { - var ufwActive = IsUfwActive(); - if (!ufwActive) - { - Console.WriteLine("Linux: no active ufw firewall detected - inbound connections should work without changes."); - return; - } + // Print ufw instructions unconditionally: 'ufw status' requires elevated + // permissions and silently returns no output when run as a normal user, + // which would suppress necessary guidance even when ufw is active. + Console.WriteLine("Linux: if ufw is active, run the following commands to open the required ports:"); - Console.WriteLine("Linux: ufw is active. Run the following commands to open the required ports:"); - foreach (var port in tcpPorts) { Console.WriteLine($" sudo ufw allow {port}/tcp"); @@ -112,35 +117,6 @@ private static void SetupLinux(int[] tcpPorts, int[] udpPorts) Console.WriteLine("NOTE: for iptables/nftables environments, see Tests/README.md for equivalent rules."); } - private static bool IsUfwActive() - { - try - { - var psi = new ProcessStartInfo("ufw", "status") - { - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false - }; - - using var proc = Process.Start(psi); - - if (proc == null) - { - return false; - } - - var output = proc.StandardOutput.ReadToEnd(); - proc.WaitForExit(); - - return output.Contains("Status: active", StringComparison.OrdinalIgnoreCase); - } - catch - { - return false; - } - } - private static void RunNetsh(string args) { var psi = new ProcessStartInfo("netsh", args) diff --git a/Tests/NetworkTestCompanion/Program.cs b/Tests/NetworkTestCompanion/Program.cs index bd9e3d7f..fc93d721 100644 --- a/Tests/NetworkTestCompanion/Program.cs +++ b/Tests/NetworkTestCompanion/Program.cs @@ -43,7 +43,7 @@ if (removeFirewall) { - FirewallHelper.Remove(); + FirewallHelper.Remove(WellKnownTcpPorts, WellKnownUdpPorts); return; } diff --git a/Tests/README.md b/Tests/README.md index fbc72008..728128a7 100644 --- a/Tests/README.md +++ b/Tests/README.md @@ -10,7 +10,7 @@ The tests target nanoFramework MCU devices (or the Win32 nanoCLR emulator). Some |---|---| | `IPAddressTests` | No - self-contained data-structure tests | | `NetworkHelperTests` | No - tests IP acquisition only | -| `SocketTests` | Loopback-only tests: No. Future round-trip tests: Yes | +| `SocketTests` | Loopback-only tests: No. Round-trip tests (`SocketRoundTripTests`): Yes (requires companion) | --- @@ -82,9 +82,10 @@ If `ufw` is active, open the ports manually: ```sh sudo ufw allow 11000/tcp # control channel -sudo ufw allow 7/tcp -sudo ufw allow 7/udp -# add others as needed +sudo ufw allow 7001/tcp # TCP echo (RoundTrip_Tcp_SendReceive_Echo) +sudo ufw allow 7002/tcp # TCP echo (RoundTrip_Tcp_LargeBuffer_Echo) +sudo ufw allow 7003/udp # UDP echo (RoundTrip_Udp_SendReceive_Echo) +sudo ufw allow 7004/tcp # MCU-as-server (RoundTrip_Tcp_McuAsServer_CompanionConnects) ``` ### Linux (iptables / nftables) @@ -94,13 +95,17 @@ For environments without ufw, add equivalent rules: ```sh # iptables example sudo iptables -A INPUT -p tcp --dport 11000 -j ACCEPT -sudo iptables -A INPUT -p tcp --dport 7 -j ACCEPT -sudo iptables -A INPUT -p udp --dport 7 -j ACCEPT +sudo iptables -A INPUT -p tcp --dport 7001 -j ACCEPT +sudo iptables -A INPUT -p tcp --dport 7002 -j ACCEPT +sudo iptables -A INPUT -p udp --dport 7003 -j ACCEPT +sudo iptables -A INPUT -p tcp --dport 7004 -j ACCEPT # nftables example sudo nft add rule inet filter input tcp dport 11000 accept -sudo nft add rule inet filter input tcp dport 7 accept -sudo nft add rule inet filter input udp dport 7 accept +sudo nft add rule inet filter input tcp dport 7001 accept +sudo nft add rule inet filter input tcp dport 7002 accept +sudo nft add rule inet filter input udp dport 7003 accept +sudo nft add rule inet filter input tcp dport 7004 accept ``` CI pipeline authors must open the required ports in their runner configuration. diff --git a/Tests/SocketTests/SocketRoundTripTests.cs b/Tests/SocketTests/SocketRoundTripTests.cs index 0fb419af..e56757f2 100644 --- a/Tests/SocketTests/SocketRoundTripTests.cs +++ b/Tests/SocketTests/SocketRoundTripTests.cs @@ -69,23 +69,27 @@ public void RoundTrip_Tcp_SendReceive_Echo() Socket client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); client.Connect(new IPEndPoint(IPAddress.Parse(TestConfiguration.CompanionIP), echoPort)); - byte[] sent = new byte[] { 0xAB, 0xCD, 0xEF }; - client.Send(sent); - - byte[] received = new byte[sent.Length]; - int totalRead = 0; - while (totalRead < sent.Length) + try { - int n = client.Receive(received, totalRead, sent.Length - totalRead, SocketFlags.None); - Assert.IsTrue(n > 0, "Connection closed before all bytes received"); - totalRead += n; + byte[] sent = new byte[] { 0xAB, 0xCD, 0xEF }; + client.Send(sent); + + byte[] received = new byte[sent.Length]; + int totalRead = 0; + while (totalRead < sent.Length) + { + int n = client.Receive(received, totalRead, sent.Length - totalRead, SocketFlags.None); + Assert.IsTrue(n > 0, "Connection closed before all bytes received"); + totalRead += n; + } + + Assert.IsTrue(SocketTools.ArrayEquals(sent, received), "Echoed bytes do not match sent bytes"); + } + finally + { + client.Close(); + companion.Stop(echoPort); } - - client.Close(); - - Assert.IsTrue(SocketTools.ArrayEquals(sent, received), "Echoed bytes do not match sent bytes"); - - companion.Stop(echoPort); } } @@ -105,29 +109,33 @@ public void RoundTrip_Tcp_LargeBuffer_Echo() Socket client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); client.Connect(new IPEndPoint(IPAddress.Parse(TestConfiguration.CompanionIP), echoPort)); - byte[] sent = new byte[bufferSize]; - - for (int i = 0; i < bufferSize; i++) + try { - sent[i] = (byte)(i & 0xFF); - } - - client.Send(sent); + byte[] sent = new byte[bufferSize]; - byte[] received = new byte[bufferSize]; - int totalRead = 0; - while (totalRead < bufferSize) - { - int n = client.Receive(received, totalRead, bufferSize - totalRead, SocketFlags.None); - Assert.IsTrue(n > 0, "Connection closed before all bytes received"); - totalRead += n; - } + for (int i = 0; i < bufferSize; i++) + { + sent[i] = (byte)(i & 0xFF); + } - client.Close(); + client.Send(sent); - Assert.IsTrue(SocketTools.ArrayEquals(sent, received), "Large buffer echo mismatch"); + byte[] received = new byte[bufferSize]; + int totalRead = 0; + while (totalRead < bufferSize) + { + int n = client.Receive(received, totalRead, bufferSize - totalRead, SocketFlags.None); + Assert.IsTrue(n > 0, "Connection closed before all bytes received"); + totalRead += n; + } - companion.Stop(echoPort); + Assert.IsTrue(SocketTools.ArrayEquals(sent, received), "Large buffer echo mismatch"); + } + finally + { + client.Close(); + companion.Stop(echoPort); + } } } @@ -146,24 +154,28 @@ public void RoundTrip_Udp_SendReceive_Echo() Socket client = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); client.Bind(new IPEndPoint(IPAddress.Any, 0)); - EndPoint serverEndPoint = new IPEndPoint(IPAddress.Parse(TestConfiguration.CompanionIP), echoPort); - - byte[] sent = new byte[] { 0x11, 0x22, 0x33 }; - client.SendTo(sent, serverEndPoint); + try + { + EndPoint serverEndPoint = new IPEndPoint(IPAddress.Parse(TestConfiguration.CompanionIP), echoPort); - byte[] received = new byte[16]; - EndPoint remoteEndPoint = new IPEndPoint(IPAddress.Any, 0); - int n = client.ReceiveFrom(received, ref remoteEndPoint); + byte[] sent = new byte[] { 0x11, 0x22, 0x33 }; + client.SendTo(sent, serverEndPoint); - client.Close(); + byte[] received = new byte[16]; + EndPoint remoteEndPoint = new IPEndPoint(IPAddress.Any, 0); + int n = client.ReceiveFrom(received, ref remoteEndPoint); - Assert.AreEqual(sent.Length, n, "UDP echo returned wrong byte count"); - for (int i = 0; i < sent.Length; i++) + Assert.AreEqual(sent.Length, n, "UDP echo returned wrong byte count"); + for (int i = 0; i < sent.Length; i++) + { + Assert.AreEqual(sent[i], received[i], $"UDP echo mismatch at byte {i}"); + } + } + finally { - Assert.AreEqual(sent[i], received[i], $"UDP echo mismatch at byte {i}"); + client.Close(); + companion.Stop(echoPort); } - - companion.Stop(echoPort); } } @@ -202,10 +214,18 @@ private static string GetLocalIp() { foreach (NetworkInterface ni in NetworkInterface.GetAllNetworkInterfaces()) { - if (ni.IPv4Address != null && ni.IPv4Address != "0.0.0.0") + if (ni.IPv4Address == null || ni.IPv4Address == "0.0.0.0") { - return ni.IPv4Address; + continue; } + + // Skip loopback, disconnected, and virtual (link-local) addresses. + if (ni.IPv4Address.StartsWith("127.") || ni.IPv4Address.StartsWith("169.254.")) + { + continue; + } + + return ni.IPv4Address; } return "0.0.0.0"; diff --git a/Tests/SocketTests/SocketTests.nfproj b/Tests/SocketTests/SocketTests.nfproj index a5b9095d..fbc60d1d 100644 --- a/Tests/SocketTests/SocketTests.nfproj +++ b/Tests/SocketTests/SocketTests.nfproj @@ -66,7 +66,8 @@ Date: Fri, 10 Jul 2026 16:18:20 +0100 Subject: [PATCH 3/3] Another fix --- Tests/SocketTests/SocketRoundTripTests.cs | 36 +++++++++++++---------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/Tests/SocketTests/SocketRoundTripTests.cs b/Tests/SocketTests/SocketRoundTripTests.cs index e56757f2..c0e94763 100644 --- a/Tests/SocketTests/SocketRoundTripTests.cs +++ b/Tests/SocketTests/SocketRoundTripTests.cs @@ -188,26 +188,32 @@ public void RoundTrip_Tcp_McuAsServer_CompanionConnects() server.Bind(new IPEndPoint(IPAddress.Any, mcuListenPort)); server.Listen(1); - using (var companion = new CompanionClient()) + try { - Assert.IsTrue(companion.Ping(), "Companion not reachable"); - - // Resolve the MCU's own IP - the companion must connect back to it. - string mcuIp = GetLocalIp(); - Assert.IsTrue(companion.ConnectTo(mcuIp, mcuListenPort), "ConnectTo command failed"); + using (var companion = new CompanionClient()) + { + Assert.IsTrue(companion.Ping(), "Companion not reachable"); - // ConnectTo is synchronous on the companion side: by the time we get ok, - // the connection is already sitting in the listen backlog. - // Poll with 10 s timeout (microseconds) to avoid blocking forever. - Assert.IsTrue(server.Poll(10 * 1000 * 1000, SelectMode.SelectRead), "No connection received from companion within timeout"); - Socket accepted = server.Accept(); + string mcuIp = GetLocalIp(); + Assert.IsTrue(companion.ConnectTo(mcuIp, mcuListenPort), "ConnectTo command failed"); - Assert.IsNotNull(accepted, "Accept returned null"); + Assert.IsTrue(server.Poll(10 * 1000 * 1000, SelectMode.SelectRead), "No connection received from companion within timeout"); + Socket accepted = server.Accept(); - accepted.Close(); + try + { + Assert.IsNotNull(accepted, "Accept returned null"); + } + finally + { + accepted.Close(); + } + } + } + finally + { + server.Close(); } - - server.Close(); } private static string GetLocalIp()