From 652b401701d73fc15fceded6ca57765d23900035 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Thu, 30 Apr 2026 00:04:58 +0200 Subject: [PATCH 1/8] Feature: Restart as admin & use New-NetNeighbor / Remove-NetNeighbor --- .../Resources/Strings.Designer.cs | 9 + .../Resources/Strings.resx | 3 + Source/NETworkManager.Models/Network/ARP.cs | 167 ++++++++++++------ .../ViewModels/ARPTableViewModel.cs | 141 ++++++++------- Source/NETworkManager/Views/ARPTableView.xaml | 41 ++++- .../NETworkManager/Views/FirewallView.xaml.cs | 1 - 6 files changed, 243 insertions(+), 119 deletions(-) diff --git a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs index 5d00425692..99ba9efb75 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs +++ b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs @@ -1112,6 +1112,15 @@ public static string ARPTable { return ResourceManager.GetString("ARPTable", resourceCulture); } } + + /// + /// Looks up a localized string similar to Read-only mode. Modifying the ARP table requires elevated rights!. + /// + public static string ARPTableAdminMessage { + get { + return ResourceManager.GetString("ARPTableAdminMessage", resourceCulture); + } + } /// /// Looks up a localized string similar to ASN. diff --git a/Source/NETworkManager.Localization/Resources/Strings.resx b/Source/NETworkManager.Localization/Resources/Strings.resx index a7445c649c..2fbc843e9b 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.resx +++ b/Source/NETworkManager.Localization/Resources/Strings.resx @@ -141,6 +141,9 @@ ARP Table + + Read-only mode. Modifying the ARP table requires elevated rights! + Authentication diff --git a/Source/NETworkManager.Models/Network/ARP.cs b/Source/NETworkManager.Models/Network/ARP.cs index 9532fb3971..f63f1da17a 100644 --- a/Source/NETworkManager.Models/Network/ARP.cs +++ b/Source/NETworkManager.Models/Network/ARP.cs @@ -1,22 +1,40 @@ -// Contains code from: https://stackoverflow.com/a/1148861/4986782 +// Contains code from: https://stackoverflow.com/a/1148861/4986782 // Modified by BornToBeRoot using System; using System.Collections.Generic; using System.ComponentModel; using System.Linq; +using System.Management.Automation.Runspaces; using System.Net; using System.Net.NetworkInformation; using System.Runtime.InteropServices; +using System.Threading; using System.Threading.Tasks; using NETworkManager.Utilities; +using SMA = System.Management.Automation; +using log4net; namespace NETworkManager.Models.Network; +/// +/// Provides static methods to read and modify the Windows ARP table. +/// Read access uses the IpHlpApi Win32 API. Modifying operations +/// (add/delete entries, clear table) run via PowerShell in a shared +/// that is initialized once with the required +/// execution policy. A serializes access so +/// the runspace is never used concurrently. Modifying operations require +/// the application to run with elevated rights. +/// public class ARP { #region Variables + /// + /// The logger for this class. + /// + private static readonly ILog Log = LogManager.GetLogger(typeof(ARP)); + // The max number of physical addresses. private const int MAXLEN_PHYSADDR = 8; @@ -50,15 +68,29 @@ private static extern int GetIpNetTable(IntPtr pIpNetTable, [MarshalAs(Unmanaged // The insufficient buffer error. private const int ERROR_INSUFFICIENT_BUFFER = 122; - #endregion - - #region Events - - public event EventHandler UserHasCanceled; - - protected virtual void OnUserHasCanceled() + /// + /// Ensures that only one PowerShell pipeline runs on at a time. + /// + private static readonly SemaphoreSlim Lock = new(1, 1); + + /// + /// Shared PowerShell runspace, initialized once in the static constructor with + /// Set-ExecutionPolicy Bypass so subsequent operations can run without + /// repeating the policy change. + /// + private static readonly Runspace SharedRunspace; + + /// + /// Opens and runs the one-time initialization script. + /// + static ARP() { - UserHasCanceled?.Invoke(this, EventArgs.Empty); + SharedRunspace = RunspaceFactory.CreateRunspace(); + SharedRunspace.Open(); + + using var ps = SMA.PowerShell.Create(); + ps.Runspace = SharedRunspace; + ps.AddScript("Set-ExecutionPolicy -ExecutionPolicy Bypass -Scope Process").Invoke(); } #endregion @@ -154,61 +186,92 @@ public static string GetMACAddress(IPAddress ipAddress) return arpInfo?.MACAddress.ToString(); } - private void RunPowerShellCommand(string command) + /// + /// Adds a static ARP entry by running arp -s through the shared PowerShell + /// runspace. Requires the application to run with elevated rights. + /// + /// The IP address of the entry. + /// The MAC address of the entry, separated with -. + /// + /// Thrown when the PowerShell pipeline reports one or more errors. + /// + public static async Task AddEntryAsync(string ipAddress, string macAddress) { - try - { - PowerShellHelper.ExecuteCommand(command, true); - } - catch (Win32Exception win32Ex) - { - switch (win32Ex.NativeErrorCode) - { - case 1223: - OnUserHasCanceled(); - break; - default: - throw; - } - } + await InvokeAsync($"arp -s '{EscapePs(ipAddress)}' '{EscapePs(macAddress)}' 2>&1 | Out-String"); } - // MAC separated with "-" - public Task AddEntryAsync(string ipAddress, string macAddress) + /// + /// Removes a single ARP entry by running arp -d through the shared PowerShell + /// runspace. Requires the application to run with elevated rights. + /// + /// The IP address of the entry to remove. + /// + /// Thrown when the PowerShell pipeline reports one or more errors. + /// + public static async Task DeleteEntryAsync(string ipAddress) { - return Task.Run(() => AddEntry(ipAddress, macAddress)); + await InvokeAsync($"arp -d '{EscapePs(ipAddress)}' 2>&1 | Out-String"); } - private void AddEntry(string ipAddress, string macAddress) + /// + /// Clears the entire ARP cache by running netsh interface ip delete arpcache + /// through the shared PowerShell runspace. Requires the application to run with + /// elevated rights. + /// + /// + /// Thrown when the PowerShell pipeline reports one or more errors. + /// + public static async Task DeleteTableAsync() { - var command = $"arp -s {ipAddress} {macAddress}"; - - RunPowerShellCommand(command); + await InvokeAsync("netsh interface ip delete arpcache 2>&1 | Out-String"); } - public Task DeleteEntryAsync(string ipAddress) + /// + /// Runs on the shared runspace and throws when the + /// command exits with a non-zero exit code or writes to the PowerShell error stream. + /// + /// The PowerShell script to execute. + private static async Task InvokeAsync(string script) { - return Task.Run(() => DeleteEntry(ipAddress)); - } - - private void DeleteEntry(string ipAddress) - { - var command = $"arp -d {ipAddress}"; - - RunPowerShellCommand(command); - } - - public Task DeleteTableAsync() - { - return Task.Run(() => DeleteTable()); + await Lock.WaitAsync(); + try + { + await Task.Run(() => + { + using var ps = SMA.PowerShell.Create(); + ps.Runspace = SharedRunspace; + + ps.AddScript(script + @" +if ($LASTEXITCODE -ne 0) { Write-Error ""Exit code: $LASTEXITCODE"" }"); + var results = ps.Invoke(); + + if (ps.Streams.Error.Count > 0) + { + var output = string.Join(Environment.NewLine, + results.Select(r => r?.ToString()).Where(s => !string.IsNullOrWhiteSpace(s))); + var errors = string.Join(Environment.NewLine, ps.Streams.Error); + + var message = string.IsNullOrWhiteSpace(output) + ? errors + : $"{output.Trim()}{Environment.NewLine}{errors}"; + + Log.Warn($"PowerShell error: {message}"); + throw new Exception(message); + } + }); + } + finally + { + Lock.Release(); + } } - private void DeleteTable() - { - const string command = "netsh interface ip delete arpcache"; - - RunPowerShellCommand(command); - } + /// + /// Escapes a string for embedding inside a PowerShell single-quoted string by + /// doubling any single-quote characters. + /// + /// The raw string value to escape. + private static string EscapePs(string value) => value.Replace("'", "''"); #endregion } \ No newline at end of file diff --git a/Source/NETworkManager/ViewModels/ARPTableViewModel.cs b/Source/NETworkManager/ViewModels/ARPTableViewModel.cs index d507a38899..5d4ba7bcba 100644 --- a/Source/NETworkManager/ViewModels/ARPTableViewModel.cs +++ b/Source/NETworkManager/ViewModels/ARPTableViewModel.cs @@ -239,6 +239,23 @@ public bool IsRefreshing } } + /// + /// Gets or sets a value indicating whether the view model is modifying an entry + /// (add entry, delete entry, delete table). + /// + public bool IsModifying + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } + /// /// Gets or sets a value indicating whether the status message is displayed. /// @@ -291,6 +308,7 @@ private bool Refresh_CanExecute(object parameter) !((MetroWindow)Application.Current.MainWindow).IsAnyDialogOpen && !ConfigurationManager.Current.IsChildWindowOpen && !IsRefreshing && + !IsModifying && !AutoRefreshEnabled; } @@ -308,34 +326,19 @@ private async Task RefreshAction() /// Gets the command to delete the ARP table. /// public ICommand DeleteTableCommand => - new RelayCommand(_ => DeleteTableAction().ConfigureAwait(false), DeleteTable_CanExecute); - - /// - /// Checks if the delete table command can be executed. - /// - /// The command parameter. - /// true if the command can be executed; otherwise, false. - private bool DeleteTable_CanExecute(object parameter) - { - return Application.Current.MainWindow != null && - !((MetroWindow)Application.Current.MainWindow).IsAnyDialogOpen && - !ConfigurationManager.Current.IsChildWindowOpen; - } + new RelayCommand(_ => DeleteTableAction().ConfigureAwait(false), ModifyEntry_CanExecute); /// /// Action to delete the ARP table. /// private async Task DeleteTableAction() { + IsModifying = true; IsStatusMessageDisplayed = false; try { - var arpTable = new ARP(); - - arpTable.UserHasCanceled += ArpTable_UserHasCanceled; - - await arpTable.DeleteTableAsync(); + await ARP.DeleteTableAsync(); await Refresh(); } @@ -344,40 +347,29 @@ private async Task DeleteTableAction() StatusMessage = ex.Message; IsStatusMessageDisplayed = true; } + finally + { + IsModifying = false; + } } /// /// Gets the command to delete an ARP entry. /// public ICommand DeleteEntryCommand => - new RelayCommand(_ => DeleteEntryAction().ConfigureAwait(false), DeleteEntry_CanExecute); - - /// - /// Checks if the delete entry command can be executed. - /// - /// The command parameter. - /// true if the command can be executed; otherwise, false. - private bool DeleteEntry_CanExecute(object parameter) - { - return Application.Current.MainWindow != null && - !((MetroWindow)Application.Current.MainWindow).IsAnyDialogOpen && - !ConfigurationManager.Current.IsChildWindowOpen; - } + new RelayCommand(_ => DeleteEntryAction().ConfigureAwait(false), ModifyEntry_CanExecute); /// /// Action to delete an ARP entry. /// private async Task DeleteEntryAction() { + IsModifying = true; IsStatusMessageDisplayed = false; try { - var arpTable = new ARP(); - - arpTable.UserHasCanceled += ArpTable_UserHasCanceled; - - await arpTable.DeleteEntryAsync(SelectedResult.IPAddress.ToString()); + await ARP.DeleteEntryAsync(SelectedResult.IPAddress.ToString()); await Refresh(); } @@ -386,36 +378,28 @@ private async Task DeleteEntryAction() StatusMessage = ex.Message; IsStatusMessageDisplayed = true; } + finally + { + IsModifying = false; + } } /// /// Gets the command to add an ARP entry. /// public ICommand AddEntryCommand => - new RelayCommand(_ => AddEntryAction().ConfigureAwait(false), AddEntry_CanExecute); - - /// - /// Checks if the add entry command can be executed. - /// - /// The command parameter. - /// true if the command can be executed; otherwise, false. - private bool AddEntry_CanExecute(object parameter) - { - return Application.Current.MainWindow != null && - !((MetroWindow)Application.Current.MainWindow).IsAnyDialogOpen && - !ConfigurationManager.Current.IsChildWindowOpen; - } + new RelayCommand(_ => AddEntryAction().ConfigureAwait(false), ModifyEntry_CanExecute); /// /// Action to add an ARP entry. /// private async Task AddEntryAction() { + IsModifying = true; IsStatusMessageDisplayed = false; var childWindow = new ARPTableAddEntryChildWindow(); - var childWindowViewModel = new ARPTableAddEntryViewModel(async instance => { childWindow.IsOpen = false; @@ -423,11 +407,7 @@ private async Task AddEntryAction() try { - var arpTable = new ARP(); - - arpTable.UserHasCanceled += ArpTable_UserHasCanceled; - - await arpTable.AddEntryAsync(instance.IPAddress, MACAddressHelper.Format(instance.MACAddress, "-")); + await ARP.AddEntryAsync(instance.IPAddress, MACAddressHelper.Format(instance.MACAddress, "-")); await Refresh(); } @@ -436,10 +416,16 @@ private async Task AddEntryAction() StatusMessage = ex.Message; IsStatusMessageDisplayed = true; } + finally + { + IsModifying = false; + } }, _ => { childWindow.IsOpen = false; ConfigurationManager.Current.IsChildWindowOpen = false; + + IsModifying = false; }); childWindow.Title = Strings.AddEntry; @@ -451,6 +437,40 @@ private async Task AddEntryAction() await Application.Current.MainWindow.ShowChildWindowAsync(childWindow); } + /// + /// Checks if the entry modification commands can be executed. + /// + private bool ModifyEntry_CanExecute(object parameter) + { + return ConfigurationManager.Current.IsAdmin && + Application.Current.MainWindow != null && + !((MetroWindow)Application.Current.MainWindow).IsAnyDialogOpen && + !ConfigurationManager.Current.IsChildWindowOpen && + !IsRefreshing && + !IsModifying; + } + + /// + /// Gets the command to restart the application as administrator. + /// + public ICommand RestartAsAdminCommand => new RelayCommand(_ => RestartAsAdminAction().ConfigureAwait(false)); + + /// + /// Action to restart the application as administrator. + /// + private async Task RestartAsAdminAction() + { + try + { + (Application.Current.MainWindow as MainWindow)?.RestartApplication(true); + } + catch (Exception ex) + { + await DialogHelper.ShowMessageAsync(Application.Current.MainWindow, Strings.Error, ex.Message, + ChildWindowIcon.Error); + } + } + /// /// Gets the command to export the ARP table. /// @@ -519,7 +539,7 @@ private async Task Refresh(bool init = false) StatusMessage = Strings.RefreshingDots; IsStatusMessageDisplayed = true; - if (init == false) + if (!init) await Task.Delay(GlobalStaticConfiguration.ApplicationUIRefreshInterval); Results.Clear(); @@ -556,15 +576,6 @@ public void OnViewHide() #region Events - /// - /// Handles the UserHasCanceled event from the ARP table. - /// - private void ArpTable_UserHasCanceled(object sender, EventArgs e) - { - StatusMessage = Strings.CanceledByUserMessage; - IsStatusMessageDisplayed = true; - } - /// /// Handles the Tick event of the auto-refresh timer. /// diff --git a/Source/NETworkManager/Views/ARPTableView.xaml b/Source/NETworkManager/Views/ARPTableView.xaml index d32fab70d1..7e692604d8 100644 --- a/Source/NETworkManager/Views/ARPTableView.xaml +++ b/Source/NETworkManager/Views/ARPTableView.xaml @@ -12,12 +12,14 @@ xmlns:localization="clr-namespace:NETworkManager.Localization.Resources;assembly=NETworkManager.Localization" xmlns:controls="clr-namespace:NETworkManager.Controls;assembly=NETworkManager.Controls" xmlns:wpfHelper="clr-namespace:NETworkManager.Utilities.WPF;assembly=NETworkManager.Utilities.WPF" - x:Class="NETworkManager.Views.ARPTableView" + xmlns:settings="clr-namespace:NETworkManager.Settings;assembly=NETworkManager.Settings" + x:Class="NETworkManager.Views.ARPTableView" d:DataContext="{d:DesignInstance {x:Type viewModels:ARPTableViewModel}}" mc:Ignorable="d"> + @@ -35,6 +37,7 @@ + @@ -249,6 +252,42 @@ + + + + + + + diff --git a/Source/NETworkManager/Views/FirewallView.xaml.cs b/Source/NETworkManager/Views/FirewallView.xaml.cs index 4565a26677..7b89181733 100644 --- a/Source/NETworkManager/Views/FirewallView.xaml.cs +++ b/Source/NETworkManager/Views/FirewallView.xaml.cs @@ -1,6 +1,5 @@ using System.Windows; using System.Windows.Controls; -using System.Windows.Input; using System.Windows.Media; using NETworkManager.ViewModels; From cad77ab56047857374041956ac0f2fe85ddea005 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Sun, 3 May 2026 20:30:43 +0200 Subject: [PATCH 2/8] Feature: Neighbor Table --- agents.md => AGENTS_.md | 0 Source/AGENTS.md | 1 + Source/GlobalAssemblyInfo.cs | 4 +- .../NeighborStateToStringConverter.cs | 31 ++ .../DocumentationIdentifier.cs | 4 +- .../DocumentationManager.cs | 6 +- .../ResourceIdentifier.cs | 3 +- .../Resources/Strings.Designer.cs | 81 +++- .../Resources/Strings.resx | 33 +- .../ApplicationManager.cs | 8 +- .../NETworkManager.Models/ApplicationName.cs | 9 +- .../Export/ExportManager.ARPInfo.cs | 96 ---- .../Export/ExportManager.NeighborInfo.cs | 99 ++++ .../Firewall/Firewall.cs | 15 +- Source/NETworkManager.Models/Network/ARP.cs | 277 ----------- .../NETworkManager.Models/Network/ARPInfo.cs | 38 -- .../Network/IPScanner.cs | 4 +- .../Network/IPv4Address.cs | 2 +- .../Network/NeighborInfo.cs | 63 +++ .../Network/NeighborState.cs | 17 + .../Network/NeighborTable.cs | 447 ++++++++++++++++++ .../GlobalStaticConfiguration.cs | 6 +- .../NETworkManager.Settings/SettingsInfo.cs | 27 +- .../SettingsManager.cs | 18 + .../PowerShellHelper.cs | 6 + Source/NETworkManager/MainWindow.xaml.cs | 18 +- .../ViewModels/ARPTableAddEntryViewModel.cs | 65 --- .../NeighborTableAddEntryViewModel.cs | 104 ++++ ...ViewModel.cs => NeighborTableViewModel.cs} | 175 ++----- ... => NeighborTableAddEntryChildWindow.xaml} | 17 +- ... NeighborTableAddEntryChildWindow.xaml.cs} | 8 +- ...PTableView.xaml => NeighborTableView.xaml} | 41 +- ...View.xaml.cs => NeighborTableView.xaml.cs} | 31 +- Website/docs/application/neighbor-table.md | 113 +++++ Website/docs/changelog/next-release.md | 9 + Website/docs/introduction.mdx | 2 +- 36 files changed, 1177 insertions(+), 701 deletions(-) rename agents.md => AGENTS_.md (100%) create mode 100644 Source/AGENTS.md create mode 100644 Source/NETworkManager.Converters/NeighborStateToStringConverter.cs delete mode 100644 Source/NETworkManager.Models/Export/ExportManager.ARPInfo.cs create mode 100644 Source/NETworkManager.Models/Export/ExportManager.NeighborInfo.cs delete mode 100644 Source/NETworkManager.Models/Network/ARP.cs delete mode 100644 Source/NETworkManager.Models/Network/ARPInfo.cs create mode 100644 Source/NETworkManager.Models/Network/NeighborInfo.cs create mode 100644 Source/NETworkManager.Models/Network/NeighborState.cs create mode 100644 Source/NETworkManager.Models/Network/NeighborTable.cs delete mode 100644 Source/NETworkManager/ViewModels/ARPTableAddEntryViewModel.cs create mode 100644 Source/NETworkManager/ViewModels/NeighborTableAddEntryViewModel.cs rename Source/NETworkManager/ViewModels/{ARPTableViewModel.cs => NeighborTableViewModel.cs} (68%) rename Source/NETworkManager/Views/{ARPTableAddEntryChildWindow.xaml => NeighborTableAddEntryChildWindow.xaml} (86%) rename Source/NETworkManager/Views/{ARPTableAddEntryChildWindow.xaml.cs => NeighborTableAddEntryChildWindow.xaml.cs} (75%) rename Source/NETworkManager/Views/{ARPTableView.xaml => NeighborTableView.xaml} (89%) rename Source/NETworkManager/Views/{ARPTableView.xaml.cs => NeighborTableView.xaml.cs} (69%) create mode 100644 Website/docs/application/neighbor-table.md diff --git a/agents.md b/AGENTS_.md similarity index 100% rename from agents.md rename to AGENTS_.md diff --git a/Source/AGENTS.md b/Source/AGENTS.md new file mode 100644 index 0000000000..3005b066e2 --- /dev/null +++ b/Source/AGENTS.md @@ -0,0 +1 @@ +See [AGENTS.md](../AGENTS.md) in the repository root for project guidelines and conventions. \ No newline at end of file diff --git a/Source/GlobalAssemblyInfo.cs b/Source/GlobalAssemblyInfo.cs index e66a24bb05..7f5aa87dfd 100644 --- a/Source/GlobalAssemblyInfo.cs +++ b/Source/GlobalAssemblyInfo.cs @@ -6,5 +6,5 @@ [assembly: AssemblyTrademark("")] [assembly: AssemblyCulture("")] -[assembly: AssemblyVersion("2026.4.26.0")] -[assembly: AssemblyFileVersion("2026.4.26.0")] +[assembly: AssemblyVersion("2026.5.3.0")] +[assembly: AssemblyFileVersion("2026.5.3.0")] diff --git a/Source/NETworkManager.Converters/NeighborStateToStringConverter.cs b/Source/NETworkManager.Converters/NeighborStateToStringConverter.cs new file mode 100644 index 0000000000..d738ac7c59 --- /dev/null +++ b/Source/NETworkManager.Converters/NeighborStateToStringConverter.cs @@ -0,0 +1,31 @@ +using System; +using System.Globalization; +using System.Windows.Data; +using NETworkManager.Localization; +using NETworkManager.Models.Network; + +namespace NETworkManager.Converters; + +/// +/// Convert to a translated . +/// +public sealed class NeighborStateToStringConverter : IValueConverter +{ + /// + /// Convert to a translated . + /// + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + return value is not NeighborState state + ? "-/-" + : ResourceTranslator.Translate(ResourceIdentifier.NeighborState, state); + } + + /// + /// !!! Method not implemented !!! + /// + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} diff --git a/Source/NETworkManager.Documentation/DocumentationIdentifier.cs b/Source/NETworkManager.Documentation/DocumentationIdentifier.cs index fde0414a35..6ae1c6c742 100644 --- a/Source/NETworkManager.Documentation/DocumentationIdentifier.cs +++ b/Source/NETworkManager.Documentation/DocumentationIdentifier.cs @@ -141,9 +141,9 @@ public enum DocumentationIdentifier ApplicationListeners, /// - /// ARP Table documentation page. + /// Neighbor Table documentation page. /// - ApplicationArpTable, + ApplicationNeighborTable, /// /// Settings\General documentation page. diff --git a/Source/NETworkManager.Documentation/DocumentationManager.cs b/Source/NETworkManager.Documentation/DocumentationManager.cs index 3907c3b97b..6347394f27 100644 --- a/Source/NETworkManager.Documentation/DocumentationManager.cs +++ b/Source/NETworkManager.Documentation/DocumentationManager.cs @@ -99,8 +99,8 @@ public static class DocumentationManager new DocumentationInfo(DocumentationIdentifier.ApplicationListeners, @"docs/application/listeners"), - new DocumentationInfo(DocumentationIdentifier.ApplicationArpTable, - @"docs/application/arp-table"), + new DocumentationInfo(DocumentationIdentifier.ApplicationNeighborTable, + @"docs/application/neighbor-table"), new DocumentationInfo(DocumentationIdentifier.SettingsGeneral, @"docs/settings/general"), @@ -228,7 +228,7 @@ public static DocumentationIdentifier GetIdentifierByApplicationName(Application ApplicationName.Lookup => DocumentationIdentifier.ApplicationLookup, ApplicationName.Connections => DocumentationIdentifier.ApplicationConnections, ApplicationName.Listeners => DocumentationIdentifier.ApplicationListeners, - ApplicationName.ARPTable => DocumentationIdentifier.ApplicationArpTable, + ApplicationName.NeighborTable => DocumentationIdentifier.ApplicationNeighborTable, ApplicationName.None => DocumentationIdentifier.Default, _ => DocumentationIdentifier.Default }; diff --git a/Source/NETworkManager.Localization/ResourceIdentifier.cs b/Source/NETworkManager.Localization/ResourceIdentifier.cs index 1f36bc0209..a174e82a2d 100644 --- a/Source/NETworkManager.Localization/ResourceIdentifier.cs +++ b/Source/NETworkManager.Localization/ResourceIdentifier.cs @@ -26,5 +26,6 @@ public enum ResourceIdentifier FirewallProtocol, FirewallInterfaceType, FirewallRuleDirection, - FirewallRuleAction + FirewallRuleAction, + NeighborState } \ No newline at end of file diff --git a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs index 99ba9efb75..8cbc870675 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs +++ b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs @@ -763,11 +763,11 @@ public static string Appearance { } /// - /// Looks up a localized string similar to ARP Table. + /// Looks up a localized string similar to Neighbor Table. /// - public static string ApplicationName_ARPTable { + public static string ApplicationName_NeighborTable { get { - return ResourceManager.GetString("ApplicationName_ARPTable", resourceCulture); + return ResourceManager.GetString("ApplicationName_NeighborTable", resourceCulture); } } @@ -1105,20 +1105,83 @@ public static string ARP { } /// - /// Looks up a localized string similar to ARP Table. + /// Looks up a localized string similar to Neighbor Table. /// - public static string ARPTable { + public static string NeighborTable { get { - return ResourceManager.GetString("ARPTable", resourceCulture); + return ResourceManager.GetString("NeighborTable", resourceCulture); } } /// - /// Looks up a localized string similar to Read-only mode. Modifying the ARP table requires elevated rights!. + /// Looks up a localized string similar to Read-only mode. Modifying the neighbor table requires elevated rights!. /// - public static string ARPTableAdminMessage { + public static string NeighborTableAdminMessage { get { - return ResourceManager.GetString("ARPTableAdminMessage", resourceCulture); + return ResourceManager.GetString("NeighborTableAdminMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Unreachable. + /// + public static string NeighborState_Unreachable { + get { + return ResourceManager.GetString("NeighborState_Unreachable", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Incomplete. + /// + public static string NeighborState_Incomplete { + get { + return ResourceManager.GetString("NeighborState_Incomplete", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Probe. + /// + public static string NeighborState_Probe { + get { + return ResourceManager.GetString("NeighborState_Probe", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Delay. + /// + public static string NeighborState_Delay { + get { + return ResourceManager.GetString("NeighborState_Delay", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Stale. + /// + public static string NeighborState_Stale { + get { + return ResourceManager.GetString("NeighborState_Stale", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Reachable. + /// + public static string NeighborState_Reachable { + get { + return ResourceManager.GetString("NeighborState_Reachable", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Permanent. + /// + public static string NeighborState_Permanent { + get { + return ResourceManager.GetString("NeighborState_Permanent", resourceCulture); } } diff --git a/Source/NETworkManager.Localization/Resources/Strings.resx b/Source/NETworkManager.Localization/Resources/Strings.resx index 2fbc843e9b..e7735b858f 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.resx +++ b/Source/NETworkManager.Localization/Resources/Strings.resx @@ -138,11 +138,32 @@ ARP - - ARP Table + + Neighbor Table - - Read-only mode. Modifying the ARP table requires elevated rights! + + Read-only mode. Modifying the neighbor table requires elevated rights! + + + Unreachable + + + Incomplete + + + Probe + + + Delay + + + Stale + + + Reachable + + + Permanent Authentication @@ -2466,8 +2487,8 @@ $$hostname$$ --> Hostname Microsoft.Windows.SDK.Contracts is required for this feature but not available on this system (e.g. on Windows Server). - - ARP Table + + Neighbor Table Connections diff --git a/Source/NETworkManager.Models/ApplicationManager.cs b/Source/NETworkManager.Models/ApplicationManager.cs index 8270954762..f4dac25830 100644 --- a/Source/NETworkManager.Models/ApplicationManager.cs +++ b/Source/NETworkManager.Models/ApplicationManager.cs @@ -27,7 +27,9 @@ public static IEnumerable GetNames() /// IEnumerable with . public static IEnumerable GetDefaultList() { - return [.. GetNames().Where(x => x != ApplicationName.None && x != ApplicationName.AWSSessionManager).Select(name => new ApplicationInfo(name, true, name == ApplicationName.Dashboard))]; +#pragma warning disable CS0618 + return [.. GetNames().Where(x => x != ApplicationName.None && x != ApplicationName.AWSSessionManager && x != ApplicationName.ARPTable).Select(name => new ApplicationInfo(name, true, name == ApplicationName.Dashboard))]; +#pragma warning restore CS0618 } /// @@ -118,13 +120,17 @@ public static Canvas GetIcon(ApplicationName name) case ApplicationName.Listeners: canvas.Children.Add(new PackIconMaterial { Kind = PackIconMaterialKind.Wan }); break; + case ApplicationName.NeighborTable: +#pragma warning disable CS0618 case ApplicationName.ARPTable: +#pragma warning restore CS0618 canvas.Children.Add(new PackIconMaterial { Kind = PackIconMaterialKind.TableOfContents }); break; case ApplicationName.Firewall: canvas.Children.Add(new PackIconMaterial { Kind = PackIconMaterialKind.WallFire }); break; case ApplicationName.None: + case ApplicationName.AWSSessionManager: default: canvas.Children.Add(new PackIconModern { Kind = PackIconModernKind.SmileyFrown }); break; diff --git a/Source/NETworkManager.Models/ApplicationName.cs b/Source/NETworkManager.Models/ApplicationName.cs index def934f22a..20bd63710c 100644 --- a/Source/NETworkManager.Models/ApplicationName.cs +++ b/Source/NETworkManager.Models/ApplicationName.cs @@ -148,7 +148,14 @@ public enum ApplicationName Listeners, /// - /// ARP table application. + /// Neighbor table application (IPv4 ARP + IPv6 NDP). /// + NeighborTable, + + /// + /// Obsolete: renamed to . Kept so that old settings files + /// with "ARPTable" can still be deserialized; migrated in SettingsManager.Upgrade(). + /// + [System.Obsolete("Use NeighborTable instead.")] ARPTable } \ No newline at end of file diff --git a/Source/NETworkManager.Models/Export/ExportManager.ARPInfo.cs b/Source/NETworkManager.Models/Export/ExportManager.ARPInfo.cs deleted file mode 100644 index 8077c59631..0000000000 --- a/Source/NETworkManager.Models/Export/ExportManager.ARPInfo.cs +++ /dev/null @@ -1,96 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Text; -using System.Xml.Linq; -using NETworkManager.Models.Network; -using Newtonsoft.Json; - -namespace NETworkManager.Models.Export; - -public static partial class ExportManager -{ - /// - /// Method to export objects from type to a file. - /// - /// Path to the export file. - /// Allowed are CSV, XML or JSON. - /// Objects as to export. - public static void Export(string filePath, ExportFileType fileType, IReadOnlyList collection) - { - switch (fileType) - { - case ExportFileType.Csv: - CreateCsv(collection, filePath); - break; - case ExportFileType.Xml: - CreateXml(collection, filePath); - break; - case ExportFileType.Json: - CreateJson(collection, filePath); - break; - case ExportFileType.Txt: - default: - throw new ArgumentOutOfRangeException(nameof(fileType), fileType, null); - } - } - - /// - /// Creates a CSV file from the given collection. - /// - /// Objects as to export. - /// Path to the export file. - private static void CreateCsv(IEnumerable collection, string filePath) - { - var stringBuilder = new StringBuilder(); - - stringBuilder.AppendLine( - $"{nameof(ARPInfo.IPAddress)},{nameof(ARPInfo.MACAddress)},{nameof(ARPInfo.IsMulticast)}"); - - foreach (var info in collection) - stringBuilder.AppendLine($"{info.IPAddress},{info.MACAddress},{info.IsMulticast}"); - - File.WriteAllText(filePath, stringBuilder.ToString()); - } - - /// - /// Creates a XML file from the given collection. - /// - /// Objects as to export. - /// Path to the export file. - private static void CreateXml(IEnumerable collection, string filePath) - { - var document = new XDocument(DefaultXDeclaration, - new XElement(ApplicationName.ARPTable.ToString(), - new XElement(nameof(ARPInfo) + "s", - from info in collection - select - new XElement(nameof(ARPInfo), - new XElement(nameof(ARPInfo.IPAddress), info.IPAddress), - new XElement(nameof(ARPInfo.MACAddress), info.MACAddress), - new XElement(nameof(ARPInfo.IsMulticast), info.IsMulticast))))); - - document.Save(filePath); - } - - /// - /// Creates a JSON file from the given collection. - /// - /// Objects as to export. - /// Path to the export file. - private static void CreateJson(IReadOnlyList collection, string filePath) - { - var jsonData = new object[collection.Count]; - - for (var i = 0; i < collection.Count; i++) - jsonData[i] = new - { - IPAddress = collection[i].IPAddress.ToString(), - MACAddress = collection[i].MACAddress.ToString(), - collection[i].IsMulticast - }; - - File.WriteAllText(filePath, JsonConvert.SerializeObject(jsonData, Formatting.Indented)); - } -} \ No newline at end of file diff --git a/Source/NETworkManager.Models/Export/ExportManager.NeighborInfo.cs b/Source/NETworkManager.Models/Export/ExportManager.NeighborInfo.cs new file mode 100644 index 0000000000..259412c6ee --- /dev/null +++ b/Source/NETworkManager.Models/Export/ExportManager.NeighborInfo.cs @@ -0,0 +1,99 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Xml.Linq; +using NETworkManager.Models.Network; +using Newtonsoft.Json; + +namespace NETworkManager.Models.Export; + +public static partial class ExportManager +{ + /// + /// Method to export objects from type to a file. + /// + /// Path to the export file. + /// Allowed are CSV, XML or JSON. + /// Objects as to export. + public static void Export(string filePath, ExportFileType fileType, IReadOnlyList collection) + { + switch (fileType) + { + case ExportFileType.Csv: + CreateCsv(collection, filePath); + break; + case ExportFileType.Xml: + CreateXml(collection, filePath); + break; + case ExportFileType.Json: + CreateJson(collection, filePath); + break; + case ExportFileType.Txt: + default: + throw new ArgumentOutOfRangeException(nameof(fileType), fileType, null); + } + } + + /// + /// Creates a CSV file from the given collection. + /// + private static void CreateCsv(IEnumerable collection, string filePath) + { + var stringBuilder = new StringBuilder(); + + stringBuilder.AppendLine( + $"{nameof(NeighborInfo.IPAddress)},{nameof(NeighborInfo.MACAddress)},{nameof(NeighborInfo.InterfaceAlias)},{nameof(NeighborInfo.InterfaceIndex)},{nameof(NeighborInfo.State)},{nameof(NeighborInfo.AddressFamily)},{nameof(NeighborInfo.IsMulticast)}"); + + foreach (var info in collection) + stringBuilder.AppendLine( + $"{info.IPAddress},{info.MACAddress},{info.InterfaceAlias},{info.InterfaceIndex},{info.State},{info.AddressFamily},{info.IsMulticast}"); + + File.WriteAllText(filePath, stringBuilder.ToString()); + } + + /// + /// Creates an XML file from the given collection. + /// + private static void CreateXml(IEnumerable collection, string filePath) + { + var document = new XDocument(DefaultXDeclaration, + new XElement(ApplicationName.NeighborTable.ToString(), + new XElement(nameof(NeighborInfo) + "s", + from info in collection + select + new XElement(nameof(NeighborInfo), + new XElement(nameof(NeighborInfo.IPAddress), info.IPAddress), + new XElement(nameof(NeighborInfo.MACAddress), info.MACAddress), + new XElement(nameof(NeighborInfo.InterfaceAlias), info.InterfaceAlias), + new XElement(nameof(NeighborInfo.InterfaceIndex), info.InterfaceIndex), + new XElement(nameof(NeighborInfo.State), info.State), + new XElement(nameof(NeighborInfo.AddressFamily), info.AddressFamily), + new XElement(nameof(NeighborInfo.IsMulticast), info.IsMulticast))))); + + document.Save(filePath); + } + + /// + /// Creates a JSON file from the given collection. + /// + private static void CreateJson(IReadOnlyList collection, string filePath) + { + var jsonData = new object[collection.Count]; + + for (var i = 0; i < collection.Count; i++) + jsonData[i] = new + { + IPAddress = collection[i].IPAddress.ToString(), + MACAddress = collection[i].MACAddress.ToString(), + collection[i].InterfaceAlias, + collection[i].InterfaceIndex, + State = collection[i].State.ToString(), + AddressFamily = collection[i].AddressFamily.ToString(), + collection[i].IsMulticast + }; + + File.WriteAllText(filePath, JsonConvert.SerializeObject(jsonData, Formatting.Indented)); + } +} diff --git a/Source/NETworkManager.Models/Firewall/Firewall.cs b/Source/NETworkManager.Models/Firewall/Firewall.cs index cbf683b843..1ce86ffca4 100644 --- a/Source/NETworkManager.Models/Firewall/Firewall.cs +++ b/Source/NETworkManager.Models/Firewall/Firewall.cs @@ -281,7 +281,7 @@ private static string BuildAddScript(FirewallRule rule) { var sb = new StringBuilder(); sb.AppendLine("$params = @{"); - sb.AppendLine($" DisplayName = '{RuleIdentifier}{EscapePs(rule.Name)}'"); + sb.AppendLine($" DisplayName = '{RuleIdentifier}{PowerShellHelper.EscapeSingleQuotes(rule.Name)}'"); sb.AppendLine($" Enabled = '{(rule.IsEnabled ? "True" : "False")}'"); sb.AppendLine($" Direction = '{rule.Direction}'"); sb.AppendLine($" Action = '{rule.Action}'"); @@ -291,7 +291,7 @@ private static string BuildAddScript(FirewallRule rule) sb.AppendLine("}"); if (!string.IsNullOrWhiteSpace(rule.Description)) - sb.AppendLine($"$params['Description'] = '{EscapePs(rule.Description)}'"); + sb.AppendLine($"$params['Description'] = '{PowerShellHelper.EscapeSingleQuotes(rule.Description)}'"); if (rule.Protocol is FirewallProtocol.TCP or FirewallProtocol.UDP) { @@ -309,20 +309,13 @@ private static string BuildAddScript(FirewallRule rule) sb.AppendLine($"$params['RemoteAddress'] = {ToPsArray(rule.RemoteAddresses)}"); if (rule.Program != null && !string.IsNullOrWhiteSpace(rule.Program.Name)) - sb.AppendLine($"$params['Program'] = '{EscapePs(rule.Program.Name)}'"); + sb.AppendLine($"$params['Program'] = '{PowerShellHelper.EscapeSingleQuotes(rule.Program.Name)}'"); sb.AppendLine("New-NetFirewallRule @params"); return sb.ToString(); } - /// - /// Escapes a string for embedding inside a PowerShell single-quoted string by - /// doubling any single-quote characters. - /// - /// The raw string value to escape. - private static string EscapePs(string value) => value.Replace("'", "''"); - /// /// Builds a PowerShell array literal (e.g. @('80','443','8080-8090')) from the given values. /// New-NetFirewallRule parameters such as -LocalPort and -LocalAddress are typed as @@ -331,7 +324,7 @@ private static string BuildAddScript(FirewallRule rule) /// /// The values to embed into the array literal. private static string ToPsArray(IEnumerable values) => - $"@({string.Join(",", values.Select(v => $"'{EscapePs(v)}'"))})"; + $"@({string.Join(",", values.Select(v => $"'{PowerShellHelper.EscapeSingleQuotes(v)}'"))})"; /// /// Maps a value to the string accepted by diff --git a/Source/NETworkManager.Models/Network/ARP.cs b/Source/NETworkManager.Models/Network/ARP.cs deleted file mode 100644 index f63f1da17a..0000000000 --- a/Source/NETworkManager.Models/Network/ARP.cs +++ /dev/null @@ -1,277 +0,0 @@ -// Contains code from: https://stackoverflow.com/a/1148861/4986782 -// Modified by BornToBeRoot - -using System; -using System.Collections.Generic; -using System.ComponentModel; -using System.Linq; -using System.Management.Automation.Runspaces; -using System.Net; -using System.Net.NetworkInformation; -using System.Runtime.InteropServices; -using System.Threading; -using System.Threading.Tasks; -using NETworkManager.Utilities; -using SMA = System.Management.Automation; -using log4net; - -namespace NETworkManager.Models.Network; - -/// -/// Provides static methods to read and modify the Windows ARP table. -/// Read access uses the IpHlpApi Win32 API. Modifying operations -/// (add/delete entries, clear table) run via PowerShell in a shared -/// that is initialized once with the required -/// execution policy. A serializes access so -/// the runspace is never used concurrently. Modifying operations require -/// the application to run with elevated rights. -/// -public class ARP -{ - #region Variables - - /// - /// The logger for this class. - /// - private static readonly ILog Log = LogManager.GetLogger(typeof(ARP)); - - // The max number of physical addresses. - private const int MAXLEN_PHYSADDR = 8; - - // Define the MIB_IPNETROW structure. - [StructLayout(LayoutKind.Sequential)] - private struct MIB_IPNETROW - { - [MarshalAs(UnmanagedType.U4)] public int dwIndex; - [MarshalAs(UnmanagedType.U4)] public int dwPhysAddrLen; - [MarshalAs(UnmanagedType.U1)] public byte mac0; - [MarshalAs(UnmanagedType.U1)] public byte mac1; - [MarshalAs(UnmanagedType.U1)] public byte mac2; - [MarshalAs(UnmanagedType.U1)] public byte mac3; - [MarshalAs(UnmanagedType.U1)] public byte mac4; - [MarshalAs(UnmanagedType.U1)] public byte mac5; - [MarshalAs(UnmanagedType.U1)] public byte mac6; - [MarshalAs(UnmanagedType.U1)] public byte mac7; - [MarshalAs(UnmanagedType.U4)] public int dwAddr; - [MarshalAs(UnmanagedType.U4)] public int dwType; - } - - // Declare the GetIpNetTable function. - [DllImport("IpHlpApi.dll")] - [return: MarshalAs(UnmanagedType.U4)] - private static extern int GetIpNetTable(IntPtr pIpNetTable, [MarshalAs(UnmanagedType.U4)] ref int pdwSize, - bool bOrder); - - [DllImport("IpHlpApi.dll", SetLastError = true, CharSet = CharSet.Auto)] - internal static extern int FreeMibTable(IntPtr plpNetTable); - - // The insufficient buffer error. - private const int ERROR_INSUFFICIENT_BUFFER = 122; - - /// - /// Ensures that only one PowerShell pipeline runs on at a time. - /// - private static readonly SemaphoreSlim Lock = new(1, 1); - - /// - /// Shared PowerShell runspace, initialized once in the static constructor with - /// Set-ExecutionPolicy Bypass so subsequent operations can run without - /// repeating the policy change. - /// - private static readonly Runspace SharedRunspace; - - /// - /// Opens and runs the one-time initialization script. - /// - static ARP() - { - SharedRunspace = RunspaceFactory.CreateRunspace(); - SharedRunspace.Open(); - - using var ps = SMA.PowerShell.Create(); - ps.Runspace = SharedRunspace; - ps.AddScript("Set-ExecutionPolicy -ExecutionPolicy Bypass -Scope Process").Invoke(); - } - - #endregion - - #region Methods - - public static Task> GetTableAsync() - { - return Task.Run(GetTable); - } - - private static List GetTable() - { - var list = new List(); - - // The number of bytes needed. - var bytesNeeded = 0; - - // The result from the API call. - var result = GetIpNetTable(IntPtr.Zero, ref bytesNeeded, false); - - // Call the function, expecting an insufficient buffer. - if (result != ERROR_INSUFFICIENT_BUFFER) - // Throw an exception. - throw new Win32Exception(result); - - // Allocate the memory, do it in a try/finally block, to ensure - // that it is released. - var buffer = IntPtr.Zero; - - // Try/finally. - try - { - // Allocate the memory. - buffer = Marshal.AllocCoTaskMem(bytesNeeded); - - // Make the call again. If it did not succeed, then - // raise an error. - result = GetIpNetTable(buffer, ref bytesNeeded, false); - - // If the result is not 0 (no error), then throw an exception. - if (result != 0) - // Throw an exception. - throw new Win32Exception(result); - - // Now we have the buffer, we have to marshal it. We can read - // the first 4 bytes to get the length of the buffer. - var entries = Marshal.ReadInt32(buffer); - - // Increment the memory pointer by the size of the int. - var currentBuffer = new IntPtr(buffer.ToInt64() + - Marshal.SizeOf(typeof(int))); - - // Allocate an array of entries. - var table = new MIB_IPNETROW[entries]; - - // Cycle through the entries. - for (var i = 0; i < entries; i++) - // Call PtrToStructure, getting the structure information. - table[i] = (MIB_IPNETROW)Marshal.PtrToStructure(new - IntPtr(currentBuffer.ToInt64() + i * Marshal.SizeOf(typeof(MIB_IPNETROW))), typeof(MIB_IPNETROW)); - - var virtualMAC = new PhysicalAddress(new byte[] { 0, 0, 0, 0, 0, 0 }); - var broadcastMAC = new PhysicalAddress(new byte[] { 255, 255, 255, 255, 255, 255 }); - - for (var i = 0; i < entries; i++) - { - var row = table[i]; - - var ipAddress = new IPAddress(BitConverter.GetBytes(row.dwAddr)); - var macAddress = new PhysicalAddress(new[] - { row.mac0, row.mac1, row.mac2, row.mac3, row.mac4, row.mac5 }); - - // Filter 0.0.0.0.0.0, 255.255.255.255.255.255 - if (!macAddress.Equals(virtualMAC) && !macAddress.Equals(broadcastMAC)) - list.Add(new ARPInfo(ipAddress, macAddress, - ipAddress.IsIPv6Multicast || IPv4Address.IsMulticast(ipAddress))); - } - - return list; - } - finally - { - // Release the memory. - FreeMibTable(buffer); - } - } - - public static string GetMACAddress(IPAddress ipAddress) - { - var arpInfo = GetTable().FirstOrDefault(x => x.IPAddress.Equals(ipAddress)); - - return arpInfo?.MACAddress.ToString(); - } - - /// - /// Adds a static ARP entry by running arp -s through the shared PowerShell - /// runspace. Requires the application to run with elevated rights. - /// - /// The IP address of the entry. - /// The MAC address of the entry, separated with -. - /// - /// Thrown when the PowerShell pipeline reports one or more errors. - /// - public static async Task AddEntryAsync(string ipAddress, string macAddress) - { - await InvokeAsync($"arp -s '{EscapePs(ipAddress)}' '{EscapePs(macAddress)}' 2>&1 | Out-String"); - } - - /// - /// Removes a single ARP entry by running arp -d through the shared PowerShell - /// runspace. Requires the application to run with elevated rights. - /// - /// The IP address of the entry to remove. - /// - /// Thrown when the PowerShell pipeline reports one or more errors. - /// - public static async Task DeleteEntryAsync(string ipAddress) - { - await InvokeAsync($"arp -d '{EscapePs(ipAddress)}' 2>&1 | Out-String"); - } - - /// - /// Clears the entire ARP cache by running netsh interface ip delete arpcache - /// through the shared PowerShell runspace. Requires the application to run with - /// elevated rights. - /// - /// - /// Thrown when the PowerShell pipeline reports one or more errors. - /// - public static async Task DeleteTableAsync() - { - await InvokeAsync("netsh interface ip delete arpcache 2>&1 | Out-String"); - } - - /// - /// Runs on the shared runspace and throws when the - /// command exits with a non-zero exit code or writes to the PowerShell error stream. - /// - /// The PowerShell script to execute. - private static async Task InvokeAsync(string script) - { - await Lock.WaitAsync(); - try - { - await Task.Run(() => - { - using var ps = SMA.PowerShell.Create(); - ps.Runspace = SharedRunspace; - - ps.AddScript(script + @" -if ($LASTEXITCODE -ne 0) { Write-Error ""Exit code: $LASTEXITCODE"" }"); - var results = ps.Invoke(); - - if (ps.Streams.Error.Count > 0) - { - var output = string.Join(Environment.NewLine, - results.Select(r => r?.ToString()).Where(s => !string.IsNullOrWhiteSpace(s))); - var errors = string.Join(Environment.NewLine, ps.Streams.Error); - - var message = string.IsNullOrWhiteSpace(output) - ? errors - : $"{output.Trim()}{Environment.NewLine}{errors}"; - - Log.Warn($"PowerShell error: {message}"); - throw new Exception(message); - } - }); - } - finally - { - Lock.Release(); - } - } - - /// - /// Escapes a string for embedding inside a PowerShell single-quoted string by - /// doubling any single-quote characters. - /// - /// The raw string value to escape. - private static string EscapePs(string value) => value.Replace("'", "''"); - - #endregion -} \ No newline at end of file diff --git a/Source/NETworkManager.Models/Network/ARPInfo.cs b/Source/NETworkManager.Models/Network/ARPInfo.cs deleted file mode 100644 index 9b0929414e..0000000000 --- a/Source/NETworkManager.Models/Network/ARPInfo.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System.Net; -using System.Net.NetworkInformation; - -namespace NETworkManager.Models.Network; - -/// -/// Class to store information about an ARP entry. -/// -public class ARPInfo -{ - /// - /// Creates a new instance of with the given parameters. - /// - /// IP address of the ARP entry. - /// Physical address of the ARP entry. - /// Indicates if the ARP entry is a multicast address. - public ARPInfo(IPAddress ipAddress, PhysicalAddress macAddress, bool isMulticast) - { - IPAddress = ipAddress; - MACAddress = macAddress; - IsMulticast = isMulticast; - } - - /// - /// IP address of the ARP entry. - /// - public IPAddress IPAddress { get; set; } - - /// - /// Physical address of the ARP entry. - /// - public PhysicalAddress MACAddress { get; set; } - - /// - /// Indicates if the ARP entry is a multicast address. - /// - public bool IsMulticast { get; set; } -} \ No newline at end of file diff --git a/Source/NETworkManager.Models/Network/IPScanner.cs b/Source/NETworkManager.Models/Network/IPScanner.cs index 7ff2e412a0..1210a75fce 100644 --- a/Source/NETworkManager.Models/Network/IPScanner.cs +++ b/Source/NETworkManager.Models/Network/IPScanner.cs @@ -161,8 +161,8 @@ public void ScanAsync(IEnumerable<(IPAddress ipAddress, string hostname)> hosts, if (options.ResolveMACAddress) { - // Get info from arp table - arpMACAddress = ARP.GetMACAddress(host.ipAddress); + // Get info from neighbor table + arpMACAddress = NeighborTable.GetMACAddress(host.ipAddress); // Check if it is the local mac if (string.IsNullOrEmpty(arpMACAddress)) diff --git a/Source/NETworkManager.Models/Network/IPv4Address.cs b/Source/NETworkManager.Models/Network/IPv4Address.cs index c6cd342f84..77a2069c11 100644 --- a/Source/NETworkManager.Models/Network/IPv4Address.cs +++ b/Source/NETworkManager.Models/Network/IPv4Address.cs @@ -89,7 +89,7 @@ public static bool IsMulticast(IPAddress ipAddress) { var ip = ToInt32(ipAddress); - return ip >= IPv4MulticastStart && ip <= IPv4MulticastEnd; + return ip is >= IPv4MulticastStart and <= IPv4MulticastEnd; } /// diff --git a/Source/NETworkManager.Models/Network/NeighborInfo.cs b/Source/NETworkManager.Models/Network/NeighborInfo.cs new file mode 100644 index 0000000000..a38e725f25 --- /dev/null +++ b/Source/NETworkManager.Models/Network/NeighborInfo.cs @@ -0,0 +1,63 @@ +using System.Net; +using System.Net.NetworkInformation; +using System.Net.Sockets; + +namespace NETworkManager.Models.Network; + +/// +/// Class to store information about an IP neighbor entry (IPv4 ARP / IPv6 NDP). +/// +public class NeighborInfo +{ + /// + /// Creates a new instance of with the given parameters. + /// + public NeighborInfo(IPAddress ipAddress, PhysicalAddress macAddress, bool isMulticast, int interfaceIndex, + string interfaceAlias, NeighborState state, AddressFamily addressFamily) + { + IPAddress = ipAddress; + MACAddress = macAddress; + IsMulticast = isMulticast; + InterfaceIndex = interfaceIndex; + InterfaceAlias = interfaceAlias; + State = state; + AddressFamily = addressFamily; + } + + /// + /// IP address of the neighbor entry (IPv4 or IPv6). + /// + public IPAddress IPAddress { get; set; } + + /// + /// Physical (link-layer) address of the neighbor entry. May be empty for + /// / entries. + /// + public PhysicalAddress MACAddress { get; set; } + + /// + /// Indicates whether the IP address is a multicast address. + /// + public bool IsMulticast { get; set; } + + /// + /// Index of the network interface this neighbor entry belongs to. + /// + public int InterfaceIndex { get; set; } + + /// + /// Human-readable name of the network interface (e.g. "Ethernet", "Wi-Fi"). + /// + public string InterfaceAlias { get; set; } + + /// + /// Reachability state of the neighbor entry. + /// + public NeighborState State { get; set; } + + /// + /// Address family ( for IPv4, + /// for IPv6). + /// + public AddressFamily AddressFamily { get; set; } +} diff --git a/Source/NETworkManager.Models/Network/NeighborState.cs b/Source/NETworkManager.Models/Network/NeighborState.cs new file mode 100644 index 0000000000..761e5339ad --- /dev/null +++ b/Source/NETworkManager.Models/Network/NeighborState.cs @@ -0,0 +1,17 @@ +namespace NETworkManager.Models.Network; + +/// +/// Reachability state of an IP neighbor entry. +/// Values match the State field of the MIB_IPNET_ROW2 structure +/// returned by GetIpNetTable2, so a direct cast is safe. +/// +public enum NeighborState +{ + Unreachable = 1, + Incomplete = 2, + Probe = 3, + Delay = 4, + Stale = 5, + Reachable = 6, + Permanent = 7 +} diff --git a/Source/NETworkManager.Models/Network/NeighborTable.cs b/Source/NETworkManager.Models/Network/NeighborTable.cs new file mode 100644 index 0000000000..75efebbca5 --- /dev/null +++ b/Source/NETworkManager.Models/Network/NeighborTable.cs @@ -0,0 +1,447 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Management.Automation.Runspaces; +using System.Net; +using System.Net.NetworkInformation; +using System.Net.Sockets; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using System.Linq; +using NETworkManager.Utilities; +using SMA = System.Management.Automation; +using log4net; + +namespace NETworkManager.Models.Network; + +/// +/// Provides static methods to read and modify the Windows IP neighbor table +/// (IPv4 ARP and IPv6 NDP). Read access uses the GetIpNetTable2 Win32 API. +/// Modifying operations (add/delete entries, clear table) run via PowerShell in a +/// shared that is initialized once with the required execution +/// policy and the NetTCPIP module imported. A +/// serializes access so the runspace is never used concurrently. Modifying operations +/// require the application to run with elevated rights. +/// +public class NeighborTable +{ + #region Variables + + /// + /// The logger for this class. + /// + private static readonly ILog Log = LogManager.GetLogger(typeof(NeighborTable)); + + // Address family constants for SOCKADDR_INET / GetIpNetTable2. + private const ushort AF_UNSPEC = 0; + private const ushort AF_INET = 2; + private const ushort AF_INET6 = 23; + + // Maximum length of a physical address inside MIB_IPNET_ROW2. + private const int IF_MAX_PHYS_ADDRESS_LENGTH = 32; + + // Size of SOCKADDR_INET (matches sizeof(SOCKADDR_IN6) = 28). + private const int SOCKADDR_INET_SIZE = 28; + + /// + /// Mirror of the native MIB_IPNET_ROW2 structure. The first 28 bytes hold + /// a SOCKADDR_INET union; we read the address family from the first two + /// bytes and parse the IPv4 / IPv6 address from the union accordingly. + /// + [StructLayout(LayoutKind.Sequential, Pack = 8)] + private struct MIB_IPNET_ROW2 + { + [MarshalAs(UnmanagedType.ByValArray, SizeConst = SOCKADDR_INET_SIZE)] + public byte[] Address; + + public uint InterfaceIndex; + public ulong InterfaceLuid; + + [MarshalAs(UnmanagedType.ByValArray, SizeConst = IF_MAX_PHYS_ADDRESS_LENGTH)] + public byte[] PhysicalAddress; + + public uint PhysicalAddressLength; + public uint State; + public uint Flags; + public uint ReachabilityTime; + } + + /// Retrieves all entries from the IPv4/IPv6 neighbor cache. Returns 0 on success. + [DllImport("Iphlpapi.dll")] + private static extern uint GetIpNetTable2(ushort family, out IntPtr table); + + /// Frees a MIB table buffer allocated by GetIpNetTable2. + [DllImport("Iphlpapi.dll")] + private static extern void FreeMibTable(IntPtr memory); + + /// Looks up a single neighbor cache entry by address. Returns 0 on success. + [DllImport("Iphlpapi.dll")] + private static extern uint GetIpNetEntry2(ref MIB_IPNET_ROW2 row); + + /// + /// Ensures that only one PowerShell pipeline runs on at a time. + /// + private static readonly SemaphoreSlim Lock = new(1, 1); + + /// Protects reads and writes to and . + private static readonly Lock InterfaceAliasCacheLock = new(); + + /// Cached result of ; until first use. + private static Dictionary _interfaceAliasCache; + + /// UTC time after which must be rebuilt. + private static DateTime _interfaceAliasCacheExpiry = DateTime.MinValue; + + /// How long the interface alias cache is considered fresh before being rebuilt (5 minutes). + private static readonly TimeSpan InterfaceAliasCacheDuration = TimeSpan.FromMinutes(5); + + /// + /// Shared PowerShell runspace, initialized once in the static constructor with + /// Set-ExecutionPolicy Bypass and Import-Module NetTCPIP so that + /// subsequent operations do not need to repeat the module import. + /// + private static readonly Runspace SharedRunspace; + + /// + /// Opens and runs the one-time initialization script. + /// + static NeighborTable() + { + SharedRunspace = RunspaceFactory.CreateRunspace(); + SharedRunspace.Open(); + + using var ps = SMA.PowerShell.Create(); + ps.Runspace = SharedRunspace; + ps.AddScript(@" +Set-ExecutionPolicy -ExecutionPolicy Bypass -Scope Process +Import-Module NetTCPIP -ErrorAction Stop").Invoke(); + } + + #endregion + + #region Methods + + /// + /// Returns all entries from the IPv4 and IPv6 neighbor cache asynchronously. + /// + public static Task> GetTableAsync() + { + return Task.Run(GetTable); + } + + /// + /// Returns a list of available network interfaces as index/name pairs, sorted by name. + /// Uses the cached alias map; see . + /// + public static Task>> GetInterfacesAsync() + { + return Task.Run(() => GetCachedInterfaceAliasMap() + .OrderBy(kv => kv.Value) + .ToList()); + } + + /// + /// Reads the full neighbor table via GetIpNetTable2 and returns it as a list + /// of objects. IPv4 entries with a virtual or broadcast MAC + /// are suppressed. + /// + private static List GetTable() + { + var list = new List(); + + var virtualMAC = new PhysicalAddress([0, 0, 0, 0, 0, 0]); + var broadcastMAC = new PhysicalAddress([255, 255, 255, 255, 255, 255]); + + var aliasMap = GetCachedInterfaceAliasMap(); + + var table = IntPtr.Zero; + + try + { + var result = GetIpNetTable2(AF_UNSPEC, out table); + + if (result != 0) + throw new Win32Exception((int)result); + + // First 4 bytes hold NumEntries; the array of MIB_IPNET_ROW2 starts after + // 4 bytes of padding (the row contains a ULONG64, requiring 8-byte alignment). + var numEntries = Marshal.ReadInt32(table); + var rowSize = Marshal.SizeOf(); + var arrayPtr = IntPtr.Add(table, 8); + + for (var i = 0; i < numEntries; i++) + { + var row = Marshal.PtrToStructure(IntPtr.Add(arrayPtr, i * rowSize)); + + var family = BitConverter.ToUInt16(row.Address, 0); + + IPAddress ipAddress; + AddressFamily addressFamily; + + switch (family) + { + case AF_INET: + { + // SOCKADDR_IN: family(2)+port(2)+addr(4)+zero(8) — addr at offset 4 + var addrBytes = new byte[4]; + Buffer.BlockCopy(row.Address, 4, addrBytes, 0, 4); + ipAddress = new IPAddress(addrBytes); + addressFamily = AddressFamily.InterNetwork; + break; + } + case AF_INET6: + { + // SOCKADDR_IN6: family(2)+port(2)+flowinfo(4)+addr(16)+scope_id(4) — addr at offset 8 + var addrBytes = new byte[16]; + Buffer.BlockCopy(row.Address, 8, addrBytes, 0, 16); + var scopeId = BitConverter.ToUInt32(row.Address, 24); + ipAddress = new IPAddress(addrBytes, scopeId); + addressFamily = AddressFamily.InterNetworkV6; + break; + } + default: + continue; + } + + var macLen = (int)row.PhysicalAddressLength; + + if (macLen is < 0 or > IF_MAX_PHYS_ADDRESS_LENGTH) + macLen = 0; + + var macBytes = new byte[macLen]; + + if (macLen > 0) + Buffer.BlockCopy(row.PhysicalAddress, 0, macBytes, 0, macLen); + + var macAddress = new PhysicalAddress(macBytes); + + // Suppress virtual/broadcast MAC for IPv4 to match legacy behavior. + if (addressFamily == AddressFamily.InterNetwork && + (macAddress.Equals(virtualMAC) || macAddress.Equals(broadcastMAC))) + continue; + + aliasMap.TryGetValue((int)row.InterfaceIndex, out var alias); + + list.Add(new NeighborInfo( + ipAddress, + macAddress, + ipAddress.IsIPv6Multicast || IPv4Address.IsMulticast(ipAddress), + (int)row.InterfaceIndex, + alias ?? string.Empty, + (NeighborState)row.State, + addressFamily)); + } + + return list; + } + finally + { + if (table != IntPtr.Zero) + FreeMibTable(table); + } + } + + /// + /// Returns the interface alias map, rebuilding it via + /// when the cache has expired or has not yet been populated. + /// + private static Dictionary GetCachedInterfaceAliasMap() + { + lock (InterfaceAliasCacheLock) + { + if (DateTime.UtcNow < _interfaceAliasCacheExpiry && _interfaceAliasCache != null) + return _interfaceAliasCache; + + _interfaceAliasCache = BuildInterfaceAliasMap(); + _interfaceAliasCacheExpiry = DateTime.UtcNow.Add(InterfaceAliasCacheDuration); + return _interfaceAliasCache; + } + } + + /// + /// Builds a dictionary that maps an interface index (IPv4 or IPv6) to the + /// human-readable interface name (e.g. "Ethernet", "Wi-Fi"). + /// + private static Dictionary BuildInterfaceAliasMap() + { + var map = new Dictionary(); + + foreach (var ni in System.Net.NetworkInformation.NetworkInterface.GetAllNetworkInterfaces()) + { + try + { + var props = ni.GetIPProperties(); + + if (ni.Supports(NetworkInterfaceComponent.IPv4)) + map[props.GetIPv4Properties().Index] = ni.Name; + + if (ni.Supports(NetworkInterfaceComponent.IPv6)) + map[props.GetIPv6Properties().Index] = ni.Name; + } + catch (Exception ex) + { + Log.Warn($"Failed to read interface properties for '{ni.Name}': {ex.Message}"); + } + } + + return map; + } + + /// + /// Returns the MAC address for via GetIpNetEntry2, + /// or when no cache entry exists. Supports both IPv4 and IPv6. + /// + public static string GetMACAddress(IPAddress ipAddress) + { + var addressFamily = ipAddress.AddressFamily; + + if (addressFamily != AddressFamily.InterNetwork && addressFamily != AddressFamily.InterNetworkV6) + return null; + + var row = new MIB_IPNET_ROW2 + { + Address = new byte[SOCKADDR_INET_SIZE], + PhysicalAddress = new byte[IF_MAX_PHYS_ADDRESS_LENGTH] + }; + + if (addressFamily == AddressFamily.InterNetwork) + { + // SOCKADDR_IN: family(2) at offset 0, addr(4) at offset 4 + BitConverter.GetBytes(AF_INET).CopyTo(row.Address, 0); + ipAddress.GetAddressBytes().CopyTo(row.Address, 4); + } + else + { + // SOCKADDR_IN6: family(2) at offset 0, addr(16) at offset 8, scope_id(4) at offset 24 + BitConverter.GetBytes(AF_INET6).CopyTo(row.Address, 0); + ipAddress.GetAddressBytes().CopyTo(row.Address, 8); + BitConverter.GetBytes((uint)ipAddress.ScopeId).CopyTo(row.Address, 24); + } + + if (GetIpNetEntry2(ref row) != 0) + return null; + + var macLen = (int)row.PhysicalAddressLength; + if (macLen is <= 0 or > IF_MAX_PHYS_ADDRESS_LENGTH) + return null; + + var macBytes = new byte[macLen]; + Buffer.BlockCopy(row.PhysicalAddress, 0, macBytes, 0, macLen); + + return new PhysicalAddress(macBytes).ToString(); + } + + /// + /// Adds a permanent neighbor entry for with the given + /// by running New-NetNeighbor through the shared + /// PowerShell runspace. Requires the application to run with elevated rights. + /// + /// The IP address (IPv4 or IPv6) of the entry. + /// The link-layer address of the entry, separated with -. + /// The index of the network interface to add the entry on. + /// + /// Thrown when the PowerShell pipeline reports one or more errors. + /// + public static async Task AddEntryAsync(string ipAddress, string macAddress, int interfaceIndex) + { + await Lock.WaitAsync(); + try + { + await Task.Run(() => + { + using var ps = SMA.PowerShell.Create(); + ps.Runspace = SharedRunspace; + + ps.AddScript($@"New-NetNeighbor -InterfaceIndex {interfaceIndex} -IPAddress '{PowerShellHelper.EscapeSingleQuotes(ipAddress)}' -LinkLayerAddress '{PowerShellHelper.EscapeSingleQuotes(macAddress)}' -State Permanent -ErrorAction Stop | Out-Null"); + ps.Invoke(); + + ThrowOnError(ps); + }); + } + finally + { + Lock.Release(); + } + } + + /// + /// Removes the neighbor entry for by running + /// Remove-NetNeighbor through the shared PowerShell runspace. Requires the + /// application to run with elevated rights. + /// + /// The IP address of the entry to remove. + /// The index of the network interface the entry belongs to. + /// + /// Thrown when the PowerShell pipeline reports one or more errors. + /// + public static async Task DeleteEntryAsync(string ipAddress, int interfaceIndex) + { + await Lock.WaitAsync(); + try + { + await Task.Run(() => + { + using var ps = SMA.PowerShell.Create(); + ps.Runspace = SharedRunspace; + + ps.AddScript($@"Remove-NetNeighbor -InterfaceIndex {interfaceIndex} -IPAddress '{PowerShellHelper.EscapeSingleQuotes(ipAddress)}' -Confirm:$false -ErrorAction Stop | Out-Null"); + ps.Invoke(); + + ThrowOnError(ps); + }); + } + finally + { + Lock.Release(); + } + } + + /// + /// Clears all dynamic neighbor entries (IPv4 + IPv6) by piping + /// Get-NetNeighbor into Remove-NetNeighbor, excluding entries whose + /// state is Permanent. Requires the application to run with elevated rights. + /// + /// + /// Thrown when the PowerShell pipeline reports one or more errors. + /// + public static async Task DeleteTableAsync() + { + await Lock.WaitAsync(); + try + { + await Task.Run(() => + { + using var ps = SMA.PowerShell.Create(); + ps.Runspace = SharedRunspace; + + ps.AddScript(@"Get-NetNeighbor -ErrorAction SilentlyContinue | Where-Object { $_.State -ne 'Permanent' } | Remove-NetNeighbor -Confirm:$false -ErrorAction Stop | Out-Null"); + ps.Invoke(); + + ThrowOnError(ps); + }); + } + finally + { + Lock.Release(); + } + } + + /// + /// Throws an whose message is the joined PowerShell error + /// stream when reported one or more errors. + /// + private static void ThrowOnError(SMA.PowerShell ps) + { + if (ps.Streams.Error.Count == 0) + return; + + var message = string.Join(Environment.NewLine, ps.Streams.Error); + + Log.Warn($"PowerShell error: {message}"); + + throw new Exception(message); + } + + #endregion +} diff --git a/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs b/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs index 51623e151f..5303a341df 100644 --- a/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs +++ b/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs @@ -275,10 +275,10 @@ public static class GlobalStaticConfiguration public static AutoRefreshTimeInfo Listeners_AutoRefreshTime => AutoRefreshTime.GetDefaults.First(x => x.Value == 30 && x.TimeUnit == TimeUnit.Second); - // Application: ARP Table - public static ExportFileType ARPTable_ExportFileType => ExportFileType.Csv; + // Application: Neighbor Table + public static ExportFileType NeighborTable_ExportFileType => ExportFileType.Csv; - public static AutoRefreshTimeInfo ARPTable_AutoRefreshTime => + public static AutoRefreshTimeInfo NeighborTable_AutoRefreshTime => AutoRefreshTime.GetDefaults.First(x => x.Value == 30 && x.TimeUnit == TimeUnit.Second); #endregion diff --git a/Source/NETworkManager.Settings/SettingsInfo.cs b/Source/NETworkManager.Settings/SettingsInfo.cs index 7d0caae60e..83a2568dc5 100644 --- a/Source/NETworkManager.Settings/SettingsInfo.cs +++ b/Source/NETworkManager.Settings/SettingsInfo.cs @@ -3917,9 +3917,9 @@ public ExportFileType Listeners_ExportFileType #endregion - #region ARPTable + #region NeighborTable - public bool ARPTable_AutoRefreshEnabled + public bool NeighborTable_AutoRefreshEnabled { get; set @@ -3932,7 +3932,7 @@ public bool ARPTable_AutoRefreshEnabled } } - public AutoRefreshTimeInfo ARPTable_AutoRefreshTime + public AutoRefreshTimeInfo NeighborTable_AutoRefreshTime { get; set @@ -3943,9 +3943,9 @@ public AutoRefreshTimeInfo ARPTable_AutoRefreshTime field = value; OnPropertyChanged(); } - } = GlobalStaticConfiguration.ARPTable_AutoRefreshTime; + } = GlobalStaticConfiguration.NeighborTable_AutoRefreshTime; - public string ARPTable_ExportFilePath + public string NeighborTable_ExportFilePath { get; set @@ -3958,7 +3958,7 @@ public string ARPTable_ExportFilePath } } - public ExportFileType ARPTable_ExportFileType + public ExportFileType NeighborTable_ExportFileType { get; set @@ -3969,7 +3969,20 @@ public ExportFileType ARPTable_ExportFileType field = value; OnPropertyChanged(); } - } = GlobalStaticConfiguration.ARPTable_ExportFileType; + } = GlobalStaticConfiguration.NeighborTable_ExportFileType; + + public string NeighborTable_InterfaceName + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } #endregion diff --git a/Source/NETworkManager.Settings/SettingsManager.cs b/Source/NETworkManager.Settings/SettingsManager.cs index 7b8a446e48..dc3493591a 100644 --- a/Source/NETworkManager.Settings/SettingsManager.cs +++ b/Source/NETworkManager.Settings/SettingsManager.cs @@ -680,6 +680,24 @@ private static void UpgradeToLatest(Version version) Current.General_ApplicationList.Insert( ApplicationManager.GetDefaultList().ToList().FindIndex(x => x.Name == ApplicationName.Firewall), ApplicationManager.GetDefaultList().First(x => x.Name == ApplicationName.Firewall)); + + // Replace ARPTable with NeighborTable +#pragma warning disable CS0618 + var arpTableEntry = Current.General_ApplicationList.FirstOrDefault(x => x.Name == ApplicationName.ARPTable); +#pragma warning restore CS0618 + + if (arpTableEntry != null) + { + Log.Info("Replacing obsolete \"ARPTable\" app entry with \"NeighborTable\"..."); + + var index = Current.General_ApplicationList.IndexOf(arpTableEntry); + var neighborTableEntry = ApplicationManager.GetDefaultList().First(x => x.Name == ApplicationName.NeighborTable); + + neighborTableEntry.IsVisible = arpTableEntry.IsVisible; + neighborTableEntry.IsDefault = arpTableEntry.IsDefault; + + Current.General_ApplicationList[index] = neighborTableEntry; + } } #endregion } diff --git a/Source/NETworkManager.Utilities/PowerShellHelper.cs b/Source/NETworkManager.Utilities/PowerShellHelper.cs index a888690556..dea4df89db 100644 --- a/Source/NETworkManager.Utilities/PowerShellHelper.cs +++ b/Source/NETworkManager.Utilities/PowerShellHelper.cs @@ -84,4 +84,10 @@ public static void ExecuteCommand(string command, bool asAdmin = false, ProcessW } } } + + /// + /// Escapes a string for safe embedding inside a PowerShell single-quoted string + /// by doubling any single-quote characters. + /// + public static string EscapeSingleQuotes(string value) => value.Replace("'", "''"); } diff --git a/Source/NETworkManager/MainWindow.xaml.cs b/Source/NETworkManager/MainWindow.xaml.cs index 24862c1e4f..264269aec4 100644 --- a/Source/NETworkManager/MainWindow.xaml.cs +++ b/Source/NETworkManager/MainWindow.xaml.cs @@ -667,7 +667,7 @@ private void LoadApplicationList() private IPGeolocationHostView _ipGeolocationHostView; private ConnectionsView _connectionsView; private ListenersView _listenersView; - private ARPTableView _arpTableView; + private NeighborTableView _neighborTableView; /// /// Method when the application view becomes visible (again). Either when switching the applications @@ -886,13 +886,13 @@ private void OnApplicationViewVisible(ApplicationName name) ContentControlApplication.Content = _listenersView; break; - case ApplicationName.ARPTable: - if (_arpTableView == null) - _arpTableView = new ARPTableView(); + case ApplicationName.NeighborTable: + if (_neighborTableView == null) + _neighborTableView = new NeighborTableView(); else - _arpTableView.OnViewVisible(); + _neighborTableView.OnViewVisible(); - ContentControlApplication.Content = _arpTableView; + ContentControlApplication.Content = _neighborTableView; break; default: Log.Error("Cannot show unknown application view: " + name); @@ -982,8 +982,8 @@ private void OnApplicationViewHide(ApplicationName name) case ApplicationName.Listeners: _listenersView?.OnViewHide(); break; - case ApplicationName.ARPTable: - _arpTableView?.OnViewHide(); + case ApplicationName.NeighborTable: + _neighborTableView?.OnViewHide(); break; default: Log.Error("Cannot hide unknown application view: " + name); @@ -1083,7 +1083,7 @@ await DialogHelper.ShowMessageAsync(Application.Current.MainWindow, Strings.Erro case ApplicationName.Lookup: case ApplicationName.Connections: case ApplicationName.Listeners: - case ApplicationName.ARPTable: + case ApplicationName.NeighborTable: break; default: Log.Error($"Cannot redirect data to unknown application: {data.Application}"); diff --git a/Source/NETworkManager/ViewModels/ARPTableAddEntryViewModel.cs b/Source/NETworkManager/ViewModels/ARPTableAddEntryViewModel.cs deleted file mode 100644 index bb93a36fa0..0000000000 --- a/Source/NETworkManager/ViewModels/ARPTableAddEntryViewModel.cs +++ /dev/null @@ -1,65 +0,0 @@ -using System; -using System.Windows.Input; -using NETworkManager.Utilities; - -namespace NETworkManager.ViewModels; - -/// -/// View model for adding an ARP table entry. -/// -public class ARPTableAddEntryViewModel : ViewModelBase -{ - /// - /// Initializes a new instance of the class. - /// - /// The action to execute when the add command is invoked. - /// The action to execute when the cancel command is invoked. - public ARPTableAddEntryViewModel(Action addCommand, - Action cancelHandler) - { - AddCommand = new RelayCommand(_ => addCommand(this)); - CancelCommand = new RelayCommand(_ => cancelHandler(this)); - } - - /// - /// Gets the command to add the entry. - /// - public ICommand AddCommand { get; } - - /// - /// Gets the command to cancel the operation. - /// - public ICommand CancelCommand { get; } - - /// - /// Gets or sets the IP address. - /// - public string IPAddress - { - get; - set - { - if (value == field) - return; - - field = value; - OnPropertyChanged(); - } - } - - /// - /// Gets or sets the MAC address. - /// - public string MACAddress - { - get; - set - { - if (value == field) - return; - - field = value; - OnPropertyChanged(); - } - } -} \ No newline at end of file diff --git a/Source/NETworkManager/ViewModels/NeighborTableAddEntryViewModel.cs b/Source/NETworkManager/ViewModels/NeighborTableAddEntryViewModel.cs new file mode 100644 index 0000000000..2ae3608504 --- /dev/null +++ b/Source/NETworkManager/ViewModels/NeighborTableAddEntryViewModel.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Windows.Input; +using NETworkManager.Settings; +using NETworkManager.Utilities; + +namespace NETworkManager.ViewModels; + +/// +/// View model for adding a neighbor table entry. +/// +public class NeighborTableAddEntryViewModel : ViewModelBase +{ + /// + /// Initializes a new instance of the class. + /// + /// The action to execute when the add command is invoked. + /// The action to execute when the cancel command is invoked. + /// Available network interfaces to select from. + public NeighborTableAddEntryViewModel(Action addCommand, + Action cancelHandler, + List> interfaces) + { + AddCommand = new RelayCommand(_ => addCommand(this)); + CancelCommand = new RelayCommand(_ => cancelHandler(this)); + + Interfaces = interfaces; + + if (interfaces.Count > 0) + { + var lastUsed = interfaces.FirstOrDefault(x => + x.Value == SettingsManager.Current.NeighborTable_InterfaceName); + + SelectedInterface = lastUsed.Value != null ? lastUsed : interfaces[0]; + } + } + + /// + /// Gets the command to add the entry. + /// + public ICommand AddCommand { get; } + + /// + /// Gets the command to cancel the operation. + /// + public ICommand CancelCommand { get; } + + /// + /// Gets or sets the IP address. + /// + public string IPAddress + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } + + /// + /// Gets or sets the MAC address. + /// + public string MACAddress + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } + + /// + /// Gets the list of available network interfaces. + /// + public List> Interfaces { get; } + + /// + /// Gets or sets the selected network interface. + /// + public KeyValuePair SelectedInterface + { + get; + set + { + if (value.Equals(field)) + return; + + field = value; + + SettingsManager.Current.NeighborTable_InterfaceName = value.Value; + + OnPropertyChanged(); + } + } +} diff --git a/Source/NETworkManager/ViewModels/ARPTableViewModel.cs b/Source/NETworkManager/ViewModels/NeighborTableViewModel.cs similarity index 68% rename from Source/NETworkManager/ViewModels/ARPTableViewModel.cs rename to Source/NETworkManager/ViewModels/NeighborTableViewModel.cs index 5d4ba7bcba..d2065430db 100644 --- a/Source/NETworkManager/ViewModels/ARPTableViewModel.cs +++ b/Source/NETworkManager/ViewModels/NeighborTableViewModel.cs @@ -22,24 +22,23 @@ namespace NETworkManager.ViewModels; /// -/// View model for the ARP table view. +/// View model for the neighbor table view (IPv4 ARP + IPv6 NDP). /// -public class ARPTableViewModel : ViewModelBase +public class NeighborTableViewModel : ViewModelBase { #region Contructor, load settings /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// - /// The dialog coordinator instance. - public ARPTableViewModel() + public NeighborTableViewModel() { _isLoading = true; // Result view + search ResultsView = CollectionViewSource.GetDefaultView(Results); - ((ListCollectionView)ResultsView).CustomSort = Comparer.Create((x, y) => + ((ListCollectionView)ResultsView).CustomSort = Comparer.Create((x, y) => IPAddressHelper.CompareIPAddresses(x.IPAddress, y.IPAddress)); ResultsView.Filter = o => @@ -47,18 +46,22 @@ public ARPTableViewModel() if (string.IsNullOrEmpty(Search)) return true; - if (o is not ARPInfo info) + if (o is not NeighborInfo info) return false; - // Search by IPAddress and MACAddress + var stateLocalized = ResourceTranslate(info.State); + return info.IPAddress.ToString().IndexOf(Search, StringComparison.OrdinalIgnoreCase) > -1 || info.MACAddress.ToString().IndexOf(Search.Replace("-", "").Replace(":", ""), StringComparison.OrdinalIgnoreCase) > -1 || + (info.InterfaceAlias ?? string.Empty).IndexOf(Search, StringComparison.OrdinalIgnoreCase) > -1 || + info.State.ToString().IndexOf(Search, StringComparison.OrdinalIgnoreCase) > -1 || + stateLocalized.IndexOf(Search, StringComparison.OrdinalIgnoreCase) > -1 || (info.IsMulticast ? Strings.Yes : Strings.No).IndexOf( Search, StringComparison.OrdinalIgnoreCase) > -1; }; - // Get ARP table + // Get neighbor table Refresh(true).ConfigureAwait(false); // Auto refresh @@ -66,32 +69,27 @@ public ARPTableViewModel() AutoRefreshTimes = CollectionViewSource.GetDefaultView(AutoRefreshTime.GetDefaults); SelectedAutoRefreshTime = AutoRefreshTimes.SourceCollection.Cast().FirstOrDefault(x => - x.Value == SettingsManager.Current.ARPTable_AutoRefreshTime.Value && - x.TimeUnit == SettingsManager.Current.ARPTable_AutoRefreshTime.TimeUnit); - AutoRefreshEnabled = SettingsManager.Current.ARPTable_AutoRefreshEnabled; + x.Value == SettingsManager.Current.NeighborTable_AutoRefreshTime.Value && + x.TimeUnit == SettingsManager.Current.NeighborTable_AutoRefreshTime.TimeUnit); + AutoRefreshEnabled = SettingsManager.Current.NeighborTable_AutoRefreshEnabled; _isLoading = false; } + private static string ResourceTranslate(NeighborState state) + { + return Localization.ResourceTranslator.Translate(Localization.ResourceIdentifier.NeighborState, state); + } #endregion #region Variables - private static readonly ILog Log = LogManager.GetLogger(typeof(ARPTableViewModel)); + private static readonly ILog Log = LogManager.GetLogger(typeof(NeighborTableViewModel)); - /// - /// Indicates whether the view model is loading. - /// private readonly bool _isLoading; - /// - /// The timer for auto-refresh. - /// private readonly DispatcherTimer _autoRefreshTimer = new(); - /// - /// Gets or sets the search text. - /// public string Search { get; @@ -108,10 +106,7 @@ public string Search } } - /// - /// Gets or sets the collection of ARP results. - /// - public ObservableCollection Results + public ObservableCollection Results { get; set @@ -124,15 +119,9 @@ public ObservableCollection Results } } = []; - /// - /// Gets the collection view for the ARP results. - /// public ICollectionView ResultsView { get; } - /// - /// Gets or sets the currently selected ARP result. - /// - public ARPInfo SelectedResult + public NeighborInfo SelectedResult { get; set @@ -145,9 +134,6 @@ public ARPInfo SelectedResult } } - /// - /// Gets or sets the list of selected ARP results. - /// public IList SelectedResults { get; @@ -161,9 +147,6 @@ public IList SelectedResults } } = new ArrayList(); - /// - /// Gets or sets a value indicating whether auto-refresh is enabled. - /// public bool AutoRefreshEnabled { get; @@ -173,11 +156,10 @@ public bool AutoRefreshEnabled return; if (!_isLoading) - SettingsManager.Current.ARPTable_AutoRefreshEnabled = value; + SettingsManager.Current.NeighborTable_AutoRefreshEnabled = value; field = value; - // Start timer to refresh automatically if (value) { _autoRefreshTimer.Interval = AutoRefreshTime.CalculateTimeSpan(SelectedAutoRefreshTime); @@ -192,14 +174,8 @@ public bool AutoRefreshEnabled } } - /// - /// Gets the collection view for the auto-refresh times. - /// public ICollectionView AutoRefreshTimes { get; } - /// - /// Gets or sets the selected auto-refresh time. - /// public AutoRefreshTimeInfo SelectedAutoRefreshTime { get; @@ -209,7 +185,7 @@ public AutoRefreshTimeInfo SelectedAutoRefreshTime return; if (!_isLoading) - SettingsManager.Current.ARPTable_AutoRefreshTime = value; + SettingsManager.Current.NeighborTable_AutoRefreshTime = value; field = value; @@ -223,9 +199,6 @@ public AutoRefreshTimeInfo SelectedAutoRefreshTime } } - /// - /// Gets or sets a value indicating whether the view model is currently refreshing. - /// public bool IsRefreshing { get; @@ -239,10 +212,6 @@ public bool IsRefreshing } } - /// - /// Gets or sets a value indicating whether the view model is modifying an entry - /// (add entry, delete entry, delete table). - /// public bool IsModifying { get; @@ -256,9 +225,6 @@ public bool IsModifying } } - /// - /// Gets or sets a value indicating whether the status message is displayed. - /// public bool IsStatusMessageDisplayed { get; @@ -272,9 +238,6 @@ public bool IsStatusMessageDisplayed } } - /// - /// Gets the status message. - /// public string StatusMessage { get; @@ -292,16 +255,8 @@ private set #region ICommands & Actions - /// - /// Gets the command to refresh the ARP table. - /// public ICommand RefreshCommand => new RelayCommand(_ => RefreshAction().ConfigureAwait(false), Refresh_CanExecute); - /// - /// Checks if the refresh command can be executed. - /// - /// The command parameter. - /// true if the command can be executed; otherwise, false. private bool Refresh_CanExecute(object parameter) { return Application.Current.MainWindow != null && @@ -312,9 +267,6 @@ private bool Refresh_CanExecute(object parameter) !AutoRefreshEnabled; } - /// - /// Action to refresh the ARP table. - /// private async Task RefreshAction() { IsStatusMessageDisplayed = false; @@ -322,15 +274,9 @@ private async Task RefreshAction() await Refresh(); } - /// - /// Gets the command to delete the ARP table. - /// public ICommand DeleteTableCommand => new RelayCommand(_ => DeleteTableAction().ConfigureAwait(false), ModifyEntry_CanExecute); - /// - /// Action to delete the ARP table. - /// private async Task DeleteTableAction() { IsModifying = true; @@ -338,7 +284,7 @@ private async Task DeleteTableAction() try { - await ARP.DeleteTableAsync(); + await NeighborTable.DeleteTableAsync(); await Refresh(); } @@ -353,15 +299,9 @@ private async Task DeleteTableAction() } } - /// - /// Gets the command to delete an ARP entry. - /// public ICommand DeleteEntryCommand => new RelayCommand(_ => DeleteEntryAction().ConfigureAwait(false), ModifyEntry_CanExecute); - /// - /// Action to delete an ARP entry. - /// private async Task DeleteEntryAction() { IsModifying = true; @@ -369,7 +309,7 @@ private async Task DeleteEntryAction() try { - await ARP.DeleteEntryAsync(SelectedResult.IPAddress.ToString()); + await NeighborTable.DeleteEntryAsync(SelectedResult.IPAddress.ToString(), SelectedResult.InterfaceIndex); await Refresh(); } @@ -384,30 +324,26 @@ private async Task DeleteEntryAction() } } - /// - /// Gets the command to add an ARP entry. - /// public ICommand AddEntryCommand => new RelayCommand(_ => AddEntryAction().ConfigureAwait(false), ModifyEntry_CanExecute); - /// - /// Action to add an ARP entry. - /// private async Task AddEntryAction() { IsModifying = true; IsStatusMessageDisplayed = false; - var childWindow = new ARPTableAddEntryChildWindow(); + var interfaces = await NeighborTable.GetInterfacesAsync(); - var childWindowViewModel = new ARPTableAddEntryViewModel(async instance => + var childWindow = new NeighborTableAddEntryChildWindow(); + + var childWindowViewModel = new NeighborTableAddEntryViewModel(async instance => { childWindow.IsOpen = false; ConfigurationManager.Current.IsChildWindowOpen = false; try { - await ARP.AddEntryAsync(instance.IPAddress, MACAddressHelper.Format(instance.MACAddress, "-")); + await NeighborTable.AddEntryAsync(instance.IPAddress, MACAddressHelper.Format(instance.MACAddress, "-"), instance.SelectedInterface.Key); await Refresh(); } @@ -426,7 +362,7 @@ private async Task AddEntryAction() ConfigurationManager.Current.IsChildWindowOpen = false; IsModifying = false; - }); + }, interfaces); childWindow.Title = Strings.AddEntry; @@ -437,9 +373,6 @@ private async Task AddEntryAction() await Application.Current.MainWindow.ShowChildWindowAsync(childWindow); } - /// - /// Checks if the entry modification commands can be executed. - /// private bool ModifyEntry_CanExecute(object parameter) { return ConfigurationManager.Current.IsAdmin && @@ -450,14 +383,8 @@ private bool ModifyEntry_CanExecute(object parameter) !IsModifying; } - /// - /// Gets the command to restart the application as administrator. - /// public ICommand RestartAsAdminCommand => new RelayCommand(_ => RestartAsAdminAction().ConfigureAwait(false)); - /// - /// Action to restart the application as administrator. - /// private async Task RestartAsAdminAction() { try @@ -471,14 +398,8 @@ await DialogHelper.ShowMessageAsync(Application.Current.MainWindow, Strings.Erro } } - /// - /// Gets the command to export the ARP table. - /// public ICommand ExportCommand => new RelayCommand(_ => ExportAction().ConfigureAwait(false)); - /// - /// Action to export the ARP table. - /// private Task ExportAction() { var childWindow = new ExportChildWindow(); @@ -493,7 +414,7 @@ private Task ExportAction() ExportManager.Export(instance.FilePath, instance.FileType, instance.ExportAll ? Results - : new ObservableCollection(SelectedResults.Cast().ToArray())); + : new ObservableCollection(SelectedResults.Cast().ToArray())); } catch (Exception ex) { @@ -504,16 +425,16 @@ await DialogHelper.ShowMessageAsync(Application.Current.MainWindow, Strings.Erro Environment.NewLine + ex.Message, ChildWindowIcon.Error); } - SettingsManager.Current.ARPTable_ExportFileType = instance.FileType; - SettingsManager.Current.ARPTable_ExportFilePath = instance.FilePath; + SettingsManager.Current.NeighborTable_ExportFileType = instance.FileType; + SettingsManager.Current.NeighborTable_ExportFilePath = instance.FilePath; }, _ => { childWindow.IsOpen = false; ConfigurationManager.Current.IsChildWindowOpen = false; }, [ ExportFileType.Csv, ExportFileType.Xml, ExportFileType.Json - ], true, SettingsManager.Current.ARPTable_ExportFileType, - SettingsManager.Current.ARPTable_ExportFilePath); + ], true, SettingsManager.Current.NeighborTable_ExportFileType, + SettingsManager.Current.NeighborTable_ExportFilePath); childWindow.Title = Strings.Export; @@ -528,10 +449,6 @@ await DialogHelper.ShowMessageAsync(Application.Current.MainWindow, Strings.Erro #region Methods - /// - /// Refreshes the ARP table. - /// - /// Indicates whether this is the initial refresh. private async Task Refresh(bool init = false) { IsRefreshing = true; @@ -544,7 +461,7 @@ private async Task Refresh(bool init = false) Results.Clear(); - (await ARP.GetTableAsync()).ForEach(Results.Add); + (await NeighborTable.GetTableAsync()).ForEach(Results.Add); StatusMessage = string.Format(Strings.ReloadedAtX, DateTime.Now.ToShortTimeString()); IsStatusMessageDisplayed = true; @@ -552,22 +469,14 @@ private async Task Refresh(bool init = false) IsRefreshing = false; } - /// - /// Called when the view becomes visible. - /// public void OnViewVisible() { - // Restart timer... if (AutoRefreshEnabled) _autoRefreshTimer.Start(); } - /// - /// Called when the view is hidden. - /// public void OnViewHide() { - // Temporarily stop timer... if (AutoRefreshEnabled) _autoRefreshTimer.Stop(); } @@ -576,20 +485,14 @@ public void OnViewHide() #region Events - /// - /// Handles the Tick event of the auto-refresh timer. - /// private async void AutoRefreshTimer_Tick(object sender, EventArgs e) { - // Stop timer... _autoRefreshTimer.Stop(); - // Refresh await Refresh(); - // Restart timer... _autoRefreshTimer.Start(); } #endregion -} \ No newline at end of file +} diff --git a/Source/NETworkManager/Views/ARPTableAddEntryChildWindow.xaml b/Source/NETworkManager/Views/NeighborTableAddEntryChildWindow.xaml similarity index 86% rename from Source/NETworkManager/Views/ARPTableAddEntryChildWindow.xaml rename to Source/NETworkManager/Views/NeighborTableAddEntryChildWindow.xaml index 60bc100268..d5374df0e0 100644 --- a/Source/NETworkManager/Views/ARPTableAddEntryChildWindow.xaml +++ b/Source/NETworkManager/Views/NeighborTableAddEntryChildWindow.xaml @@ -1,4 +1,4 @@ - + mc:Ignorable="d" d:DataContext="{d:DesignInstance viewModels:NeighborTableAddEntryViewModel}"> @@ -33,6 +32,8 @@ + + - + + + @@ -87,4 +94,4 @@ Style="{StaticResource DefaultButton}" /> - \ No newline at end of file + diff --git a/Source/NETworkManager/Views/ARPTableAddEntryChildWindow.xaml.cs b/Source/NETworkManager/Views/NeighborTableAddEntryChildWindow.xaml.cs similarity index 75% rename from Source/NETworkManager/Views/ARPTableAddEntryChildWindow.xaml.cs rename to Source/NETworkManager/Views/NeighborTableAddEntryChildWindow.xaml.cs index 1589e99192..2e7d9815ae 100644 --- a/Source/NETworkManager/Views/ARPTableAddEntryChildWindow.xaml.cs +++ b/Source/NETworkManager/Views/NeighborTableAddEntryChildWindow.xaml.cs @@ -1,12 +1,12 @@ -using System; +using System; using System.Windows; using System.Windows.Threading; namespace NETworkManager.Views; -public partial class ARPTableAddEntryChildWindow +public partial class NeighborTableAddEntryChildWindow { - public ARPTableAddEntryChildWindow() + public NeighborTableAddEntryChildWindow() { InitializeComponent(); } @@ -18,4 +18,4 @@ private void ChildWindow_OnLoaded(object sender, RoutedEventArgs e) TextBoxIPAddress.Focus(); })); } -} \ No newline at end of file +} diff --git a/Source/NETworkManager/Views/ARPTableView.xaml b/Source/NETworkManager/Views/NeighborTableView.xaml similarity index 89% rename from Source/NETworkManager/Views/ARPTableView.xaml rename to Source/NETworkManager/Views/NeighborTableView.xaml index 7e692604d8..0497fa3e5a 100644 --- a/Source/NETworkManager/Views/ARPTableView.xaml +++ b/Source/NETworkManager/Views/NeighborTableView.xaml @@ -1,4 +1,4 @@ - @@ -22,12 +22,13 @@ + - + @@ -46,7 +47,7 @@ - + + + + @@ -213,7 +230,7 @@ public class NeighborTable { @@ -75,11 +75,7 @@ private struct MIB_IPNET_ROW2 [DllImport("Iphlpapi.dll")] private static extern void FreeMibTable(IntPtr memory); - /// Looks up a single neighbor cache entry by address. Returns 0 on success. - [DllImport("Iphlpapi.dll")] - private static extern uint GetIpNetEntry2(ref MIB_IPNET_ROW2 row); - - /// +/// /// Ensures that only one PowerShell pipeline runs on at a time. /// private static readonly SemaphoreSlim Lock = new(1, 1); @@ -97,26 +93,26 @@ private struct MIB_IPNET_ROW2 private static readonly TimeSpan InterfaceAliasCacheDuration = TimeSpan.FromMinutes(5); /// - /// Shared PowerShell runspace, initialized once in the static constructor with - /// Set-ExecutionPolicy Bypass and Import-Module NetTCPIP so that - /// subsequent operations do not need to repeat the module import. + /// Lazily initialized PowerShell runspace. Created and configured on first access so that + /// read-only paths (e.g. MAC address lookup in IP Scanner) do not start a PowerShell + /// process unless a modifying operation is actually performed. /// - private static readonly Runspace SharedRunspace; - - /// - /// Opens and runs the one-time initialization script. - /// - static NeighborTable() + private static readonly Lazy _sharedRunspace = new(() => { - SharedRunspace = RunspaceFactory.CreateRunspace(); - SharedRunspace.Open(); + var runspace = RunspaceFactory.CreateRunspace(); + runspace.Open(); using var ps = SMA.PowerShell.Create(); - ps.Runspace = SharedRunspace; + ps.Runspace = runspace; ps.AddScript(@" Set-ExecutionPolicy -ExecutionPolicy Bypass -Scope Process Import-Module NetTCPIP -ErrorAction Stop").Invoke(); - } + + return runspace; + }); + + /// Returns the shared runspace, initializing it on first access. + private static Runspace SharedRunspace => _sharedRunspace.Value; #endregion @@ -226,7 +222,9 @@ private static List GetTable() list.Add(new NeighborInfo( ipAddress, macAddress, - ipAddress.IsIPv6Multicast || IPv4Address.IsMulticast(ipAddress), + addressFamily == AddressFamily.InterNetworkV6 + ? ipAddress.IsIPv6Multicast + : IPv4Address.IsMulticast(ipAddress), (int)row.InterfaceIndex, alias ?? string.Empty, (NeighborState)row.State, @@ -289,47 +287,13 @@ private static Dictionary BuildInterfaceAliasMap() } /// - /// Returns the MAC address for via GetIpNetEntry2, - /// or when no cache entry exists. Supports both IPv4 and IPv6. + /// Returns the MAC address for by scanning the neighbor + /// cache, or when no entry exists. Supports both IPv4 and IPv6. /// public static string GetMACAddress(IPAddress ipAddress) { - var addressFamily = ipAddress.AddressFamily; - - if (addressFamily != AddressFamily.InterNetwork && addressFamily != AddressFamily.InterNetworkV6) - return null; - - var row = new MIB_IPNET_ROW2 - { - Address = new byte[SOCKADDR_INET_SIZE], - PhysicalAddress = new byte[IF_MAX_PHYS_ADDRESS_LENGTH] - }; - - if (addressFamily == AddressFamily.InterNetwork) - { - // SOCKADDR_IN: family(2) at offset 0, addr(4) at offset 4 - BitConverter.GetBytes(AF_INET).CopyTo(row.Address, 0); - ipAddress.GetAddressBytes().CopyTo(row.Address, 4); - } - else - { - // SOCKADDR_IN6: family(2) at offset 0, addr(16) at offset 8, scope_id(4) at offset 24 - BitConverter.GetBytes(AF_INET6).CopyTo(row.Address, 0); - ipAddress.GetAddressBytes().CopyTo(row.Address, 8); - BitConverter.GetBytes((uint)ipAddress.ScopeId).CopyTo(row.Address, 24); - } - - if (GetIpNetEntry2(ref row) != 0) - return null; - - var macLen = (int)row.PhysicalAddressLength; - if (macLen is <= 0 or > IF_MAX_PHYS_ADDRESS_LENGTH) - return null; - - var macBytes = new byte[macLen]; - Buffer.BlockCopy(row.PhysicalAddress, 0, macBytes, 0, macLen); - - return new PhysicalAddress(macBytes).ToString(); + var entry = GetTable().FirstOrDefault(x => x.IPAddress.Equals(ipAddress)); + return entry?.MACAddress.ToString(); } /// diff --git a/Source/NETworkManager/ViewModels/NeighborTableViewModel.cs b/Source/NETworkManager/ViewModels/NeighborTableViewModel.cs index d2065430db..48bdbe7371 100644 --- a/Source/NETworkManager/ViewModels/NeighborTableViewModel.cs +++ b/Source/NETworkManager/ViewModels/NeighborTableViewModel.cs @@ -489,7 +489,10 @@ private async void AutoRefreshTimer_Tick(object sender, EventArgs e) { _autoRefreshTimer.Stop(); - await Refresh(); + // Skip refresh while a modify operation (add/delete) is in progress to avoid + // clearing the table while the user is interacting with it. + if (!IsModifying) + await Refresh(); _autoRefreshTimer.Start(); } diff --git a/Website/docs/application/neighbor-table.md b/Website/docs/application/neighbor-table.md index e748f053b9..72bed9eadf 100644 --- a/Website/docs/application/neighbor-table.md +++ b/Website/docs/application/neighbor-table.md @@ -18,7 +18,7 @@ Both protocols are susceptible to spoofing/poisoning attacks that can manipulate ::: -::::::note +:::note Adding and deleting neighbor entries requires administrator privileges. If the application is not running as administrator, the view is in read-only mode. Use the **Restart as administrator** button to relaunch the application with elevated rights. diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index e66e13b2e1..5c527e9422 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -27,7 +27,7 @@ Release date: **xx.xx.2025** ## Breaking Changes -- **ARP Table** has been renamed to **[Neighbor Table](../application/neighbor-table.md)**. Existing settings are automatically migrated on first launch after the update. [#3403](https://github.com/BornToBeRoot/NETworkManager/pull/3403) +- **ARP Table** has been renamed to **[Neighbor Table](../application/neighbor-table.md)**. The application list entry is automatically migrated on first launch. Other view settings (auto-refresh interval, export file type/path) reset to their defaults. [#3403](https://github.com/BornToBeRoot/NETworkManager/pull/3403) - **IP Scanner** export: The `ARPMACAddress` and `ARPVendor` columns have been removed from CSV, XML and JSON exports. Use `MACAddress` and `Vendor` instead, which contain the same value (ARP/NDP preferred, NetBIOS as fallback). [#3403](https://github.com/BornToBeRoot/NETworkManager/pull/3403) ## What's new? From 54fc1d275fdddb73ba1fbffa459986d5af0cf397 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Sun, 3 May 2026 22:40:24 +0200 Subject: [PATCH 8/8] Fix: Based on Copilot feedback --- .../NETworkManager.Models/Firewall/Firewall.cs | 18 +++++++++--------- .../Network/NeighborTable.cs | 16 ++++++++-------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/Source/NETworkManager.Models/Firewall/Firewall.cs b/Source/NETworkManager.Models/Firewall/Firewall.cs index fe70212c4c..915692d24c 100644 --- a/Source/NETworkManager.Models/Firewall/Firewall.cs +++ b/Source/NETworkManager.Models/Firewall/Firewall.cs @@ -38,7 +38,7 @@ public class Firewall /// /// Ensures that only one PowerShell pipeline runs on at a time. /// - private static readonly SemaphoreSlim Lock = new(1, 1); + private static readonly SemaphoreSlim RunspaceLock = new(1, 1); /// /// Lazily initialized PowerShell runspace. Created and configured on first access so that @@ -77,7 +77,7 @@ public class Firewall /// public static async Task> GetRulesAsync() { - await Lock.WaitAsync(); + await RunspaceLock.WaitAsync(); try { return await Task.Run(() => @@ -163,7 +163,7 @@ public static async Task> GetRulesAsync() } finally { - Lock.Release(); + RunspaceLock.Release(); } } @@ -183,7 +183,7 @@ public static async Task> GetRulesAsync() /// public static async Task SetRuleEnabledAsync(FirewallRule rule, bool enabled) { - await Lock.WaitAsync(); + await RunspaceLock.WaitAsync(); try { await Task.Run(() => @@ -200,7 +200,7 @@ await Task.Run(() => } finally { - Lock.Release(); + RunspaceLock.Release(); } } @@ -216,7 +216,7 @@ await Task.Run(() => /// public static async Task DeleteRuleAsync(FirewallRule rule) { - await Lock.WaitAsync(); + await RunspaceLock.WaitAsync(); try { await Task.Run(() => @@ -233,7 +233,7 @@ await Task.Run(() => } finally { - Lock.Release(); + RunspaceLock.Release(); } } @@ -250,7 +250,7 @@ await Task.Run(() => /// public static async Task AddRuleAsync(FirewallRule rule) { - await Lock.WaitAsync(); + await RunspaceLock.WaitAsync(); try { await Task.Run(() => @@ -267,7 +267,7 @@ await Task.Run(() => } finally { - Lock.Release(); + RunspaceLock.Release(); } } diff --git a/Source/NETworkManager.Models/Network/NeighborTable.cs b/Source/NETworkManager.Models/Network/NeighborTable.cs index 7bd6d4cd37..7b0c13bcad 100644 --- a/Source/NETworkManager.Models/Network/NeighborTable.cs +++ b/Source/NETworkManager.Models/Network/NeighborTable.cs @@ -75,10 +75,10 @@ private struct MIB_IPNET_ROW2 [DllImport("Iphlpapi.dll")] private static extern void FreeMibTable(IntPtr memory); -/// + /// /// Ensures that only one PowerShell pipeline runs on at a time. /// - private static readonly SemaphoreSlim Lock = new(1, 1); + private static readonly SemaphoreSlim RunspaceLock = new(1, 1); /// Protects reads and writes to and . private static readonly Lock InterfaceAliasCacheLock = new(); @@ -309,7 +309,7 @@ public static string GetMACAddress(IPAddress ipAddress) /// public static async Task AddEntryAsync(string ipAddress, string macAddress, int interfaceIndex) { - await Lock.WaitAsync(); + await RunspaceLock.WaitAsync(); try { await Task.Run(() => @@ -325,7 +325,7 @@ await Task.Run(() => } finally { - Lock.Release(); + RunspaceLock.Release(); } } @@ -341,7 +341,7 @@ await Task.Run(() => /// public static async Task DeleteEntryAsync(string ipAddress, int interfaceIndex) { - await Lock.WaitAsync(); + await RunspaceLock.WaitAsync(); try { await Task.Run(() => @@ -357,7 +357,7 @@ await Task.Run(() => } finally { - Lock.Release(); + RunspaceLock.Release(); } } @@ -371,7 +371,7 @@ await Task.Run(() => /// public static async Task DeleteTableAsync() { - await Lock.WaitAsync(); + await RunspaceLock.WaitAsync(); try { await Task.Run(() => @@ -387,7 +387,7 @@ await Task.Run(() => } finally { - Lock.Release(); + RunspaceLock.Release(); } }