From c40f2bdaf919d361c5c19a1a2e8d1cd5ebc6467b Mon Sep 17 00:00:00 2001 From: neodk2004 Date: Fri, 16 Oct 2026 06:53:33 +0200 Subject: [PATCH] Verbinden: Router im Netzwerk finden per MNDP (#53) MndpDiscovery (UDP 5678, Broadcast-Anfrage + Antworten 4 s sammeln, TLV-Parser wie Mac v1.6.0) findet Router ueber die MAC-Adresse, auch ohne passende IP. Treffer zeigen Name/Modell/IP/MAC; 'IP uebernehmen' fuellt die Adresse, 'MAC kopieren' mit Hinweis auf WinBox bei Routern ohne IP. Co-Authored-By: Claude Opus 5.5 --- .../Converters/VisibilityConverters.cs | 4 +- .../Steps/ConnectionStepViewModel.cs | 48 ++++++++++ .../Views/Steps/ConnectionStepView.xaml | 61 +++++++++++- .../Devices/MndpDiscoveryTests.cs | 58 ++++++++++++ .../Devices/MndpDiscovery.cs | 92 +++++++++++++++++++ 5 files changed, 259 insertions(+), 4 deletions(-) create mode 100644 src/MikrotikInstaller.Core.Tests/Devices/MndpDiscoveryTests.cs create mode 100644 src/MikrotikInstaller.Core/Devices/MndpDiscovery.cs diff --git a/src/MikrotikInstaller.App/Converters/VisibilityConverters.cs b/src/MikrotikInstaller.App/Converters/VisibilityConverters.cs index 6342ae7..a50984f 100644 --- a/src/MikrotikInstaller.App/Converters/VisibilityConverters.cs +++ b/src/MikrotikInstaller.App/Converters/VisibilityConverters.cs @@ -38,11 +38,11 @@ public sealed class StringToVisibilityConverter : IValueConverter throw new NotSupportedException(); } -/// Sichtbar, wenn der gebundene Wert nicht null ist. +/// Sichtbar, wenn der gebundene Wert nicht null ist. Mit ConverterParameter="Invert" umgekehrt. public sealed class NullToVisibilityConverter : IValueConverter { public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) => - value is null ? Visibility.Collapsed : Visibility.Visible; + (value is null) == string.Equals(parameter as string, "Invert", StringComparison.OrdinalIgnoreCase) ? Visibility.Visible : Visibility.Collapsed; public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) => throw new NotSupportedException(); diff --git a/src/MikrotikInstaller.App/ViewModels/Steps/ConnectionStepViewModel.cs b/src/MikrotikInstaller.App/ViewModels/Steps/ConnectionStepViewModel.cs index 6776d6f..90493c6 100644 --- a/src/MikrotikInstaller.App/ViewModels/Steps/ConnectionStepViewModel.cs +++ b/src/MikrotikInstaller.App/ViewModels/Steps/ConnectionStepViewModel.cs @@ -41,6 +41,54 @@ public partial class ConnectionStepViewModel : WizardStepViewModelBase public bool ShowTestEnvironment => UserSettings.Current.TestEnvironment; + /// Per MNDP gefundene Router (wie „Neighbors" in WinBox), Mac v1.6.0. + public ObservableCollection Neighbors { get; } = []; + + [ObservableProperty] + private bool _isSearchingNeighbors; + + [ObservableProperty] + private string? _neighborSearchMessage; + + [RelayCommand] + private async Task SearchNeighborsAsync() + { + IsSearchingNeighbors = true; + NeighborSearchMessage = null; + Neighbors.Clear(); + try + { + foreach (var neighbor in await MndpDiscovery.DiscoverAsync(TimeSpan.FromSeconds(4))) + { + Neighbors.Add(neighbor); + } + + NeighborSearchMessage = Neighbors.Count == 0 + ? "Kein MikroTik-Router gefunden. Ist der PC per Kabel oder WLAN mit dem Router verbunden?" + : null; + } + catch (System.Net.Sockets.SocketException) + { + NeighborSearchMessage = "Die Router-Suche ist gerade nicht möglich. Läuft auf diesem PC ein anderes Programm, das Port 5678 belegt?"; + } + finally + { + IsSearchingNeighbors = false; + } + } + + [RelayCommand] + private void UseNeighbor(MndpNeighbor neighbor) + { + if (neighbor.IPv4Address is { } address) + { + Host = address; + } + } + + [RelayCommand] + private void CopyMac(MndpNeighbor neighbor) => System.Windows.Clipboard.SetText(neighbor.MacAddress); + [RelayCommand] private async Task ConnectToTestDeviceAsync(DemoDeviceProfile profile) { diff --git a/src/MikrotikInstaller.App/Views/Steps/ConnectionStepView.xaml b/src/MikrotikInstaller.App/Views/Steps/ConnectionStepView.xaml index e58fd8e..7179b01 100644 --- a/src/MikrotikInstaller.App/Views/Steps/ConnectionStepView.xaml +++ b/src/MikrotikInstaller.App/Views/Steps/ConnectionStepView.xaml @@ -14,8 +14,65 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/MikrotikInstaller.Core.Tests/Devices/MndpDiscoveryTests.cs b/src/MikrotikInstaller.Core.Tests/Devices/MndpDiscoveryTests.cs new file mode 100644 index 0000000..0921883 --- /dev/null +++ b/src/MikrotikInstaller.Core.Tests/Devices/MndpDiscoveryTests.cs @@ -0,0 +1,58 @@ +using System.Text; +using MikrotikInstaller.Core.Devices; + +namespace MikrotikInstaller.Core.Tests.Devices; + +public class MndpDiscoveryTests +{ + [Fact] + public void Parse_ReadsAnnouncement() + { + var packet = Packet( + (1, [0x48, 0x8F, 0x5A, 0x01, 0x02, 0x03]), + (5, Encoding.UTF8.GetBytes("Keller")), + (7, Encoding.UTF8.GetBytes("7.15.2 (stable)")), + (12, Encoding.UTF8.GetBytes("RB750Gr3")), + (10, [0x10, 0x0E, 0x00, 0x00]), + (16, Encoding.UTF8.GetBytes("bridge")), + (17, [192, 168, 88, 1])); + + var neighbor = MndpDiscovery.Parse(packet); + + Assert.NotNull(neighbor); + Assert.Equal("48:8F:5A:01:02:03", neighbor.MacAddress); + Assert.Equal("Keller", neighbor.Identity); + Assert.Equal("RB750Gr3", neighbor.Board); + Assert.Equal("192.168.88.1", neighbor.IPv4Address); + Assert.Equal("bridge", neighbor.InterfaceName); + Assert.Equal(3600u, neighbor.UptimeSeconds); + } + + [Fact] + public void Parse_OwnRequestWithoutMac_IsIgnored() => + Assert.Null(MndpDiscovery.Parse(new byte[4])); + + [Fact] + public void Parse_TruncatedField_KeepsWhatIsComplete() + { + var packet = Packet((1, [1, 2, 3, 4, 5, 6])).Concat(new byte[] { 0, 5, 0, 50, 65 }).ToArray(); + + var neighbor = MndpDiscovery.Parse(packet); + + Assert.NotNull(neighbor); + Assert.Null(neighbor.Identity); + Assert.Null(neighbor.IPv4Address); + } + + private static byte[] Packet(params (ushort Type, byte[] Value)[] fields) + { + var bytes = new List { 0, 0, 0, 1 }; + foreach (var (type, value) in fields) + { + bytes.AddRange([(byte)(type >> 8), (byte)type, (byte)(value.Length >> 8), (byte)value.Length]); + bytes.AddRange(value); + } + + return bytes.ToArray(); + } +} diff --git a/src/MikrotikInstaller.Core/Devices/MndpDiscovery.cs b/src/MikrotikInstaller.Core/Devices/MndpDiscovery.cs new file mode 100644 index 0000000..b8637ee --- /dev/null +++ b/src/MikrotikInstaller.Core/Devices/MndpDiscovery.cs @@ -0,0 +1,92 @@ +using System.Buffers.Binary; +using System.Net; +using System.Net.Sockets; +using System.Text; + +namespace MikrotikInstaller.Core.Devices; + +/// Ein Router, der sich per MikroTik Neighbor Discovery (MNDP) meldet — dieselbe Liste wie „Neighbors" in WinBox. +public sealed record MndpNeighbor( + string MacAddress, string? Identity, string? Version, string? Platform, string? Board, string? IPv4Address, string? InterfaceName, uint? UptimeSeconds); + +/// +/// MNDP über UDP 5678 (Aufbau wie Wiresharks packet-mndp.c, übernommen aus der Mac-Version v1.6.0): +/// 4 Byte Kopf, danach TLVs aus Typ (2 Byte, Big Endian), Länge (2 Byte, Big Endian) und Wert. +/// Router antworten auf eine Broadcast-Anfrage und melden sich außerdem etwa minütlich von selbst. +/// Findet Router über ihre MAC-Adresse — also auch solche mit kaputter oder unpassender IP-Konfiguration. +/// +public static class MndpDiscovery +{ + public const int Port = 5678; + + /// Sendet eine Anfrage und sammelt lang alle Antworten, eine je MAC-Adresse. + public static async Task> DiscoverAsync(TimeSpan duration, CancellationToken cancellationToken = default) + { + using var udp = new UdpClient(AddressFamily.InterNetwork) { EnableBroadcast = true, ExclusiveAddressUse = false }; + // Teilt sich den Port mit z. B. einem laufenden WinBox, statt mit „Adresse wird bereits verwendet" zu scheitern. + udp.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); + udp.Client.Bind(new IPEndPoint(IPAddress.Any, Port)); + + await udp.SendAsync(new byte[4], new IPEndPoint(IPAddress.Broadcast, Port), cancellationToken); + + var found = new Dictionary(); + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(duration); + try + { + while (true) + { + var result = await udp.ReceiveAsync(timeout.Token); + // Die eigene 4-Byte-Anfrage kommt ebenfalls zurück; ohne MAC-Feld verwirft Parse sie. + if (Parse(result.Buffer) is { } neighbor) + { + found[neighbor.MacAddress] = neighbor; + } + } + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + // Suchzeit abgelaufen. + } + + return found.Values.OrderBy(n => n.Identity ?? n.MacAddress, StringComparer.CurrentCultureIgnoreCase).ToList(); + } + + public static MndpNeighbor? Parse(ReadOnlySpan packet) + { + var fields = new Dictionary(); + var offset = 4; + while (offset + 4 <= packet.Length) + { + var type = BinaryPrimitives.ReadUInt16BigEndian(packet[offset..]); + var length = BinaryPrimitives.ReadUInt16BigEndian(packet[(offset + 2)..]); + offset += 4; + if (offset + length > packet.Length) + { + break; + } + + fields[type] = packet.Slice(offset, length).ToArray(); + offset += length; + } + + if (!fields.TryGetValue(1, out var mac) || mac.Length != 6) + { + return null; + } + + string? Text(ushort type) => + fields.TryGetValue(type, out var value) && value.Length > 0 ? Encoding.UTF8.GetString(value) : null; + + return new MndpNeighbor( + MacAddress: string.Join(":", mac.Select(b => b.ToString("X2"))), + Identity: Text(5), + Version: Text(7), + Platform: Text(8), + Board: Text(12), + IPv4Address: fields.TryGetValue(17, out var ip) && ip.Length == 4 ? new IPAddress(ip).ToString() : null, + InterfaceName: Text(16), + // Little Endian, anders als der TLV-Kopf (live gegen /system resource geprüft, Mac v1.6.0). + UptimeSeconds: fields.TryGetValue(10, out var uptime) && uptime.Length == 4 ? BinaryPrimitives.ReadUInt32LittleEndian(uptime) : null); + } +}