Verbinden: Router im Netzwerk finden per MNDP (#53)
Build / build (push) Canceled after 0s

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 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-16 06:53:33 +02:00
1 parent 36ad3629cf
commit c40f2bdaf9
5 files changed
+259 -4

No files matched your search

@@ -38,11 +38,11 @@ public sealed class StringToVisibilityConverter : IValueConverter
throw new NotSupportedException();
}
/// <summary>Sichtbar, wenn der gebundene Wert nicht null ist.</summary>
/// <summary>Sichtbar, wenn der gebundene Wert nicht null ist. Mit ConverterParameter="Invert" umgekehrt.</summary>
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();
@@ -41,6 +41,54 @@ public partial class ConnectionStepViewModel : WizardStepViewModelBase
public bool ShowTestEnvironment => UserSettings.Current.TestEnvironment;
/// <summary>Per MNDP gefundene Router (wie „Neighbors" in WinBox), Mac v1.6.0.</summary>
public ObservableCollection<MndpNeighbor> 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)
{
@@ -14,8 +14,65 @@
<TextBlock Text="Adresse des Geräts (IP oder Hostname)" />
<controls:InfoHint Text="Die Adresse, unter der dein Router in deinem Netzwerk erreichbar ist – meist etwas wie 192.168.88.1. Steht z. B. auf dem Gerät oder in der Anleitung deines Routers." />
</StackPanel>
<ui:TextBox Text="{Binding Host, UpdateSourceTrigger=PropertyChanged}"
PlaceholderText="z. B. 192.168.88.1" Margin="0,0,0,16" />
<Grid Margin="0,0,0,8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<ui:TextBox Grid.Column="0" Text="{Binding Host, UpdateSourceTrigger=PropertyChanged}" PlaceholderText="z. B. 192.168.88.1" />
<ui:Button Grid.Column="1" Content="Router im Netzwerk suchen" Appearance="Secondary" Margin="8,0,0,0"
Command="{Binding SearchNeighborsCommand}"
IsEnabled="{Binding IsSearchingNeighbors, Converter={StaticResource InverseBooleanConverter}}">
<ui:Button.Icon>
<ui:SymbolIcon Symbol="Search24" />
</ui:Button.Icon>
</ui:Button>
</Grid>
<!-- Router-Suche per MNDP: findet Router über die MAC-Adresse, auch ohne passende IP (Mac v1.6.0). -->
<StackPanel Orientation="Horizontal" Margin="0,0,0,8" Visibility="{Binding IsSearchingNeighbors, Converter={StaticResource BoolToVisibilityConverter}}">
<ui:ProgressRing IsIndeterminate="True" Width="16" Height="16" Margin="0,0,8,0" />
<TextBlock Text="Suche Router im Netzwerk …" VerticalAlignment="Center" />
</StackPanel>
<TextBlock Text="{Binding NeighborSearchMessage}" TextWrapping="Wrap" Margin="0,0,0,8" Foreground="{DynamicResource TextFillColorSecondaryBrush}"
Visibility="{Binding NeighborSearchMessage, Converter={StaticResource StringToVisibilityConverter}}" />
<ItemsControl ItemsSource="{Binding Neighbors}" Margin="0,0,0,8">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border BorderBrush="{DynamicResource DividerStrokeColorDefaultBrush}" BorderThickness="1" CornerRadius="6" Padding="10,6" Margin="0,0,0,4">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<TextBlock>
<Run Text="{Binding Identity, Mode=OneWay, TargetNullValue=(ohne Namen)}" FontWeight="SemiBold" />
<Run Text="{Binding Board, Mode=OneWay}" Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
</TextBlock>
<TextBlock FontSize="12" Foreground="{DynamicResource TextFillColorSecondaryBrush}">
<Run Text="{Binding IPv4Address, Mode=OneWay, TargetNullValue=keine IP-Adresse}" />
<Run Text=" · " />
<Run Text="{Binding MacAddress, Mode=OneWay}" />
</TextBlock>
<TextBlock FontSize="12" TextWrapping="Wrap" Foreground="{StaticResource MikrotikWarningBrush}"
Text="Ohne IP-Adresse nur per WinBox über die MAC-Adresse erreichbar (in WinBox unter „Neighbors“ anklicken)."
Visibility="{Binding IPv4Address, Converter={StaticResource NullToVisibilityConverter}, ConverterParameter=Invert}" />
</StackPanel>
<ui:Button Grid.Column="1" Content="IP übernehmen" Appearance="Secondary" Margin="8,0,0,0" VerticalAlignment="Center"
Command="{Binding DataContext.UseNeighborCommand, RelativeSource={RelativeSource AncestorType=ItemsControl}}"
CommandParameter="{Binding}"
Visibility="{Binding IPv4Address, Converter={StaticResource NullToVisibilityConverter}}" />
<ui:Button Grid.Column="2" Content="MAC kopieren" Appearance="Secondary" Margin="8,0,0,0" VerticalAlignment="Center"
Command="{Binding DataContext.CopyMacCommand, RelativeSource={RelativeSource AncestorType=ItemsControl}}"
CommandParameter="{Binding}" />
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Border Height="8" />
<!-- Nebeneinander statt untereinander, damit "Verbindung testen" im Standardfenster ohne Scrollen erreichbar bleibt (Issue #31). -->
<Grid Margin="0,0,0,16">
@@ -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<byte> { 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();
}
}
@@ -0,0 +1,92 @@
using System.Buffers.Binary;
using System.Net;
using System.Net.Sockets;
using System.Text;
namespace MikrotikInstaller.Core.Devices;
/// <summary>Ein Router, der sich per MikroTik Neighbor Discovery (MNDP) meldet — dieselbe Liste wie „Neighbors" in WinBox.</summary>
public sealed record MndpNeighbor(
string MacAddress, string? Identity, string? Version, string? Platform, string? Board, string? IPv4Address, string? InterfaceName, uint? UptimeSeconds);
/// <summary>
/// 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.
/// </summary>
public static class MndpDiscovery
{
public const int Port = 5678;
/// <summary>Sendet eine Anfrage und sammelt <paramref name="duration"/> lang alle Antworten, eine je MAC-Adresse.</summary>
public static async Task<IReadOnlyList<MndpNeighbor>> 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<string, MndpNeighbor>();
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<byte> packet)
{
var fields = new Dictionary<ushort, byte[]>();
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);
}
}