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:
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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user