From ac3b4c5290affe6b239c83ff2e96ae7480f279da Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:30:49 +0100 Subject: [PATCH 001/131] Feature: IP Scanner: Show which probe found a host (A1) The probes a host answered (Ping, TCP, NetBIOS) are tracked as IPScannerHostInfo.DetectedBy (updated when a later probe answers) and shown in the tooltip of the status icon and as "Detected by" in the row details. Exported as DetectedBy (last column / element). Co-Authored-By: Claude Opus 5.5 --- ...ScannerDetectionMethodToStringConverter.cs | 59 +++++++++++++++++++ .../Resources/Strings.Designer.cs | 27 +++++++++ .../Resources/Strings.resx | 9 +++ .../Export/ExportManager.IPScannerHostInfo.cs | 22 +++++-- .../Network/IPScanner.cs | 33 +++++++++-- .../Network/IPScannerDetectionMethod.cs | 30 ++++++++++ .../Network/IPScannerHostInfo.cs | 19 ++++++ .../NETworkManager/Views/IPScannerView.xaml | 33 +++++++++-- Website/docs/application/ip-scanner.md | 2 + Website/docs/changelog/next-release.md | 1 + 10 files changed, 221 insertions(+), 14 deletions(-) create mode 100644 Source/NETworkManager.Converters/IPScannerDetectionMethodToStringConverter.cs create mode 100644 Source/NETworkManager.Models/Network/IPScannerDetectionMethod.cs diff --git a/Source/NETworkManager.Converters/IPScannerDetectionMethodToStringConverter.cs b/Source/NETworkManager.Converters/IPScannerDetectionMethodToStringConverter.cs new file mode 100644 index 0000000000..ab7ebb6c4c --- /dev/null +++ b/Source/NETworkManager.Converters/IPScannerDetectionMethodToStringConverter.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Windows.Data; +using NETworkManager.Localization.Resources; +using NETworkManager.Models.Network; + +namespace NETworkManager.Converters; + +/// +/// Convert the of a host to a translated +/// (e.g. "Ping, TCP"). +/// +public sealed class IPScannerDetectionMethodToStringConverter : IValueConverter +{ + /// + /// Convert the of a host to a translated . + /// + /// Probes the host answered as . + /// + /// "Status" to get the status of the host ("Up (detected by Ping, TCP)" / "Down"). + /// + /// Translated, comma-separated probes, "-/-" if the host didn't answer any probe. + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + var isStatus = parameter as string == "Status"; + + if (value is not IPScannerDetectionMethod detectedBy || detectedBy == IPScannerDetectionMethod.None) + return isStatus ? Strings.Down : "-/-"; + + var names = new List(3); + + if (detectedBy.HasFlag(IPScannerDetectionMethod.ICMP)) + names.Add(Strings.Ping); + + if (detectedBy.HasFlag(IPScannerDetectionMethod.TCP)) + names.Add(Strings.TCP); + + if (detectedBy.HasFlag(IPScannerDetectionMethod.NetBIOS)) + names.Add(Strings.NetBIOS); + + var text = string.Join(", ", names); + + return isStatus ? string.Format(Strings.UpDetectedByX, text) : text; + } + + /// + /// !!! Method not implemented !!! + /// + /// + /// + /// + /// + /// + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} diff --git a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs index f4e3005577..a2e9230597 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs +++ b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs @@ -3397,6 +3397,15 @@ public static string Details { } } + /// + /// Looks up a localized string similar to Detected by. + /// + public static string DetectedBy { + get { + return ResourceManager.GetString("DetectedBy", resourceCulture); + } + } + /// /// Looks up a localized string similar to Detecting network.... /// @@ -13510,6 +13519,15 @@ public static string Taupe { } } + /// + /// Looks up a localized string similar to TCP. + /// + public static string TCP { + get { + return ResourceManager.GetString("TCP", resourceCulture); + } + } + /// /// Looks up a localized string similar to TCP/IP stack is available. "{0}" is reachable via ICMP!. /// @@ -14176,6 +14194,15 @@ public static string UpdateNow { } } + /// + /// Looks up a localized string similar to Up (detected by {0}). + /// + public static string UpDetectedByX { + get { + return ResourceManager.GetString("UpDetectedByX", resourceCulture); + } + } + /// /// Looks up a localized string similar to Upgraded to {0}. /// diff --git a/Source/NETworkManager.Localization/Resources/Strings.resx b/Source/NETworkManager.Localization/Resources/Strings.resx index 4bee6b1c4b..4ad8d280a0 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.resx +++ b/Source/NETworkManager.Localization/Resources/Strings.resx @@ -5152,4 +5152,13 @@ Do you want to start the scan? PTR records can only be looked up for IP addresses. + + Detected by + + + Up (detected by {0}) + + + TCP + \ No newline at end of file diff --git a/Source/NETworkManager.Models/Export/ExportManager.IPScannerHostInfo.cs b/Source/NETworkManager.Models/Export/ExportManager.IPScannerHostInfo.cs index 509d6fdc08..44d9b64d1f 100644 --- a/Source/NETworkManager.Models/Export/ExportManager.IPScannerHostInfo.cs +++ b/Source/NETworkManager.Models/Export/ExportManager.IPScannerHostInfo.cs @@ -68,7 +68,8 @@ private static void CreateCsv(IEnumerable collection, string $"{nameof(IPScannerHostInfo.MACAddress)}," + $"{nameof(IPScannerHostInfo.Vendor)}," + // Added later - last column, so the position of the other columns doesn't change - $"{nameof(IPScannerHostInfo.DNSHostname)}" + $"{nameof(IPScannerHostInfo.DNSHostname)}," + + $"{nameof(IPScannerHostInfo.DetectedBy)}" ); foreach (var info in collection) @@ -111,7 +112,8 @@ private static void CreateCsv(IEnumerable collection, string $"{CsvHelper.QuoteString(netBIOSInfo?.Vendor)}," + $"{info.MACAddress}," + $"{CsvHelper.QuoteString(info.Vendor)}," + - $"{CsvHelper.QuoteString(info.DNSHostname)}" + $"{CsvHelper.QuoteString(info.DNSHostname)}," + + $"{CsvHelper.QuoteString(GetDetectedBy(info))}" ); } @@ -165,7 +167,8 @@ from info in collection new XElement(nameof(IPScannerHostInfo.MACAddress), info.MACAddress), new XElement(nameof(IPScannerHostInfo.Vendor), info.Vendor), // Added later - last element, so the position of the other elements doesn't change - new XElement(nameof(IPScannerHostInfo.DNSHostname), info.DNSHostname) + new XElement(nameof(IPScannerHostInfo.DNSHostname), info.DNSHostname), + new XElement(nameof(IPScannerHostInfo.DetectedBy), GetDetectedBy(info)) ) ) ) @@ -233,13 +236,24 @@ private static void CreateJson(IReadOnlyList collection, stri NetBIOSMACAddress = netBIOSInfo?.MACAddress, NetBIOSVendor = netBIOSInfo?.Vendor, info.MACAddress, - info.Vendor + info.Vendor, + DetectedBy = GetDetectedBy(info) }; } File.WriteAllText(filePath, JsonConvert.SerializeObject(jsonData, Formatting.Indented)); } + /// + /// Gets the probes a host answered (e.g. "ICMP, TCP"), null if it is unreachable. + /// + /// Host to get the probes for. + /// Probes or null. + private static string GetDetectedBy(IPScannerHostInfo info) + { + return info.DetectedBy == IPScannerDetectionMethod.None ? null : info.DetectedBy.ToString(); + } + /// /// Gets the port status of a host: open if any port is open, otherwise closed - null while the port scan is /// still running (no result yet). diff --git a/Source/NETworkManager.Models/Network/IPScanner.cs b/Source/NETworkManager.Models/Network/IPScanner.cs index 88053c2601..d400474074 100644 --- a/Source/NETworkManager.Models/Network/IPScanner.cs +++ b/Source/NETworkManager.Models/Network/IPScanner.cs @@ -232,6 +232,9 @@ private async Task ScanHostAsync(IPAddress ipAddress, ParallelOptions portScanPa List ports = null; NetBIOSInfo netBIOSInfo = null; + // Probes that answered so far + var detectedBy = IPScannerDetectionMethod.None; + IPScannerHostInfo hostInfo = null; try @@ -249,6 +252,7 @@ private async Task ScanHostAsync(IPAddress ipAddress, ParallelOptions portScanPa cancellationToken.ThrowIfCancellationRequested(); bool isAnswer; + IPScannerDetectionMethod method; Action update = null; if (completed == pingTask) @@ -256,6 +260,7 @@ private async Task ScanHostAsync(IPAddress ipAddress, ParallelOptions portScanPa var value = pingInfo = pingTask.Result; isAnswer = value.Status == IPStatus.Success; // ICMP response + method = IPScannerDetectionMethod.ICMP; update = x => { x.PingInfo = value; @@ -271,6 +276,7 @@ private async Task ScanHostAsync(IPAddress ipAddress, ParallelOptions portScanPa // Any port is open or the connection was refused (TCP reset) isAnswer = ports.Any(x => x.State is PortState.Open or PortState.Closed); + method = IPScannerDetectionMethod.TCP; var shownPorts = GetShownPorts(ports); var isAnyPortOpen = ports.Any(x => x.State == PortState.Open); @@ -287,6 +293,7 @@ private async Task ScanHostAsync(IPAddress ipAddress, ParallelOptions portScanPa var value = netBIOSInfo = netBIOSTask.Result; isAnswer = value.IsReachable; // NetBIOS response + method = IPScannerDetectionMethod.NetBIOS; update = x => { x.NetBIOSInfo = value; @@ -297,10 +304,22 @@ private async Task ScanHostAsync(IPAddress ipAddress, ParallelOptions portScanPa { // A port answered - its result follows with the port scan isAnswer = true; + method = IPScannerDetectionMethod.TCP; } + if (isAnswer) + detectedBy |= method; + if (hostInfo != null) { + // Another probe answered after the host was found + if (isAnswer) + { + var detectedByValue = detectedBy; + + update += x => x.DetectedBy = detectedByValue; + } + if (update != null) OnHostUpdated(new IPScannerHostUpdatedArgs(hostInfo, update)); @@ -312,7 +331,7 @@ private async Task ScanHostAsync(IPAddress ipAddress, ParallelOptions portScanPa // First answer: the host is up. Before it could answer, the local system had to resolve its MAC // address, so it is in the neighbor table (ARP/NDP) from now on. - hostInfo = CreateHostInfo(ipAddress, true, pingInfo, ports, netBIOSInfo); + hostInfo = CreateHostInfo(ipAddress, detectedBy, pingInfo, ports, netBIOSInfo); _hostsUp.Increment(); @@ -330,7 +349,7 @@ private async Task ScanHostAsync(IPAddress ipAddress, ParallelOptions portScanPa if (options.ShowAllResults || options.ReportAllHosts) { - hostInfo = CreateHostInfo(ipAddress, false, pingInfo, ports, netBIOSInfo); + hostInfo = CreateHostInfo(ipAddress, IPScannerDetectionMethod.None, pingInfo, ports, netBIOSInfo); OnHostFound(new IPScannerHostFoundArgs(hostInfo)); @@ -384,22 +403,24 @@ private List GetShownPorts(List ports) /// (marked as pending). /// /// IP address of the host. - /// Indicates whether the host answered a probe. + /// Probes the host answered so far, if it is + /// unreachable. /// Result of the ping, null if it is still running. /// All probed ports, null if the port scan is still running. /// Result of the NetBIOS request, null if it is still running. /// Result of the host. - private IPScannerHostInfo CreateHostInfo(IPAddress ipAddress, bool isReachable, PingInfo pingInfo, - List ports, NetBIOSInfo netBIOSInfo) + private IPScannerHostInfo CreateHostInfo(IPAddress ipAddress, IPScannerDetectionMethod detectedBy, + PingInfo pingInfo, List ports, NetBIOSInfo netBIOSInfo) { return new IPScannerHostInfo( ipAddress, - isReachable, + detectedBy != IPScannerDetectionMethod.None, pingInfo ?? new PingInfo(DateTime.Now, ipAddress, IPStatus.Unknown), ports?.Any(x => x.State == PortState.Open) ?? false, ports != null ? GetShownPorts(ports) : [], netBIOSInfo ?? new NetBIOSInfo(ipAddress)) { + DetectedBy = detectedBy, IsPingPending = pingInfo == null, IsPortScanPending = ports == null, IsNetBIOSPending = netBIOSInfo == null diff --git a/Source/NETworkManager.Models/Network/IPScannerDetectionMethod.cs b/Source/NETworkManager.Models/Network/IPScannerDetectionMethod.cs new file mode 100644 index 0000000000..426af9087f --- /dev/null +++ b/Source/NETworkManager.Models/Network/IPScannerDetectionMethod.cs @@ -0,0 +1,30 @@ +using System; + +namespace NETworkManager.Models.Network; + +/// +/// Probes of the a host answered, i.e. how it was detected as reachable. +/// +[Flags] +public enum IPScannerDetectionMethod +{ + /// + /// The host didn't answer any probe. + /// + None = 0, + + /// + /// The host answered the ping (ICMP echo reply). + /// + ICMP = 1, + + /// + /// A TCP port of the host answered (open, or the connection was refused). + /// + TCP = 2, + + /// + /// The host answered the NetBIOS request. + /// + NetBIOS = 4 +} diff --git a/Source/NETworkManager.Models/Network/IPScannerHostInfo.cs b/Source/NETworkManager.Models/Network/IPScannerHostInfo.cs index a2b929ee5e..6ef514cb37 100644 --- a/Source/NETworkManager.Models/Network/IPScannerHostInfo.cs +++ b/Source/NETworkManager.Models/Network/IPScannerHostInfo.cs @@ -55,6 +55,23 @@ internal set } } + /// + /// Probes the host answered so far, i.e. how it was detected as reachable. + /// if it is unreachable. + /// + public IPScannerDetectionMethod DetectedBy + { + get; + internal set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } + /// /// Information about the ping to the host. /// @@ -253,6 +270,7 @@ internal set public void CopyFrom(IPScannerHostInfo source) { IsReachable = source.IsReachable; + DetectedBy = source.DetectedBy; PingInfo = source.PingInfo; IsAnyPortOpen = source.IsAnyPortOpen; Ports = source.Ports; @@ -274,6 +292,7 @@ public IPScannerHostInfo Clone() { return new IPScannerHostInfo(IPAddress, IsReachable, PingInfo, IsAnyPortOpen, Ports, NetBIOSInfo) { + DetectedBy = DetectedBy, IsPingPending = IsPingPending, IsPortScanPending = IsPortScanPending, IsNetBIOSPending = IsNetBIOSPending, diff --git a/Source/NETworkManager/Views/IPScannerView.xaml b/Source/NETworkManager/Views/IPScannerView.xaml index 7173d5b4d5..0544d99706 100644 --- a/Source/NETworkManager/Views/IPScannerView.xaml +++ b/Source/NETworkManager/Views/IPScannerView.xaml @@ -24,6 +24,7 @@ + @@ -678,7 +679,8 @@ + Style="{Binding IsReachable, Converter={StaticResource HostUpStyleConverter}}" + ToolTip="{Binding DetectedBy, Converter={StaticResource IPScannerDetectionMethodToStringConverter}, ConverterParameter=Status}" /> @@ -785,6 +787,11 @@ + + + + + @@ -800,8 +807,26 @@ + + + + + + + + + + + + - + @@ -891,7 +916,7 @@ - + @@ -975,7 +1000,7 @@ - + diff --git a/Website/docs/application/ip-scanner.md b/Website/docs/application/ip-scanner.md index edc565a2c1..15a74cc6e4 100644 --- a/Website/docs/application/ip-scanner.md +++ b/Website/docs/application/ip-scanner.md @@ -87,6 +87,8 @@ The number of reachable (up) and unreachable (down) IP addresses is shown next t A host is shown (and counted as up) as soon as it answers the first probe: ping reply, open or refused port or NetBIOS answer. Values of probes that are still running are shown as `...`; the results of the other probes, the hostname (DNS) and the MAC address / vendor are filled in when they arrive. Unreachable hosts are added when all probes are finished. A column you sorted by is not sorted again for values that arrive later, click the header again. The scan is finished when all hosts and all lookups are finished. +The probes a host answered are shown in the tooltip of its status icon (e.g. `Up (detected by Ping, TCP)`) and as **Detected by** in the row details: **Ping** (ICMP echo reply), **TCP** (a [port](#ports) is open or refused the connection) and **NetBIOS** (answer to the [NetBIOS](#scan-for-netbios) request). Probes that answer after the host is shown are added. The export contains them as `DetectedBy` (e.g. `ICMP, TCP`, empty for unreachable hosts). + Only reachable hosts are shown, unless [Show unreachable IP addresses and ports](#show-unreachable-ip-addresses-and-ports) is enabled. A message below the input is shown if no reachable host was found, the scan was canceled, a hostname could not be resolved or an error occurred. The search box right of the **Result** header filters the results by IP address, hostname, MAC address, vendor and open ports (number or service), also while the scan is running (hosts whose hostname or MAC address arrives later are filtered again). **Export...** > all exports the filtered results; the up / down counters always count all hosts. Results can also be exported while the scan is running; values of probes that are still running (shown as `...`) are exported as empty values. diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index 82a2f50fff..8282b052fd 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -87,6 +87,7 @@ Release date: **xx.xx.2026** - Closed ports are reported immediately. Windows repeated the connection request after the host refused it and reported a closed port only after about 2 seconds. The connection request is sent once. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) - New **Attempts** setting for the port scan (default `1`): another connection request is sent if there was no answer, e.g. on an unreliable network (Wi-Fi, VPN) or for hosts on the internet. Open and closed ports are not repeated. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) - Added **RDAP...** to the context menu to query the registration data of the IP address or hostname of a host in the [**RDAP**](../application/rdap.md) tool. [#3642](https://github.com/BornToBeRoot/NETworkManager/pull/3642) +- The probes a host answered (**Ping**, **TCP**, **NetBIOS**) are shown in the tooltip of the status icon and as **Detected by** in the row details, e.g. to see why a host is up. The export contains them as `DetectedBy` (last column / element). [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) **Port Scanner** From a4250a363aff04fb5a88fe40004648b384f6b5de Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:32:33 +0100 Subject: [PATCH 002/131] Fix: IP Scanner: Export empty values for disabled probes (A2a) The export gets the options of the shown scan: the port status / ports and the NetBIOS values are exported as empty values if the port scan / NetBIOS was disabled, instead of their placeholders (e.g. PortStatus "Closed"). Preparation for A2 (ping can be disabled). Co-Authored-By: Claude Opus 5.5 --- .../Export/ExportManager.IPScannerHostInfo.cs | 67 +++++++++++++------ .../ViewModels/IPScannerViewModel.cs | 5 +- Website/docs/changelog/next-release.md | 1 + 3 files changed, 50 insertions(+), 23 deletions(-) diff --git a/Source/NETworkManager.Models/Export/ExportManager.IPScannerHostInfo.cs b/Source/NETworkManager.Models/Export/ExportManager.IPScannerHostInfo.cs index 44d9b64d1f..6b18a28491 100644 --- a/Source/NETworkManager.Models/Export/ExportManager.IPScannerHostInfo.cs +++ b/Source/NETworkManager.Models/Export/ExportManager.IPScannerHostInfo.cs @@ -18,19 +18,23 @@ public static partial class ExportManager /// Path to the export file. /// Allowed are CSV, XML or JSON. /// Objects as to export. + /// + /// Options of the scan the hosts are from. Values of disabled probes are exported as empty values instead of + /// their placeholders (e.g. port status "Closed" if the port scan was disabled). + /// public static void Export(string filePath, ExportFileType fileType, - IReadOnlyList collection) + IReadOnlyList collection, IPScannerResultOptions options) { switch (fileType) { case ExportFileType.Csv: - CreateCsv(collection, filePath); + CreateCsv(collection, options, filePath); break; case ExportFileType.Xml: - CreateXml(collection, filePath); + CreateXml(collection, options, filePath); break; case ExportFileType.Json: - CreateJson(collection, filePath); + CreateJson(collection, options, filePath); break; case ExportFileType.Txt: default: @@ -42,8 +46,10 @@ public static void Export(string filePath, ExportFileType fileType, /// Creates a CSV file from the given collection. /// /// Objects as to export. + /// Options of the scan the hosts are from. /// Path to the export file. - private static void CreateCsv(IEnumerable collection, string filePath) + private static void CreateCsv(IEnumerable collection, IPScannerResultOptions options, + string filePath) { var stringBuilder = new StringBuilder(); @@ -79,13 +85,13 @@ private static void CreateCsv(IEnumerable collection, string continue; // Values of a probe that is still running (export during a scan, or a scan canceled before the probe - // finished) are placeholders - exported as empty values instead of a result + // finished) or disabled are placeholders - exported as empty values instead of a result var ping = info.IsPingPending ? null : info.PingInfo; - var netBIOSInfo = info.IsNetBIOSPending ? null : info.NetBIOSInfo; + var netBIOSInfo = GetNetBIOSInfo(info, options); var stringBuilderPorts = new StringBuilder(); - if (info.Ports != null) + if (info.Ports != null && options.PortScanEnabled) { foreach (var port in info.Ports) stringBuilderPorts.Append( @@ -101,7 +107,7 @@ private static void CreateCsv(IEnumerable collection, string $"{(ping == null ? null : Ping.TimeToString(ping.Status, ping.Time, true))}," + $"{ping?.TTL}," + $"{ping?.Bytes}," + - $"{GetPortStatus(info)}," + + $"{GetPortStatus(info, options)}," + $"{CsvHelper.QuoteString(stringBuilderPorts.ToString().TrimEnd(';'))}," + $"{netBIOSInfo?.IsReachable}," + $"{netBIOSInfo?.IPAddress}," + @@ -124,17 +130,19 @@ private static void CreateCsv(IEnumerable collection, string /// Creates a XML file from the given collection. /// /// Objects as to export. + /// Options of the scan the hosts are from. /// Path to the export file. - private static void CreateXml(IEnumerable collection, string filePath) + private static void CreateXml(IEnumerable collection, IPScannerResultOptions options, + string filePath) { var document = new XDocument(DefaultXDeclaration, new XElement(ApplicationName.IPScanner.ToString(), new XElement(nameof(IPScannerHostInfo) + "s", from info in collection where info?.PingInfo != null - // Placeholders of running probes are exported as empty values (see CreateCsv) + // Placeholders of running / disabled probes are exported as empty values (see CreateCsv) let ping = info.IsPingPending ? null : info.PingInfo - let netBIOSInfo = info.IsNetBIOSPending ? null : info.NetBIOSInfo + let netBIOSInfo = GetNetBIOSInfo(info, options) select new XElement(nameof(IPScannerHostInfo), new XElement(nameof(IPScannerHostInfo.IsReachable), info.IsReachable), @@ -147,8 +155,8 @@ from info in collection ping == null ? null : Ping.TimeToString(ping.Status, ping.Time, true)), new XElement(nameof(PingInfo.TTL), ping?.TTL), new XElement(nameof(PingInfo.Bytes), ping?.Bytes), - new XElement("PortStatus", GetPortStatus(info)), - info.Ports != null + new XElement("PortStatus", GetPortStatus(info, options)), + info.Ports != null && options.PortScanEnabled ? from port in info.Ports select new XElement(nameof(PortInfo), new XElement(nameof(PortInfo.Port), port.Port), @@ -181,8 +189,10 @@ from info in collection /// Creates a JSON file from the given collection. /// /// Objects as to export. + /// Options of the scan the hosts are from. /// Path to the export file. - private static void CreateJson(IReadOnlyList collection, string filePath) + private static void CreateJson(IReadOnlyList collection, IPScannerResultOptions options, + string filePath) { var validCollection = collection.Where(info => info?.PingInfo != null).ToList(); var jsonData = new object[validCollection.Count]; @@ -191,13 +201,13 @@ private static void CreateJson(IReadOnlyList collection, stri { var info = validCollection[i]; - // Placeholders of running probes are exported as empty values (see CreateCsv) + // Placeholders of running / disabled probes are exported as empty values (see CreateCsv) var ping = info.IsPingPending ? null : info.PingInfo; - var netBIOSInfo = info.IsNetBIOSPending ? null : info.NetBIOSInfo; + var netBIOSInfo = GetNetBIOSInfo(info, options); object[] jsonDataPorts; - if (info.Ports != null && info.Ports.Count > 0) + if (info.Ports != null && info.Ports.Count > 0 && options.PortScanEnabled) { jsonDataPorts = new object[info.Ports.Count]; for (var j = 0; j < info.Ports.Count; j++) @@ -226,7 +236,7 @@ private static void CreateJson(IReadOnlyList collection, stri TTL = ping?.TTL, Bytes = ping?.Bytes, info.DNSHostname, - PortStatus = GetPortStatus(info)?.ToString(), + PortStatus = GetPortStatus(info, options)?.ToString(), Ports = jsonDataPorts, NetBIOSIsReachable = netBIOSInfo?.IsReachable, NetBIOSIPAddress = netBIOSInfo?.IPAddress?.ToString(), @@ -254,15 +264,28 @@ private static string GetDetectedBy(IPScannerHostInfo info) return info.DetectedBy == IPScannerDetectionMethod.None ? null : info.DetectedBy.ToString(); } + /// + /// Gets the NetBIOS information of a host - null while the NetBIOS request is still running or if it was + /// disabled (placeholder). + /// + /// Host to get the NetBIOS information for. + /// Options of the scan the host is from. + /// NetBIOS information or null. + private static NetBIOSInfo GetNetBIOSInfo(IPScannerHostInfo info, IPScannerResultOptions options) + { + return info.IsNetBIOSPending || !options.NetBIOSEnabled ? null : info.NetBIOSInfo; + } + /// /// Gets the port status of a host: open if any port is open, otherwise closed - null while the port scan is - /// still running (no result yet). + /// still running (no result yet) or if it was disabled. /// /// Host to get the port status for. + /// Options of the scan the host is from. /// Port status or null. - private static PortState? GetPortStatus(IPScannerHostInfo info) + private static PortState? GetPortStatus(IPScannerHostInfo info, IPScannerResultOptions options) { - if (info.IsPortScanPending) + if (info.IsPortScanPending || !options.PortScanEnabled) return null; return info.IsAnyPortOpen ? PortState.Open : PortState.Closed; diff --git a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs index 4b92b976ab..8d280c2a71 100644 --- a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs @@ -1110,7 +1110,10 @@ private Task Export() : GetSelectedResultsInGridOrder()) .Select(x => x.Clone()).ToList(); - await Task.Run(() => ExportManager.Export(instance.FilePath, instance.FileType, results)); + // Options of the shown scan (values of disabled probes are exported as empty values) + var options = ScanOptions; + + await Task.Run(() => ExportManager.Export(instance.FilePath, instance.FileType, results, options)); } catch (Exception ex) { diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index 8282b052fd..51faad49fc 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -293,6 +293,7 @@ Release date: **xx.xx.2026** - Fixed **Copy** > **Ports** being shown in the context menu with the port scan disabled (it copied nothing). [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) - Fixed IPv6 hosts waiting for the NetBIOS timeout (NetBIOS doesn't exist over IPv6). NetBIOS is only requested from IPv4 addresses. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) - Fixed columns that couldn't be made wider (by dragging or double-clicking the gripper) if the window was not wide enough, e.g. to see a long hostname. The last column still fills the remaining width, other columns can now grow beyond the window with a horizontal scrollbar. This also applies to the custom commands in the settings. [#3557](https://github.com/BornToBeRoot/NETworkManager/issues/3557) +- Fixed the export containing values of disabled probes, e.g. `PortStatus` `Closed` if **Scan ports** was disabled or `NetBIOSIsReachable` `False` if **Scan for NetBIOS** was disabled. They are exported as empty values now. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) **Port Scanner** From 18efed3746fd517dbb6af548cc32ea56c82ee1fb Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:34:24 +0100 Subject: [PATCH 003/131] Feature: IP Scanner: Option to disable the ping (A2) New setting IPScanner_ICMPEnabled ("Ping hosts", default on). Without ping, hosts are found by their ports or NetBIOS answer and an unreachable host doesn't wait for the ping attempts. The ping columns, copy entries and row details are hidden for a scan without ping (IPScannerResultOptions) and the ping values are exported as empty values. The scan doesn't start if ping, port scan and NetBIOS are all disabled. Co-Authored-By: Claude Opus 5.5 --- .../Resources/Strings.Designer.cs | 36 ++++ .../Resources/Strings.resx | 12 ++ .../Export/ExportManager.IPScannerHostInfo.cs | 18 +- .../Network/IPScanner.cs | 24 ++- .../Network/IPScannerOptions.cs | 5 + .../Network/IPScannerResultOptions.cs | 4 +- .../GlobalStaticConfiguration.cs | 1 + .../NETworkManager.Settings/SettingsInfo.cs | 13 ++ .../ViewModels/IPScannerSettingsViewModel.cs | 20 ++ .../ViewModels/IPScannerViewModel.cs | 12 ++ .../Views/IPScannerSettingsView.xaml | 30 ++- .../NETworkManager/Views/IPScannerView.xaml | 175 ++++++++++-------- Website/docs/application/ip-scanner.md | 14 ++ Website/docs/changelog/next-release.md | 1 + 14 files changed, 266 insertions(+), 99 deletions(-) diff --git a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs index a2e9230597..16901c4dbc 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs +++ b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs @@ -4064,6 +4064,15 @@ public static string Enable { } } + /// + /// Looks up a localized string similar to Enable at least one of ping, port scan or NetBIOS in the settings. A host is only found if it answers one of them.. + /// + public static string EnableAtLeastOneProbeToFindHostsMessage { + get { + return ResourceManager.GetString("EnableAtLeastOneProbeToFindHostsMessage", resourceCulture); + } + } + /// /// Looks up a localized string similar to Enable Credential Security Support Provider. /// @@ -5993,6 +6002,15 @@ public static string HelpMessage_PasswordNotDisplayedCanBeOverwritten { } } + /// + /// Looks up a localized string similar to Send a ping (ICMP echo request) to every host. Disable it if ICMP is blocked or rate limited in the network, the scan is faster then. Hosts that only answer the ping are not found without it.. + /// + public static string HelpMessage_PingHosts { + get { + return ResourceManager.GetString("HelpMessage_PingHosts", resourceCulture); + } + } + /// /// Looks up a localized string similar to Only hosts that answer a ping (ICMP echo, with the attempts, timeout and buffer below) are scanned. Faster for ranges with many unused IP addresses. Hosts that block ICMP (e.g. Windows with the firewall enabled) are not scanned.. /// @@ -9139,6 +9157,15 @@ public static string PingDetails { } } + /// + /// Looks up a localized string similar to Ping hosts. + /// + public static string PingHosts { + get { + return ResourceManager.GetString("PingHosts", resourceCulture); + } + } + /// /// Looks up a localized string similar to Ping hosts before the scan. /// @@ -9148,6 +9175,15 @@ public static string PingHostsBeforeScan { } } + /// + /// Looks up a localized string similar to Ping is disabled!. + /// + public static string PingIsDisabled { + get { + return ResourceManager.GetString("PingIsDisabled", resourceCulture); + } + } + /// /// Looks up a localized string similar to Ping Monitor. /// diff --git a/Source/NETworkManager.Localization/Resources/Strings.resx b/Source/NETworkManager.Localization/Resources/Strings.resx index 4ad8d280a0..19704864f5 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.resx +++ b/Source/NETworkManager.Localization/Resources/Strings.resx @@ -5161,4 +5161,16 @@ Do you want to start the scan? TCP + + Ping hosts + + + Send a ping (ICMP echo request) to every host. Disable it if ICMP is blocked or rate limited in the network, the scan is faster then. Hosts that only answer the ping are not found without it. + + + Ping is disabled! + + + Enable at least one of ping, port scan or NetBIOS in the settings. A host is only found if it answers one of them. + \ No newline at end of file diff --git a/Source/NETworkManager.Models/Export/ExportManager.IPScannerHostInfo.cs b/Source/NETworkManager.Models/Export/ExportManager.IPScannerHostInfo.cs index 6b18a28491..4f101ca9ad 100644 --- a/Source/NETworkManager.Models/Export/ExportManager.IPScannerHostInfo.cs +++ b/Source/NETworkManager.Models/Export/ExportManager.IPScannerHostInfo.cs @@ -86,7 +86,7 @@ private static void CreateCsv(IEnumerable collection, IPScann // Values of a probe that is still running (export during a scan, or a scan canceled before the probe // finished) or disabled are placeholders - exported as empty values instead of a result - var ping = info.IsPingPending ? null : info.PingInfo; + var ping = GetPingInfo(info, options); var netBIOSInfo = GetNetBIOSInfo(info, options); var stringBuilderPorts = new StringBuilder(); @@ -141,7 +141,7 @@ private static void CreateXml(IEnumerable collection, IPScann from info in collection where info?.PingInfo != null // Placeholders of running / disabled probes are exported as empty values (see CreateCsv) - let ping = info.IsPingPending ? null : info.PingInfo + let ping = GetPingInfo(info, options) let netBIOSInfo = GetNetBIOSInfo(info, options) select new XElement(nameof(IPScannerHostInfo), @@ -202,7 +202,7 @@ private static void CreateJson(IReadOnlyList collection, IPSc var info = validCollection[i]; // Placeholders of running / disabled probes are exported as empty values (see CreateCsv) - var ping = info.IsPingPending ? null : info.PingInfo; + var ping = GetPingInfo(info, options); var netBIOSInfo = GetNetBIOSInfo(info, options); object[] jsonDataPorts; @@ -264,6 +264,18 @@ private static string GetDetectedBy(IPScannerHostInfo info) return info.DetectedBy == IPScannerDetectionMethod.None ? null : info.DetectedBy.ToString(); } + /// + /// Gets the result of the ping to a host - null while the ping is still running or if it was disabled + /// (placeholder). + /// + /// Host to get the ping result for. + /// Options of the scan the host is from. + /// Result of the ping or null. + private static PingInfo GetPingInfo(IPScannerHostInfo info, IPScannerResultOptions options) + { + return info.IsPingPending || !options.ICMPEnabled ? null : info.PingInfo; + } + /// /// Gets the NetBIOS information of a host - null while the NetBIOS request is still running or if it was /// disabled (placeholder). diff --git a/Source/NETworkManager.Models/Network/IPScanner.cs b/Source/NETworkManager.Models/Network/IPScanner.cs index d400474074..25b70a0983 100644 --- a/Source/NETworkManager.Models/Network/IPScanner.cs +++ b/Source/NETworkManager.Models/Network/IPScanner.cs @@ -147,7 +147,8 @@ public void ScanAsync(IEnumerable<(IPAddress ipAddress, string hostname)> hosts, // Spread the start of the hosts over time instead of starting all at once - based on the time // an unreachable host takes (the slowest of ping attempts, port scan and NetBIOS). In long, the port // scan can exceed int (e.g. 15000 ms x 10 attempts x 65535 ports with one port thread) - var hostDuration = Math.Max((long)options.ICMPAttempts * options.ICMPTimeout, + var hostDuration = Math.Max( + options.ICMPEnabled ? (long)options.ICMPAttempts * options.ICMPTimeout : 0, Math.Max( options.PortScanEnabled ? (long)options.PortScanTimeout * options.PortScanAttempts * @@ -213,8 +214,11 @@ private async Task ScanHostAsync(IPAddress ipAddress, ParallelOptions portScanPa // all ports are finished var portAnswered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var pingTask = PingProbe.PingAsync(ipAddress, options.ICMPTimeout, options.ICMPAttempts, - _icmpBuffer, cancellationToken); + // Without ping, the host gets the placeholder of the ping (see CreateHostInfo) + var pingTask = options.ICMPEnabled + ? PingProbe.PingAsync(ipAddress, options.ICMPTimeout, options.ICMPAttempts, _icmpBuffer, + cancellationToken) + : null; var portScanTask = options.PortScanEnabled ? PortScanAsync(ipAddress, portScanParallelOptions, () => portAnswered.TrySetResult(), cancellationToken) @@ -239,7 +243,10 @@ private async Task ScanHostAsync(IPAddress ipAddress, ParallelOptions portScanPa try { - List pending = [pingTask, portScanTask, netBIOSTask, portAnswered.Task]; + List pending = [portScanTask, netBIOSTask, portAnswered.Task]; + + if (pingTask != null) + pending.Add(pingTask); while (pending.Count > 0) { @@ -365,7 +372,8 @@ private async Task ScanHostAsync(IPAddress ipAddress, ParallelOptions portScanPa finally { // Never return while a probe or lookup is still running (e.g. another probe was canceled or failed) - await WaitIgnoringErrorsAsync(pingTask, portScanTask, netBIOSTask, enrichTask).ConfigureAwait(false); + await WaitIgnoringErrorsAsync(pingTask ?? Task.CompletedTask, portScanTask, netBIOSTask, enrichTask) + .ConfigureAwait(false); } } @@ -400,12 +408,12 @@ private List GetShownPorts(List ports) /// /// Creates the result of a host with the results of the finished probes and placeholders for the running ones - /// (marked as pending). + /// (marked as pending) and the disabled ones. /// /// IP address of the host. /// Probes the host answered so far, if it is /// unreachable. - /// Result of the ping, null if it is still running. + /// Result of the ping, null if it is still running or disabled. /// All probed ports, null if the port scan is still running. /// Result of the NetBIOS request, null if it is still running. /// Result of the host. @@ -421,7 +429,7 @@ private IPScannerHostInfo CreateHostInfo(IPAddress ipAddress, IPScannerDetection netBIOSInfo ?? new NetBIOSInfo(ipAddress)) { DetectedBy = detectedBy, - IsPingPending = pingInfo == null, + IsPingPending = pingInfo == null && options.ICMPEnabled, IsPortScanPending = ports == null, IsNetBIOSPending = netBIOSInfo == null }; diff --git a/Source/NETworkManager.Models/Network/IPScannerOptions.cs b/Source/NETworkManager.Models/Network/IPScannerOptions.cs index 39047590e9..afc1ea555f 100644 --- a/Source/NETworkManager.Models/Network/IPScannerOptions.cs +++ b/Source/NETworkManager.Models/Network/IPScannerOptions.cs @@ -16,6 +16,11 @@ public record IPScannerOptions /// public required int MaxPortThreads { get; init; } + /// + /// Ping the host. + /// + public required bool ICMPEnabled { get; init; } + /// /// Number of attempts to ping a host. /// diff --git a/Source/NETworkManager.Models/Network/IPScannerResultOptions.cs b/Source/NETworkManager.Models/Network/IPScannerResultOptions.cs index 385c71fce0..cb61a3b4ae 100644 --- a/Source/NETworkManager.Models/Network/IPScannerResultOptions.cs +++ b/Source/NETworkManager.Models/Network/IPScannerResultOptions.cs @@ -4,11 +4,13 @@ /// Options of an IP scan that decide which columns and details of its result are shown. Only these flags of the /// , without the ports and the ICMP buffer of the scan. /// +/// The hosts were pinged. /// The hostname was resolved via DNS. /// The ports of the hosts were scanned. /// The NetBIOS information was requested. /// The MAC address and vendor were resolved. public sealed record IPScannerResultOptions( + bool ICMPEnabled, bool ResolveHostname, bool PortScanEnabled, bool NetBIOSEnabled, @@ -19,7 +21,7 @@ public sealed record IPScannerResultOptions( /// /// Options of the scan. public IPScannerResultOptions(IPScannerOptions options) - : this(options.ResolveHostname, options.PortScanEnabled, options.NetBIOSEnabled, options.ResolveMACAddress) + : this(options.ICMPEnabled, options.ResolveHostname, options.PortScanEnabled, options.NetBIOSEnabled, options.ResolveMACAddress) { } } diff --git a/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs b/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs index 48ad2b3032..e32c860c5e 100644 --- a/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs +++ b/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs @@ -148,6 +148,7 @@ public static class GlobalStaticConfiguration public static ExportFileType WiFi_ExportFileType => ExportFileType.Csv; // Application: IP Scanner + public static bool IPScanner_ICMPEnabled => true; public static int IPScanner_ICMPAttempts => 2; public static int IPScanner_ICMPTimeout => 2000; public static int IPScanner_ICMPBuffer => 32; diff --git a/Source/NETworkManager.Settings/SettingsInfo.cs b/Source/NETworkManager.Settings/SettingsInfo.cs index b3892ce80d..fc81f19fda 100644 --- a/Source/NETworkManager.Settings/SettingsInfo.cs +++ b/Source/NETworkManager.Settings/SettingsInfo.cs @@ -1220,6 +1220,19 @@ public bool IPScanner_ExpandHostView } } = GlobalStaticConfiguration.IPScanner_ExpandHostView; + public bool IPScanner_ICMPEnabled + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } = GlobalStaticConfiguration.IPScanner_ICMPEnabled; + public int IPScanner_ICMPTimeout { get; diff --git a/Source/NETworkManager/ViewModels/IPScannerSettingsViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerSettingsViewModel.cs index a2a62e91d5..d62177ede4 100644 --- a/Source/NETworkManager/ViewModels/IPScannerSettingsViewModel.cs +++ b/Source/NETworkManager/ViewModels/IPScannerSettingsViewModel.cs @@ -64,6 +64,25 @@ public bool ExpandHostView } } + /// + /// Gets or sets a value indicating whether the hosts are pinged. + /// + public bool ICMPEnabled + { + get; + set + { + if (value == field) + return; + + if (!_isLoading) + SettingsManager.Current.IPScanner_ICMPEnabled = value; + + field = value; + OnPropertyChanged(); + } + } + /// /// Gets or sets the number of ICMP attempts. /// @@ -375,6 +394,7 @@ private void LoadSettings() { ShowAllResults = SettingsManager.Current.IPScanner_ShowAllResults; ExpandHostView = SettingsManager.Current.IPScanner_ExpandHostView; + ICMPEnabled = SettingsManager.Current.IPScanner_ICMPEnabled; ICMPAttempts = SettingsManager.Current.IPScanner_ICMPAttempts; ICMPTimeout = SettingsManager.Current.IPScanner_ICMPTimeout; ICMPBuffer = SettingsManager.Current.IPScanner_ICMPBuffer; diff --git a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs index 8d280c2a71..cb5d602f86 100644 --- a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs @@ -679,6 +679,16 @@ private void ExportAction() /// private async Task Start() { + // A host is only found if it answers a probe - without ping, port scan and NetBIOS every host is unreachable + if (!SettingsManager.Current.IPScanner_ICMPEnabled && !SettingsManager.Current.IPScanner_PortScanEnabled && + !SettingsManager.Current.IPScanner_NetBIOSEnabled) + { + StatusMessage = Strings.EnableAtLeastOneProbeToFindHostsMessage; + IsStatusMessageDisplayed = true; + + return; + } + // Every IP address is kept in memory - ask before a large scan and refuse a too large one // (e.g. 10.0.0.0/1) instead of running out of memory long hostCount; @@ -899,6 +909,7 @@ private static IPScannerOptions CreateScanOptions(int[] ports) { MaxHostThreads = SettingsManager.Current.IPScanner_MaxHostThreads, MaxPortThreads = SettingsManager.Current.IPScanner_MaxPortThreads, + ICMPEnabled = SettingsManager.Current.IPScanner_ICMPEnabled, ICMPAttempts = SettingsManager.Current.IPScanner_ICMPAttempts, ICMPTimeout = SettingsManager.Current.IPScanner_ICMPTimeout, ICMPBufferSize = GlobalStaticConfiguration.LimitICMPBuffer(SettingsManager.Current.IPScanner_ICMPBuffer), @@ -933,6 +944,7 @@ private static IPScannerOptions CreateRescanOptions(IPScannerOptions options) private static IPScannerResultOptions CreateResultOptions() { return new IPScannerResultOptions( + SettingsManager.Current.IPScanner_ICMPEnabled, SettingsManager.Current.IPScanner_ResolveHostname, SettingsManager.Current.IPScanner_PortScanEnabled, SettingsManager.Current.IPScanner_NetBIOSEnabled, diff --git a/Source/NETworkManager/Views/IPScannerSettingsView.xaml b/Source/NETworkManager/Views/IPScannerSettingsView.xaml index 05cfced30a..88ea3c7339 100644 --- a/Source/NETworkManager/Views/IPScannerSettingsView.xaml +++ b/Source/NETworkManager/Views/IPScannerSettingsView.xaml @@ -22,15 +22,27 @@ - - - - - - + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Website/docs/application/ip-scanner.md b/Website/docs/application/ip-scanner.md index 15a74cc6e4..c7e098afa8 100644 --- a/Website/docs/application/ip-scanner.md +++ b/Website/docs/application/ip-scanner.md @@ -160,6 +160,20 @@ Show the details (ping, ports, NetBIOS) of every host in the result, also of hos **Default:** `Disabled` +### Ping hosts + +Send a ping (ICMP echo request) to every IP address. Disable it if ICMP is blocked or rate limited in the network (e.g. by an IDS): without ping, an unreachable host is finished after the port scan / NetBIOS timeout instead of after all ping attempts, so the scan is faster. Hosts that only answer the ping are not found then. The **Ping status** and **Time** columns and the ping details are hidden for a scan without ping. + +**Type:** `Boolean` + +**Default:** `Enabled` + +:::note + +A host is only found if it answers a probe: at least one of **Ping hosts**, [Scan ports](#scan-ports) or [Scan for NetBIOS](#scan-for-netbios) must be enabled, otherwise the scan doesn't start. + +::: + ### Attempts Maximum number of ICMP requests sent to each IP address. Another request is only sent if the previous one has timed out (e.g. a lost packet), other replies like "destination host unreachable" are final. diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index 51faad49fc..733aec98c3 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -88,6 +88,7 @@ Release date: **xx.xx.2026** - New **Attempts** setting for the port scan (default `1`): another connection request is sent if there was no answer, e.g. on an unreliable network (Wi-Fi, VPN) or for hosts on the internet. Open and closed ports are not repeated. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) - Added **RDAP...** to the context menu to query the registration data of the IP address or hostname of a host in the [**RDAP**](../application/rdap.md) tool. [#3642](https://github.com/BornToBeRoot/NETworkManager/pull/3642) - The probes a host answered (**Ping**, **TCP**, **NetBIOS**) are shown in the tooltip of the status icon and as **Detected by** in the row details, e.g. to see why a host is up. The export contains them as `DetectedBy` (last column / element). [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) +- New **Ping hosts** setting (default on) to scan without ping, e.g. if ICMP is blocked or rate limited in the network: hosts are found by their ports or NetBIOS answer, and the scan is faster because an unreachable host doesn't wait for the ping attempts. At least one of ping, port scan or NetBIOS must be enabled. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) **Port Scanner** From dc2e045a3640a3a5ba277d78824094c1a3e2aec5 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:35:26 +0100 Subject: [PATCH 004/131] Fix: Neighbor Table: State shifted by one (A3a) NeighborState started at 1, but NL_NEIGHBOR_STATE (MIB_IPNET_ROW2.State from GetIpNetTable2) starts at 0 (NlnsUnreachable): Reachable entries were shown as Stale, Permanent as Reachable, Unreachable as 0. Preparation for A3 (IP Scanner reads the state). Co-Authored-By: Claude Opus 5.5 --- .../Network/NeighborState.cs | 18 +++++++++--------- Website/docs/changelog/next-release.md | 1 + 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/Source/NETworkManager.Models/Network/NeighborState.cs b/Source/NETworkManager.Models/Network/NeighborState.cs index 761e5339ad..6dd72acc37 100644 --- a/Source/NETworkManager.Models/Network/NeighborState.cs +++ b/Source/NETworkManager.Models/Network/NeighborState.cs @@ -2,16 +2,16 @@ 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. +/// Values match the NL_NEIGHBOR_STATE enumeration (State field of the MIB_IPNET_ROW2 +/// structure returned by GetIpNetTable2, starts at 0), so a direct cast is safe. /// public enum NeighborState { - Unreachable = 1, - Incomplete = 2, - Probe = 3, - Delay = 4, - Stale = 5, - Reachable = 6, - Permanent = 7 + Unreachable = 0, + Incomplete = 1, + Probe = 2, + Delay = 3, + Stale = 4, + Reachable = 5, + Permanent = 6 } diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index 733aec98c3..fba9f3e1af 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -237,6 +237,7 @@ Release date: **xx.xx.2026** **Neighbor Table** +- Fixed the **State** of the entries being shifted by one (e.g. `Reachable` entries were shown as `Stale`, `Permanent` entries as `Reachable`, `Unreachable` entries as `0`) since the table is read with the Windows API (2026.7.7). [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) - Fixed adding and deleting entries not working until the application was restarted if loading the PowerShell module `NetTCPIP` failed once. [cc05937](https://github.com/BornToBeRoot/NETworkManager/commit/cc05937a7de2bf1247621845f47077f3796b9bb2) - Fixed the auto refresh continuing in the background after switching to another tool, or after it was disabled, if this happened while a refresh was running. [#3639](https://github.com/BornToBeRoot/NETworkManager/pull/3639) - Fixed columns that couldn't be made wider than the window (by dragging or double-clicking the gripper). The grid scrolls horizontally now, the last (or main) column still fills the remaining width. [#3557](https://github.com/BornToBeRoot/NETworkManager/issues/3557) From 7a812818856fc67f1b48fc3ca605eb5cba634b1f Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:40:22 +0100 Subject: [PATCH 005/131] Feature: IP Scanner: Find hosts in the local subnet via ARP / NDP (A3) New setting IPScanner_ARPEnabled ("Find hosts in the local subnet", default on). A host in a local subnet that didn't answer any probe is checked in the neighbor table (read after its probes were finished): a Reachable entry means it answered the ARP request / neighbor solicitation of the probes (DetectedBy ARP). Stale / Delay / Probe entries from earlier traffic are resolved again with ResolveIpNetEntry2 (max. 4 at the same time, blocking). Answers with the MAC address of a default gateway are ignored (proxy ARP). The neighbor table snapshot now keeps the whole entry (state, interface) and is read at most every 100 ms (hosts share the next read). Unit tests for the decision (IPScannerNeighborCheck) and the NeighborState values. Co-Authored-By: Claude Opus 5.5 --- ...ScannerDetectionMethodToStringConverter.cs | 5 +- .../Resources/Strings.Designer.cs | 27 ++++ .../Resources/Strings.resx | 9 ++ .../Network/IPScanner.cs | 151 +++++++++++++++--- .../Network/IPScannerDetectionMethod.cs | 8 +- .../Network/IPScannerNeighborCheck.cs | 63 ++++++++ .../Network/IPScannerNeighborCheckResult.cs | 25 +++ .../Network/IPScannerOptions.cs | 6 + .../Network/NeighborTable.cs | 79 ++++++++- .../GlobalStaticConfiguration.cs | 1 + .../NETworkManager.Settings/SettingsInfo.cs | 13 ++ .../Network/IPScannerNeighborCheckTests.cs | 83 ++++++++++ .../ViewModels/IPScannerSettingsViewModel.cs | 20 +++ .../ViewModels/IPScannerViewModel.cs | 1 + .../Views/IPScannerSettingsView.xaml | 12 ++ Website/docs/application/ip-scanner.md | 18 ++- Website/docs/changelog/next-release.md | 1 + 17 files changed, 488 insertions(+), 34 deletions(-) create mode 100644 Source/NETworkManager.Models/Network/IPScannerNeighborCheck.cs create mode 100644 Source/NETworkManager.Models/Network/IPScannerNeighborCheckResult.cs create mode 100644 Source/NETworkManager.Tests/Network/IPScannerNeighborCheckTests.cs diff --git a/Source/NETworkManager.Converters/IPScannerDetectionMethodToStringConverter.cs b/Source/NETworkManager.Converters/IPScannerDetectionMethodToStringConverter.cs index ab7ebb6c4c..45f618185e 100644 --- a/Source/NETworkManager.Converters/IPScannerDetectionMethodToStringConverter.cs +++ b/Source/NETworkManager.Converters/IPScannerDetectionMethodToStringConverter.cs @@ -28,7 +28,7 @@ public object Convert(object value, Type targetType, object parameter, CultureIn if (value is not IPScannerDetectionMethod detectedBy || detectedBy == IPScannerDetectionMethod.None) return isStatus ? Strings.Down : "-/-"; - var names = new List(3); + var names = new List(4); if (detectedBy.HasFlag(IPScannerDetectionMethod.ICMP)) names.Add(Strings.Ping); @@ -39,6 +39,9 @@ public object Convert(object value, Type targetType, object parameter, CultureIn if (detectedBy.HasFlag(IPScannerDetectionMethod.NetBIOS)) names.Add(Strings.NetBIOS); + if (detectedBy.HasFlag(IPScannerDetectionMethod.ARP)) + names.Add(Strings.ARPNDP); + var text = string.Join(", ", names); return isStatus ? string.Format(Strings.UpDetectedByX, text) : text; diff --git a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs index 16901c4dbc..abbe6c502e 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs +++ b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs @@ -1275,6 +1275,15 @@ public static string ARP { } } + /// + /// Looks up a localized string similar to ARP / NDP. + /// + public static string ARPNDP { + get { + return ResourceManager.GetString("ARPNDP", resourceCulture); + } + } + /// /// Looks up a localized string similar to ASN. /// @@ -5129,6 +5138,15 @@ public static string FilterProfilesDots { } } + /// + /// Looks up a localized string similar to Find hosts in the local subnet. + /// + public static string FindHostsInTheLocalSubnet { + get { + return ResourceManager.GetString("FindHostsInTheLocalSubnet", resourceCulture); + } + } + /// /// Looks up a localized string similar to Firewall. /// @@ -5930,6 +5948,15 @@ public static string HelpMessage_ExperimentalFeatures { } } + /// + /// Looks up a localized string similar to Find hosts in a local subnet that don't answer the ping, a port or NetBIOS (e.g. a firewall drops everything) by their entry in the neighbor table (ARP / NDP). To reach a host in a local subnet, Windows has to resolve its MAC address first, and a host answers this request even if its firewall drops everything else. No additional packets are sent, except to check an entry from earlier traffic again. Hosts behind a router can't be found this way.. + /// + public static string HelpMessage_FindHostsInTheLocalSubnet { + get { + return ResourceManager.GetString("HelpMessage_FindHostsInTheLocalSubnet", resourceCulture); + } + } + /// /// Looks up a localized string similar to Show all active Windows Firewall rules (e.g. Windows built-in rules, rules created by other applications or from Group Policy) instead of only the rules managed by NETworkManager. Rules not managed by NETworkManager can be enabled, disabled, deleted or copied, but not edited. Rules from a policy (e.g. Group Policy or MDM) can only be copied.. /// diff --git a/Source/NETworkManager.Localization/Resources/Strings.resx b/Source/NETworkManager.Localization/Resources/Strings.resx index 19704864f5..ee6a9ab3c3 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.resx +++ b/Source/NETworkManager.Localization/Resources/Strings.resx @@ -5173,4 +5173,13 @@ Do you want to start the scan? Enable at least one of ping, port scan or NetBIOS in the settings. A host is only found if it answers one of them. + + ARP / NDP + + + Find hosts in the local subnet + + + Find hosts in a local subnet that don't answer the ping, a port or NetBIOS (e.g. a firewall drops everything) by their entry in the neighbor table (ARP / NDP). To reach a host in a local subnet, Windows has to resolve its MAC address first, and a host answers this request even if its firewall drops everything else. No additional packets are sent, except to check an entry from earlier traffic again. Hosts behind a router can't be found this way. + \ No newline at end of file diff --git a/Source/NETworkManager.Models/Network/IPScanner.cs b/Source/NETworkManager.Models/Network/IPScanner.cs index 25b70a0983..288d336994 100644 --- a/Source/NETworkManager.Models/Network/IPScanner.cs +++ b/Source/NETworkManager.Models/Network/IPScanner.cs @@ -35,11 +35,27 @@ public sealed class IPScanner(IPScannerOptions options) private readonly InterlockedCounter _hostsUp = new(); private readonly InterlockedCounter _hostsDown = new(); - // Snapshot of the neighbor table (IP address -> MAC address), see GetMACAddressFromNeighborTableAsync + /// + /// Minimum time between two reads of the neighbor table, see . Hosts that + /// finish while waiting share the next read - otherwise the table would be read for almost every host when + /// unreachable hosts are checked for their neighbor entry (ARP / NDP). + /// + private static readonly TimeSpan MinNeighborTableReadInterval = TimeSpan.FromMilliseconds(100); + + /// + /// Maximum number of neighbor entries that are verified at the same time, see - + /// each verification blocks a thread for up to about 3 seconds. + /// + private const int MaxConcurrentNeighborVerifications = 4; + + // Snapshot of the neighbor table (IP address -> entry), see GetNeighborEntryAsync. Replaced, never changed. private readonly SemaphoreSlim _neighborTableSemaphore = new(1, 1); - private Dictionary _neighborTable = []; + private Dictionary _neighborTable = []; private DateTime _neighborTableTimestamp = DateTime.MinValue; + private readonly SemaphoreSlim _neighborVerificationSemaphore = + new(MaxConcurrentNeighborVerifications, MaxConcurrentNeighborVerifications); + /// /// Gets the number of hosts finished so far (all probes and lookups). Thread-safe; may be read from /// any thread while the scan is running. @@ -129,8 +145,11 @@ public void ScanAsync(IEnumerable<(IPAddress ipAddress, string hostname)> hosts, try { - // Get all network interfaces (for local mac address lookup) - var networkInterfaces = options.ResolveMACAddress ? NetworkInterface.GetNetworkInterfaces() : []; + // Get all network interfaces (for the MAC address lookup and the neighbor check of hosts in a local + // subnet) + var networkInterfaces = options.ResolveMACAddress || options.ARPEnabled + ? NetworkInterface.GetNetworkInterfaces() + : []; var hostParallelOptions = new ParallelOptions { @@ -348,6 +367,22 @@ private async Task ScanHostAsync(IPAddress ipAddress, ParallelOptions portScanPa enrichTask = EnrichAsync(ipAddress, hostInfo, DateTime.UtcNow, networkInterfaces, cancellationToken); } + // No probe answered: a host in a local subnet may still have answered the ARP request / neighbor + // solicitation of the probes (e.g. a firewall that drops everything else) + if (hostInfo == null && options.ARPEnabled && IsInLocalSubnet(ipAddress, networkInterfaces) && + await IsNeighborAsync(ipAddress, networkInterfaces, cancellationToken).ConfigureAwait(false)) + { + detectedBy = IPScannerDetectionMethod.ARP; + hostInfo = CreateHostInfo(ipAddress, detectedBy, pingInfo, ports, netBIOSInfo); + + _hostsUp.Increment(); + + OnHostFound(new IPScannerHostFoundArgs(hostInfo)); + OnProgressChanged(); + + enrichTask = EnrichAsync(ipAddress, hostInfo, DateTime.UtcNow, networkInterfaces, cancellationToken); + } + if (hostInfo == null) { // Count unreachable hosts unconditionally, since ShowAllResults may prevent them from ever @@ -494,8 +529,8 @@ await Task.WhenAll( // Get info from neighbor table. Only hosts in a local subnet are in it (for any other // host it contains the router), an unreachable host only reads it if it was not read yet. if (IsInLocalSubnet(ipAddress, networkInterfaces)) - macAddress = await GetMACAddressFromNeighborTableAsync(ipAddress, discoveredAt, cancellationToken) - .ConfigureAwait(false); + macAddress = (await GetNeighborEntryAsync(ipAddress, discoveredAt, false, cancellationToken) + .ConfigureAwait(false))?.MACAddress.ToString() ?? string.Empty; // Check if it is the local mac if (string.IsNullOrEmpty(macAddress)) @@ -522,21 +557,27 @@ await Task.WhenAll( } /// - /// Gets the MAC address of from the neighbor table (ARP / NDP). The table is + /// Gets the entry of from the neighbor table (ARP / NDP). The table is /// read once and cached for the scan instead of reading it for every host. It is only read again on a miss - /// if the cached snapshot is older than (the time the host answered, when - /// its entry was already created) - a host is always looked up in a table read after its answer, and hosts - /// that finish at the same time share one read. + /// (or, with , if the cached entry is not + /// ) if the cached snapshot is older than + /// (the time the host answered, when its entry was already created) - a host is always looked up in a table + /// read after its answer, and hosts that finish at the same time share one read (at most one read per + /// ). /// /// IP address of the host (in a local subnet, see ). /// - /// Time the host answered, to use the cache - or to read the table if it was not - /// read yet. + /// Time the host answered (or its probes were finished), to use the cache - or + /// to read the table if it was not read yet. + /// + /// + /// Read the table again if the cached entry is not (e.g. still + /// / in an older snapshot). /// /// Token to monitor for cancellation requests. - /// MAC address or an empty string. - private async Task GetMACAddressFromNeighborTableAsync(IPAddress ipAddress, DateTime discoveredAt, - CancellationToken cancellationToken) + /// Neighbor entry or null. + private async Task GetNeighborEntryAsync(IPAddress ipAddress, DateTime discoveredAt, + bool requireReachable, CancellationToken cancellationToken) { // Hosts wait without blocking a thread while another host reads the table await _neighborTableSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); @@ -544,19 +585,26 @@ private async Task GetMACAddressFromNeighborTableAsync(IPAddress ipAddre try { // With the scope id if it was entered (e.g. "fe80::1%3" and "fe80::1%7" are two hosts on different - // interfaces), see NeighborTable.GetMACAddressMap - if (_neighborTable.TryGetValue(ipAddress, out var macAddress)) - return macAddress; + // interfaces), see NeighborTable.GetNeighborMap + if (_neighborTable.TryGetValue(ipAddress, out var entry) && + (!requireReachable || entry.State == NeighborState.Reachable)) + return entry; - // Already read after the host answered -> no entry + // Already read after the host answered -> no (newer) entry if (_neighborTableTimestamp > discoveredAt) - return string.Empty; + return entry; + + // Hosts that finish in the meantime share the next read + var wait = _neighborTableTimestamp + MinNeighborTableReadInterval - DateTime.UtcNow; + + if (wait > TimeSpan.Zero) + await Task.Delay(wait, cancellationToken).ConfigureAwait(false); // Timestamp before reading the table, so entries created while reading are covered by the next read _neighborTableTimestamp = DateTime.UtcNow; - _neighborTable = NeighborTable.GetMACAddressMap(); + _neighborTable = NeighborTable.GetNeighborMap(); - return _neighborTable.GetValueOrDefault(ipAddress, string.Empty); + return _neighborTable.GetValueOrDefault(ipAddress); } finally { @@ -564,6 +612,65 @@ private async Task GetMACAddressFromNeighborTableAsync(IPAddress ipAddre } } + /// + /// Checks if a host in a local subnet that didn't answer any probe answered the ARP request / neighbor + /// solicitation of the probes (see ): its neighbor entry is read after the + /// probes were finished; an entry from earlier traffic that was not verified again yet (e.g. + /// ) is resolved again (at most + /// at the same time). + /// + /// IP address of the host (in a local subnet, see ). + /// Local network interfaces (for the default gateways). + /// Token to monitor for cancellation requests. + /// True if the host is reachable. + private async Task IsNeighborAsync(IPAddress ipAddress, List networkInterfaces, + CancellationToken cancellationToken) + { + var entry = await GetNeighborEntryAsync(ipAddress, DateTime.UtcNow, true, cancellationToken) + .ConfigureAwait(false); + + var gateways = networkInterfaces.SelectMany(x => x.IPv4Gateway.Concat(x.IPv6Gateway)) + .Select(IPAddressHelper.WithoutScopeId).ToList(); + + var ipAddressWithoutScopeId = IPAddressHelper.WithoutScopeId(ipAddress); + var isGateway = gateways.Contains(ipAddressWithoutScopeId); + + // MAC addresses of the gateways from the current snapshot (replaced, never changed - no lock needed) + var neighborTable = _neighborTable; + + var gatewayMACAddresses = gateways + .Select(x => neighborTable.GetValueOrDefault(x)?.MACAddress.ToString()) + .Where(x => !string.IsNullOrEmpty(x)) + .ToList(); + + switch (IPScannerNeighborCheck.Check(entry, isGateway, gatewayMACAddresses)) + { + case IPScannerNeighborCheckResult.Reachable: + return true; + case IPScannerNeighborCheckResult.Unreachable: + return false; + } + + // Entry from earlier traffic: resolve it again - blocks a thread until the neighbor answered or the resolution + // failed, so only a few at the same time + await _neighborVerificationSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + + try + { + // Address of the entry (IPv6 link-local: with the scope id of its interface). A canceled scan doesn't wait + // for the resolution (the thread is released when Windows returns). + var macAddress = await Task.Run(() => NeighborTable.ResolveEntry(entry.IPAddress, entry.InterfaceIndex), + CancellationToken.None).WaitAsync(cancellationToken).ConfigureAwait(false); + + return !string.IsNullOrEmpty(macAddress) && + (isGateway || !IPScannerNeighborCheck.IsProxyARPAnswer(macAddress, gatewayMACAddresses)); + } + finally + { + _neighborVerificationSemaphore.Release(); + } + } + /// /// Checks if is in a subnet of a local network interface (on-link). Only these /// hosts are in the neighbor table - for a host behind a router, its MAC address can't be resolved via ARP/NDP diff --git a/Source/NETworkManager.Models/Network/IPScannerDetectionMethod.cs b/Source/NETworkManager.Models/Network/IPScannerDetectionMethod.cs index 426af9087f..7d5657905e 100644 --- a/Source/NETworkManager.Models/Network/IPScannerDetectionMethod.cs +++ b/Source/NETworkManager.Models/Network/IPScannerDetectionMethod.cs @@ -26,5 +26,11 @@ public enum IPScannerDetectionMethod /// /// The host answered the NetBIOS request. /// - NetBIOS = 4 + NetBIOS = 4, + + /// + /// The host answered the ARP request (IPv4) / neighbor solicitation (IPv6) of the probes, see + /// . + /// + ARP = 8 } diff --git a/Source/NETworkManager.Models/Network/IPScannerNeighborCheck.cs b/Source/NETworkManager.Models/Network/IPScannerNeighborCheck.cs new file mode 100644 index 0000000000..5bff4763bd --- /dev/null +++ b/Source/NETworkManager.Models/Network/IPScannerNeighborCheck.cs @@ -0,0 +1,63 @@ +using System; +using System.Collections.Generic; + +namespace NETworkManager.Models.Network; + +/// +/// Decides if a host that didn't answer any probe of the is reachable based on its +/// neighbor entry (ARP / NDP). Every probe (ping, TCP, NetBIOS) makes Windows resolve the MAC address of a host in +/// a local subnet first, and a host answers ARP requests / neighbor solicitations even if its firewall drops +/// everything else. +/// +public static class IPScannerNeighborCheck +{ + /// + /// Checks the neighbor entry of a host that didn't answer any probe. + /// + /// + /// Neighbor entry of the host (read after its probes were finished), null if there is none. + /// + /// The host is a default gateway of a local network interface. + /// MAC addresses of the default gateways (e.g. "001122AABBCC"). + /// What the entry says about the host. + public static IPScannerNeighborCheckResult Check(NeighborInfo entry, bool isGateway, + IReadOnlyCollection gatewayMACAddresses) + { + // No entry (the neighbor table only contains entries with a MAC address): the host didn't answer the ARP + // request / neighbor solicitation + if (entry == null) + return IPScannerNeighborCheckResult.Unreachable; + + // A router with proxy ARP answers for addresses of other hosts (e.g. every address of the subnet) with its own + // MAC address - not an answer of the host + if (!isGateway && IsProxyARPAnswer(entry.MACAddress.ToString(), gatewayMACAddresses)) + return IPScannerNeighborCheckResult.Unreachable; + + return entry.State switch + { + // Confirmed recently, i.e. when the probes resolved it + NeighborState.Reachable => IPScannerNeighborCheckResult.Reachable, + // Entry from earlier traffic, not verified again yet (Delay / Probe: the verification is still running) + NeighborState.Stale or NeighborState.Delay or NeighborState.Probe => IPScannerNeighborCheckResult.Verify, + // Static entry (says nothing about the host), or the neighbor didn't answer + _ => IPScannerNeighborCheckResult.Unreachable + }; + } + + /// + /// Checks if a MAC address resolved for a host is the MAC address of a default gateway (proxy ARP). + /// + /// Resolved MAC address (e.g. "001122AABBCC"). + /// MAC addresses of the default gateways (e.g. "001122AABBCC"). + /// True if the MAC address is the MAC address of a default gateway. + public static bool IsProxyARPAnswer(string macAddress, IReadOnlyCollection gatewayMACAddresses) + { + foreach (var gatewayMACAddress in gatewayMACAddresses) + { + if (string.Equals(macAddress, gatewayMACAddress, StringComparison.OrdinalIgnoreCase)) + return true; + } + + return false; + } +} diff --git a/Source/NETworkManager.Models/Network/IPScannerNeighborCheckResult.cs b/Source/NETworkManager.Models/Network/IPScannerNeighborCheckResult.cs new file mode 100644 index 0000000000..d60a7f0d52 --- /dev/null +++ b/Source/NETworkManager.Models/Network/IPScannerNeighborCheckResult.cs @@ -0,0 +1,25 @@ +namespace NETworkManager.Models.Network; + +/// +/// Result of : what the neighbor entry (ARP / NDP) of a host that +/// didn't answer any probe says about the host. +/// +public enum IPScannerNeighborCheckResult +{ + /// + /// The host is not reachable (no entry, the neighbor didn't answer, a static entry or a proxy ARP answer). + /// + Unreachable, + + /// + /// The host answered the ARP request / neighbor solicitation of the probes - it is reachable. + /// + Reachable, + + /// + /// The entry is older than the probes and was not verified again yet (e.g. + /// ) - resolve it again to find out (see + /// ). + /// + Verify +} diff --git a/Source/NETworkManager.Models/Network/IPScannerOptions.cs b/Source/NETworkManager.Models/Network/IPScannerOptions.cs index afc1ea555f..e3c8613181 100644 --- a/Source/NETworkManager.Models/Network/IPScannerOptions.cs +++ b/Source/NETworkManager.Models/Network/IPScannerOptions.cs @@ -72,6 +72,12 @@ public record IPScannerOptions /// public required int NetBIOSTimeout { get; init; } + /// + /// Find hosts in a local subnet that didn't answer any probe by their neighbor entry (ARP / NDP), see + /// . + /// + public required bool ARPEnabled { get; init; } + /// /// Resolve the MAC address for the host from ARP. /// diff --git a/Source/NETworkManager.Models/Network/NeighborTable.cs b/Source/NETworkManager.Models/Network/NeighborTable.cs index 2de34aa297..ba24b3cafd 100644 --- a/Source/NETworkManager.Models/Network/NeighborTable.cs +++ b/Source/NETworkManager.Models/Network/NeighborTable.cs @@ -75,6 +75,16 @@ private struct MIB_IPNET_ROW2 [DllImport("Iphlpapi.dll")] private static extern void FreeMibTable(IntPtr memory); + /// + /// Flushes the neighbor entry of Row.Address on the interface of the row and resolves it again (ARP request / + /// neighbor solicitation). Blocks until the neighbor answered or the resolution failed. Returns 0 on success. + /// + [DllImport("Iphlpapi.dll")] + private static extern uint ResolveIpNetEntry2(ref MIB_IPNET_ROW2 row, IntPtr sourceAddress); + + // ResolveIpNetEntry2: the neighbor didn't answer (network name not found) + private const uint ERROR_BAD_NET_NAME = 67; + /// /// Ensures that only one PowerShell pipeline runs on at a time. /// @@ -312,31 +322,84 @@ private static Dictionary BuildInterfaceAliasMap() } /// - /// Returns a snapshot of the neighbor cache (IPv4 and IPv6) as map from IP address to MAC address - /// (e.g. "001122AABBCC"). IPv6 addresses are stored with their scope id (e.g. "fe80::1%3", for an address entered + /// Returns a snapshot of the neighbor cache (IPv4 and IPv6) as map from IP address to the entry (MAC address, state, + /// interface). IPv6 addresses are stored with their scope id (e.g. "fe80::1%3", for an address entered /// with one) and without it (see , for an address entered without one - /// if the address is on several interfaces, the last entry wins). Entries without a MAC address, or with a link-layer /// address that is not a 6 byte MAC address (e.g. the IPv4 address of a tunnel interface), are skipped. /// Reading the table once and looking up many addresses is much cheaper than reading it per address. /// - public static Dictionary GetMACAddressMap() + public static Dictionary GetNeighborMap() { - var map = new Dictionary(); + var map = new Dictionary(); foreach (var entry in GetTable()) { if (entry.MACAddress.GetAddressBytes().Length != 6) continue; - var macAddress = entry.MACAddress.ToString(); - - map[entry.IPAddress] = macAddress; - map[IPAddressHelper.WithoutScopeId(entry.IPAddress)] = macAddress; + map[entry.IPAddress] = entry; + map[IPAddressHelper.WithoutScopeId(entry.IPAddress)] = entry; } return map; } + /// + /// Verifies a neighbor entry: flushes the entry of on the interface and resolves it again + /// (ARP request for IPv4, neighbor solicitation for IPv6) via ResolveIpNetEntry2, e.g. to check if a neighbor + /// whose entry is is still there. Blocks until the neighbor answered or + /// the resolution failed (about 3 seconds) - run it on a thread pool thread and limit how many run at the same time. + /// + /// IP address of the neighbor (IPv4 or IPv6, with the scope id for a link-local address). + /// Index of the interface the neighbor is on (e.g. of its current entry). + /// MAC address (e.g. "001122AABBCC") if the neighbor answered, otherwise null. + public static string ResolveEntry(IPAddress ipAddress, int interfaceIndex) + { + // SOCKADDR_INET: family (2 bytes), IPv4: address at offset 4, IPv6: address at offset 8 and scope id at 24 + var address = new byte[SOCKADDR_INET_SIZE]; + + if (ipAddress.AddressFamily == AddressFamily.InterNetwork) + { + BitConverter.GetBytes(AF_INET).CopyTo(address, 0); + ipAddress.GetAddressBytes().CopyTo(address, 4); + } + else + { + BitConverter.GetBytes(AF_INET6).CopyTo(address, 0); + ipAddress.GetAddressBytes().CopyTo(address, 8); + BitConverter.GetBytes((uint)ipAddress.ScopeId).CopyTo(address, 24); + } + + var row = new MIB_IPNET_ROW2 + { + Address = address, + InterfaceIndex = (uint)interfaceIndex, + PhysicalAddress = new byte[IF_MAX_PHYS_ADDRESS_LENGTH] + }; + + var result = ResolveIpNetEntry2(ref row, IntPtr.Zero); + + if (result != 0) + { + // No answer is the expected result for a neighbor that is gone + if (result != ERROR_BAD_NET_NAME) + Log.Warn($"Could not resolve the neighbor entry of {ipAddress} (interface {interfaceIndex}): " + + new Win32Exception((int)result).Message); + + return null; + } + + if (row.PhysicalAddressLength != 6) + return null; + + var macBytes = new byte[6]; + Buffer.BlockCopy(row.PhysicalAddress, 0, macBytes, 0, 6); + + // An unanswered resolution can return a zero MAC address + return macBytes.All(x => x == 0) ? null : new PhysicalAddress(macBytes).ToString(); + } + /// /// Adds a permanent neighbor entry for with the given /// by running New-NetNeighbor through the shared diff --git a/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs b/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs index e32c860c5e..2cc661ad6e 100644 --- a/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs +++ b/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs @@ -161,6 +161,7 @@ public static class GlobalStaticConfiguration public static int IPScanner_MaxPortThreads => 16; public static bool IPScanner_NetBIOSEnabled => true; public static int IPScanner_NetBIOSTimeout => 2000; + public static bool IPScanner_ARPEnabled => true; public static bool IPScanner_ResolveMACAddress => true; public static bool IPScanner_ExpandHostView => false; public static ExportFileType IPScanner_ExportFileType => ExportFileType.Csv; diff --git a/Source/NETworkManager.Settings/SettingsInfo.cs b/Source/NETworkManager.Settings/SettingsInfo.cs index fc81f19fda..f58cd5a73f 100644 --- a/Source/NETworkManager.Settings/SettingsInfo.cs +++ b/Source/NETworkManager.Settings/SettingsInfo.cs @@ -1351,6 +1351,19 @@ public int IPScanner_PortScanAttempts } } = GlobalStaticConfiguration.IPScanner_PortScanAttempts; + public bool IPScanner_ARPEnabled + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } = GlobalStaticConfiguration.IPScanner_ARPEnabled; + public bool IPScanner_NetBIOSEnabled { get; diff --git a/Source/NETworkManager.Tests/Network/IPScannerNeighborCheckTests.cs b/Source/NETworkManager.Tests/Network/IPScannerNeighborCheckTests.cs new file mode 100644 index 0000000000..527607d9f4 --- /dev/null +++ b/Source/NETworkManager.Tests/Network/IPScannerNeighborCheckTests.cs @@ -0,0 +1,83 @@ +using System.Net; +using System.Net.NetworkInformation; +using System.Net.Sockets; +using NETworkManager.Models.Network; +using Xunit; + +namespace NETworkManager.Tests.Network; + +public class IPScannerNeighborCheckTests +{ + private const string HostMAC = "001122AABBCC"; + private const string GatewayMAC = "00AABBCCDDEE"; + + private static readonly string[] GatewayMACs = [GatewayMAC]; + + private static NeighborInfo CreateEntry(NeighborState state, string macAddress = HostMAC) + { + return new NeighborInfo(IPAddress.Parse("192.168.1.10"), PhysicalAddress.Parse(macAddress), false, 12, + "Ethernet", state, AddressFamily.InterNetwork); + } + + [Fact] + public void Check_NoEntry_IsUnreachable() + { + Assert.Equal(IPScannerNeighborCheckResult.Unreachable, IPScannerNeighborCheck.Check(null, false, GatewayMACs)); + } + + [Theory] + [InlineData(NeighborState.Reachable, IPScannerNeighborCheckResult.Reachable)] + [InlineData(NeighborState.Stale, IPScannerNeighborCheckResult.Verify)] + [InlineData(NeighborState.Delay, IPScannerNeighborCheckResult.Verify)] + [InlineData(NeighborState.Probe, IPScannerNeighborCheckResult.Verify)] + [InlineData(NeighborState.Permanent, IPScannerNeighborCheckResult.Unreachable)] + [InlineData(NeighborState.Incomplete, IPScannerNeighborCheckResult.Unreachable)] + [InlineData(NeighborState.Unreachable, IPScannerNeighborCheckResult.Unreachable)] + public void Check_State(NeighborState state, IPScannerNeighborCheckResult expected) + { + Assert.Equal(expected, IPScannerNeighborCheck.Check(CreateEntry(state), false, GatewayMACs)); + } + + [Theory] + [InlineData(NeighborState.Reachable)] + [InlineData(NeighborState.Stale)] + public void Check_MACOfGateway_IsProxyARP(NeighborState state) + { + Assert.Equal(IPScannerNeighborCheckResult.Unreachable, + IPScannerNeighborCheck.Check(CreateEntry(state, GatewayMAC), false, GatewayMACs)); + } + + [Fact] + public void Check_GatewayItself_IsReachable() + { + Assert.Equal(IPScannerNeighborCheckResult.Reachable, + IPScannerNeighborCheck.Check(CreateEntry(NeighborState.Reachable, GatewayMAC), true, GatewayMACs)); + } + + [Fact] + public void Check_NoGateway_IsReachable() + { + Assert.Equal(IPScannerNeighborCheckResult.Reachable, + IPScannerNeighborCheck.Check(CreateEntry(NeighborState.Reachable), false, [])); + } + + [Theory] + [InlineData("00AABBCCDDEE", true)] + [InlineData("00aabbccddee", true)] + [InlineData("001122AABBCC", false)] + public void IsProxyARPAnswer(string macAddress, bool expected) + { + Assert.Equal(expected, IPScannerNeighborCheck.IsProxyARPAnswer(macAddress, GatewayMACs)); + } + + [Theory] + [InlineData(0, NeighborState.Unreachable)] + [InlineData(4, NeighborState.Stale)] + [InlineData(5, NeighborState.Reachable)] + [InlineData(6, NeighborState.Permanent)] + public void NeighborState_MatchesWindowsValues(uint value, NeighborState expected) + { + // NL_NEIGHBOR_STATE (MIB_IPNET_ROW2.State) starts at 0 (NlnsUnreachable) + Assert.Equal(expected, (NeighborState)value); + } +} diff --git a/Source/NETworkManager/ViewModels/IPScannerSettingsViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerSettingsViewModel.cs index d62177ede4..72ac87ad00 100644 --- a/Source/NETworkManager/ViewModels/IPScannerSettingsViewModel.cs +++ b/Source/NETworkManager/ViewModels/IPScannerSettingsViewModel.cs @@ -235,6 +235,25 @@ public int PortScanAttempts } } + /// + /// Gets or sets a value indicating whether hosts in a local subnet are found by their neighbor entry (ARP / NDP). + /// + public bool ARPEnabled + { + get; + set + { + if (value == field) + return; + + if (!_isLoading) + SettingsManager.Current.IPScanner_ARPEnabled = value; + + field = value; + OnPropertyChanged(); + } + } + /// /// Gets or sets a value indicating whether NetBIOS is enabled. /// @@ -405,6 +424,7 @@ private void LoadSettings() PortScanAttempts = SettingsManager.Current.IPScanner_PortScanAttempts; NetBIOSEnabled = SettingsManager.Current.IPScanner_NetBIOSEnabled; NetBIOSTimeout = SettingsManager.Current.IPScanner_NetBIOSTimeout; + ARPEnabled = SettingsManager.Current.IPScanner_ARPEnabled; ResolveMACAddress = SettingsManager.Current.IPScanner_ResolveMACAddress; MaxHostThreads = SettingsManager.Current.IPScanner_MaxHostThreads; MaxPortThreads = SettingsManager.Current.IPScanner_MaxPortThreads; diff --git a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs index cb5d602f86..5ab9e4f6a3 100644 --- a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs @@ -920,6 +920,7 @@ private static IPScannerOptions CreateScanOptions(int[] ports) PortScanAttempts = SettingsManager.Current.IPScanner_PortScanAttempts, NetBIOSEnabled = SettingsManager.Current.IPScanner_NetBIOSEnabled, NetBIOSTimeout = SettingsManager.Current.IPScanner_NetBIOSTimeout, + ARPEnabled = SettingsManager.Current.IPScanner_ARPEnabled, ResolveMACAddress = SettingsManager.Current.IPScanner_ResolveMACAddress, ShowAllResults = SettingsManager.Current.IPScanner_ShowAllResults, ReportAllHosts = false diff --git a/Source/NETworkManager/Views/IPScannerSettingsView.xaml b/Source/NETworkManager/Views/IPScannerSettingsView.xaml index 88ea3c7339..1326d75918 100644 --- a/Source/NETworkManager/Views/IPScannerSettingsView.xaml +++ b/Source/NETworkManager/Views/IPScannerSettingsView.xaml @@ -109,6 +109,18 @@ Margin="0,0,0,10" /> + + + + + + - - - - - - - + + + + + + + + + + + + + + + + + + + private const int CancellationCheckInterval = 65536; + /// + /// Prefix of an input that excludes IP addresses from the other inputs (e.g. "10.0.0.0/24; !10.0.0.1"). + /// + public const char ExclusionPrefix = '!'; + + /// + /// Checks if an input excludes IP addresses (e.g. "!10.0.0.1"), see . + /// + /// Input like "10.0.0.1" or "!10.0.0.1" (see ). + /// True if the input is an exclusion. + public static bool IsExclusion(string host) + { + return host.StartsWith(ExclusionPrefix); + } + /// /// Create a list of hosts from a string input like "10.0.0.1; example.com; 10.0.0.0/24". /// Inputs can also be separated by newlines (e.g. pasted from Excel, one host/range per line). @@ -120,8 +135,8 @@ private static List> GetIPv4SpecialRangeOctets(string host) /// /// Gets the number of IP addresses of the inputs without expanding the ranges or resolving the hostnames - /// (a hostname counts as 1, overlapping inputs are counted multiple times). Used to warn about (or refuse) - /// very large scans before any memory is allocated. + /// (a hostname counts as 1, overlapping inputs are counted multiple times, exclusions are ignored). Used to warn + /// about (or refuse) very large scans before any memory is allocated. /// /// Inputs like "10.0.0.1", "example.com", "10.0.0.0/24" (see ). /// Number of IP addresses. @@ -131,6 +146,10 @@ public static long GetHostCount(IEnumerable hosts) foreach (var host in hosts) { + // Every address of the other inputs is expanded before the exclusions are applied + if (IsExclusion(host)) + continue; + switch (host) { // 192.168.0.1 @@ -194,7 +213,9 @@ private static long GetIPv4RangeCount(IPAddress start, IPAddress end) /// /// Resolves the inputs (IP addresses, ranges, subnets, hostnames) into a list of hosts. The list is sorted /// by IP address and contains every IP address only once (e.g. overlapping ranges), see - /// . + /// . IP addresses of an exclusion (e.g. "!10.0.0.1", "!10.0.0.0/28", + /// "!server.example.com", see ) are removed - exclusions are not expanded, so a + /// large excluded range doesn't need any memory. /// /// Inputs like "10.0.0.1", "example.com", "10.0.0.0/24" (see ). /// Prefer the A record over the AAAA record for hostnames. @@ -209,7 +230,10 @@ private static long GetIPv4RangeCount(IPAddress start, IPAddress end) ResolveAsync(IEnumerable hosts, bool dnsResolveHostnamePreferIPv4, bool keepAllHostnames, CancellationToken cancellationToken) { - var inputs = hosts.ToList(); + var allInputs = hosts.ToList(); + + var inputs = allInputs.Where(x => !IsExclusion(x)).ToList(); + var exclusionInputs = allInputs.Where(IsExclusion).Select(x => x[1..]).ToList(); // Results per input (index = position in the input) to keep the input order var hostsPerInput = new List<(IPAddress ipAddress, string hostname)>[inputs.Count]; @@ -306,8 +330,149 @@ await Parallel.ForEachAsync(Enumerable.Range(0, inputs.Count), } }).ConfigureAwait(false); - return (SortAndRemoveDuplicates(hostsPerInput, keepAllHostnames), - [.. hostnameNotResolvedPerInput.Where(x => x != null)]); + // Exclusions as checks (index = position in the input), null if the hostname could not be resolved + var exclusions = new Func[exclusionInputs.Count]; + var exclusionHostnameNotResolved = new string[exclusionInputs.Count]; + + await Parallel.ForEachAsync(Enumerable.Range(0, exclusionInputs.Count), + new ParallelOptions { CancellationToken = cancellationToken }, + async (index, ct) => + { + exclusions[index] = await CreateExclusionAsync(exclusionInputs[index], dnsResolveHostnamePreferIPv4, + ct).ConfigureAwait(false); + + if (exclusions[index] == null) + exclusionHostnameNotResolved[index] = exclusionInputs[index].Split('/')[0]; + }).ConfigureAwait(false); + + var result = SortAndRemoveDuplicates(hostsPerInput, keepAllHostnames); + + var activeExclusions = exclusions.Where(x => x != null).ToList(); + + if (activeExclusions.Count > 0) + result = [.. result.Where(host => !activeExclusions.Any(isExcluded => isExcluded(host.ipAddress)))]; + + return (result, + [.. hostnameNotResolvedPerInput.Concat(exclusionHostnameNotResolved).Where(x => x != null)]); + } + + /// + /// Creates the check of an exclusion (input without ): IP address, subnet, range or + /// hostname (resolved like the other inputs). An IPv6 address without scope id also excludes the address with + /// any scope id (e.g. "fe80::1" excludes "fe80::1%3"). + /// + /// Input like "10.0.0.1", "10.0.0.0/28", "10.0.0.200-254" or "server.example.com". + /// Prefer the A record over the AAAA record for hostnames. + /// Token to monitor for cancellation requests. + /// Check if an IP address is excluded, null if the hostname could not be resolved. + private static async Task> CreateExclusionAsync(string host, + bool dnsResolveHostnamePreferIPv4, CancellationToken cancellationToken) + { + switch (host) + { + // 192.168.0.1 or 2001:db8:85a3::8a2e:370:7334 + case var _ when RegexHelper.IPv4AddressRegex().IsMatch(host): + case var _ when Regex.IsMatch(host, RegexHelper.IPv6AddressRegex): + return CreateIPAddressExclusion(IPAddress.Parse(host)); + + // 192.168.0.0/24 or 192.168.0.0/255.255.255.0 + case var _ when RegexHelper.IPv4AddressCidrRegex().IsMatch(host): + case var _ when RegexHelper.IPv4AddressSubnetmaskRegex().IsMatch(host): + return CreateIPv4RangeExclusion(IPNetwork2.Parse(host)); + + // 192.168.0.1-100 + case var _ when RegexHelper.IPv4AddressShortRangeRegex().IsMatch(host): + var shortRange = host.Split('-'); + var shortBase = shortRange[0][..shortRange[0].LastIndexOf('.')]; + + return CreateIPv4RangeExclusion(IPAddress.Parse(shortRange[0]), + IPAddress.Parse($"{shortBase}.{shortRange[1]}")); + + // 192.168.0.0 - 192.168.0.100 + case var _ when RegexHelper.IPv4AddressRangeRegex().IsMatch(host): + var range = host.Split('-'); + + return CreateIPv4RangeExclusion(IPAddress.Parse(range[0]), IPAddress.Parse(range[1])); + + // 192.168.[50-100].1 + case var _ when RegexHelper.IPv4AddressSpecialRangeRegex().IsMatch(host): + var octets = GetIPv4SpecialRangeOctets(host).Select(x => x.ToHashSet()).ToArray(); + + return ipAddress => + { + if (ipAddress.AddressFamily != AddressFamily.InterNetwork) + return false; + + var bytes = ipAddress.GetAddressBytes(); + + return octets[0].Contains(bytes[0]) && octets[1].Contains(bytes[1]) && + octets[2].Contains(bytes[2]) && octets[3].Contains(bytes[3]); + }; + + // example.com + case var _ when RegexHelper.HostnameOrDomainRegex().IsMatch(host): + var dnsResult = await DNSClientHelper.ResolveAorAaaaAsync(host, dnsResolveHostnamePreferIPv4) + .WaitAsync(cancellationToken).ConfigureAwait(false); + + return dnsResult.HasError ? null : CreateIPAddressExclusion(IPAddress.Parse($"{dnsResult.Value}")); + + // example.com/24 or example.com/255.255.255.128 (only IPv4, like the inputs) + case var _ when Regex.IsMatch(host, RegexHelper.HostnameOrDomainWithCidrRegex): + case var _ when Regex.IsMatch(host, RegexHelper.HostnameOrDomainWithSubnetmaskRegex): + var hostAndSubnet = host.Split('/'); + + var dnsResultWithSubnet = await DNSClientHelper.ResolveAorAaaaAsync(hostAndSubnet[0], true) + .WaitAsync(cancellationToken).ConfigureAwait(false); + + if (dnsResultWithSubnet.HasError || + dnsResultWithSubnet.Value.AddressFamily != AddressFamily.InterNetwork) + return null; + + return CreateIPv4RangeExclusion(IPNetwork2.Parse($"{dnsResultWithSubnet.Value}/{hostAndSubnet[1]}")); + + // Not a valid input (rejected by the validation of the input) - excludes nothing + default: + return _ => false; + } + } + + /// + /// Creates the check of an excluded IP address. An IPv6 address without scope id also excludes the address + /// with any scope id. + /// + private static Func CreateIPAddressExclusion(IPAddress excludedIPAddress) + { + if (excludedIPAddress.AddressFamily == AddressFamily.InterNetworkV6 && excludedIPAddress.ScopeId == 0) + return ipAddress => IPAddressHelper.WithoutScopeId(ipAddress).Equals(excludedIPAddress); + + return ipAddress => ipAddress.Equals(excludedIPAddress); + } + + /// + /// Creates the check of an excluded IPv4 subnet (incl. network and broadcast address). + /// + private static Func CreateIPv4RangeExclusion(IPNetwork2 network) + { + return CreateIPv4RangeExclusion(network.Network, network.Broadcast); + } + + /// + /// Creates the check of an excluded IPv4 range [, ]. + /// + private static Func CreateIPv4RangeExclusion(IPAddress start, IPAddress end) + { + var from = IPv4Address.ToUInt32(start); + var to = IPv4Address.ToUInt32(end); + + return ipAddress => + { + if (ipAddress.AddressFamily != AddressFamily.InterNetwork) + return false; + + var value = IPv4Address.ToUInt32(ipAddress); + + return value >= from && value <= to; + }; } /// diff --git a/Source/NETworkManager.Models/RunCommand/RunCommandTarget.cs b/Source/NETworkManager.Models/RunCommand/RunCommandTarget.cs index 4c32588196..aefdbfe945 100644 --- a/Source/NETworkManager.Models/RunCommand/RunCommandTarget.cs +++ b/Source/NETworkManager.Models/RunCommand/RunCommandTarget.cs @@ -2,6 +2,7 @@ using System.Linq; using System.Net; using System.Text.RegularExpressions; +using NETworkManager.Models.Network; using NETworkManager.Utilities; namespace NETworkManager.Models.RunCommand; @@ -53,13 +54,16 @@ public static RunCommandTargetKind GetKind(string target) if (RegexHelper.ASNumberWithPrefixRegex().IsMatch(target)) return RunCommandTargetKind.ASN; - // Several hosts, IP ranges or subnets + // Several hosts, IP ranges or subnets, optionally with exclusions (e.g. "10.0.0.0/24;!10.0.0.1") if (target.Contains(';')) { - var kinds = target.Split(';', StringSplitOptions.RemoveEmptyEntries).Select(GetKind).ToList(); + var entries = target.Split(';', StringSplitOptions.RemoveEmptyEntries); - if (kinds.Count == 0 || kinds.Any(x => x is not (RunCommandTargetKind.Host or RunCommandTargetKind.Hostname - or RunCommandTargetKind.IPRange or RunCommandTargetKind.Subnet))) + var kinds = entries.Where(x => !HostRangeHelper.IsExclusion(x)).Select(GetKind).ToList(); + var exclusionKinds = entries.Where(HostRangeHelper.IsExclusion).Select(x => GetKind(x[1..])).ToList(); + + if (kinds.Count == 0 || kinds.Concat(exclusionKinds).Any(x => x is not (RunCommandTargetKind.Host + or RunCommandTargetKind.Hostname or RunCommandTargetKind.IPRange or RunCommandTargetKind.Subnet))) return RunCommandTargetKind.None; // Only host names without a domain (e.g. "foo;" or "a;b" from copied text) can also be any words, like a @@ -67,8 +71,9 @@ public static RunCommandTargetKind GetKind(string target) if (kinds.All(x => x == RunCommandTargetKind.Hostname)) return RunCommandTargetKind.None; - // With an IP range or subnet, it is a range for the scanners (e.g. not for a DNS lookup). - return kinds.Any(x => x is RunCommandTargetKind.IPRange or RunCommandTargetKind.Subnet) + // With an IP range, subnet or exclusion, it is a range for the scanners (e.g. not for a DNS lookup). + return exclusionKinds.Count > 0 || + kinds.Any(x => x is RunCommandTargetKind.IPRange or RunCommandTargetKind.Subnet) ? RunCommandTargetKind.IPRange : RunCommandTargetKind.Hosts; } diff --git a/Source/NETworkManager.Tests/Network/HostRangeHelperExclusionTests.cs b/Source/NETworkManager.Tests/Network/HostRangeHelperExclusionTests.cs new file mode 100644 index 0000000000..235d2379f2 --- /dev/null +++ b/Source/NETworkManager.Tests/Network/HostRangeHelperExclusionTests.cs @@ -0,0 +1,57 @@ +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using NETworkManager.Models.Network; +using Xunit; + +namespace NETworkManager.Tests.Network; + +public class HostRangeHelperExclusionTests +{ + private static async Task ResolveAsync(string input) + { + var (hosts, _) = await HostRangeHelper.ResolveAsync(HostRangeHelper.CreateListFromInput(input), true, false, + CancellationToken.None); + + return [.. hosts.Select(x => x.ipAddress.ToString())]; + } + + [Theory] + [InlineData("10.0.0.0/29; !10.0.0.1", "10.0.0.0,10.0.0.2,10.0.0.3,10.0.0.4,10.0.0.5,10.0.0.6,10.0.0.7")] + [InlineData("10.0.0.0/29; !10.0.0.0/30", "10.0.0.4,10.0.0.5,10.0.0.6,10.0.0.7")] + [InlineData("10.0.0.0/29; !10.0.0.0/255.255.255.252", "10.0.0.4,10.0.0.5,10.0.0.6,10.0.0.7")] + [InlineData("10.0.0.0/29; !10.0.0.2-5", "10.0.0.0,10.0.0.1,10.0.0.6,10.0.0.7")] + [InlineData("10.0.0.0/29; !10.0.0.2 - 10.0.0.5", "10.0.0.0,10.0.0.1,10.0.0.6,10.0.0.7")] + [InlineData("10.0.0.0/29; !10.0.0.[0,7]", "10.0.0.1,10.0.0.2,10.0.0.3,10.0.0.4,10.0.0.5,10.0.0.6")] + [InlineData("!10.0.0.1; 10.0.0.1-3", "10.0.0.2,10.0.0.3")] + [InlineData("10.0.0.1-3; !10.0.0.0/8", "")] + [InlineData("10.0.0.1-3; !192.168.0.1", "10.0.0.1,10.0.0.2,10.0.0.3")] + [InlineData("2001:db8::1; 2001:db8::2; !2001:db8::1", "2001:db8::2")] + public async Task ResolveAsync_Exclusions(string input, string expected) + { + Assert.Equal(expected, string.Join(",", await ResolveAsync(input))); + } + + [Fact] + public async Task ResolveAsync_OnlyExclusions_NoHosts() + { + Assert.Empty(await ResolveAsync("!10.0.0.1")); + } + + [Theory] + [InlineData("10.0.0.0/24; !10.0.0.1", 256)] + [InlineData("10.0.0.0/24; !10.0.0.0/8", 256)] + [InlineData("!10.0.0.0/8", 0)] + public void GetHostCount_IgnoresExclusions(string input, long expected) + { + Assert.Equal(expected, HostRangeHelper.GetHostCount(HostRangeHelper.CreateListFromInput(input))); + } + + [Theory] + [InlineData("!10.0.0.1", true)] + [InlineData("10.0.0.1", false)] + public void IsExclusion(string input, bool expected) + { + Assert.Equal(expected, HostRangeHelper.IsExclusion(input)); + } +} diff --git a/Source/NETworkManager.Tests/RunCommand/RunCommandTargetTests.cs b/Source/NETworkManager.Tests/RunCommand/RunCommandTargetTests.cs index 32ad9bc0ce..618eafbe75 100644 --- a/Source/NETworkManager.Tests/RunCommand/RunCommandTargetTests.cs +++ b/Source/NETworkManager.Tests/RunCommand/RunCommandTargetTests.cs @@ -17,6 +17,10 @@ public class RunCommandTargetTests [InlineData("10.0.0.1-10.0.0.100", RunCommandTargetKind.IPRange)] [InlineData("10.0.0.1-100", RunCommandTargetKind.IPRange)] [InlineData("10.0.[0-1].1", RunCommandTargetKind.IPRange)] + // Exclusions (e.g. "!10.0.0.1") make a list a range for the scanners + [InlineData("10.0.0.0/24;!10.0.0.1", RunCommandTargetKind.IPRange)] + [InlineData("10.0.0.1;10.0.0.2;!10.0.0.2", RunCommandTargetKind.IPRange)] + [InlineData("server.borntoberoot.net;!10.0.0.1", RunCommandTargetKind.IPRange)] [InlineData("10.0.0.0/24", RunCommandTargetKind.Subnet)] [InlineData("10.0.0.0/255.255.255.0", RunCommandTargetKind.Subnet)] [InlineData("2001:db8::/32", RunCommandTargetKind.Subnet)] @@ -68,6 +72,11 @@ public void HostsWithHostnames(string target) // Only host names without a domain can be any words (e.g. copied text) [InlineData("server01;server02")] [InlineData("foo;")] + [InlineData("foo;!bar")] + // Only exclusions, an invalid exclusion + [InlineData("!10.0.0.1")] + [InlineData("!10.0.0.1;!10.0.0.2")] + [InlineData("10.0.0.0/24;!server_01")] // Ports only from 1 to 65535 [InlineData("0")] [InlineData("99999")] diff --git a/Source/NETworkManager.Validators/MultipleHostsRangeValidator.cs b/Source/NETworkManager.Validators/MultipleHostsRangeValidator.cs index 2615fbb17c..cd20a17aba 100644 --- a/Source/NETworkManager.Validators/MultipleHostsRangeValidator.cs +++ b/Source/NETworkManager.Validators/MultipleHostsRangeValidator.cs @@ -16,13 +16,22 @@ public override ValidationResult Validate(object value, CultureInfo cultureInfo) { var isValid = true; + // At least one input that is not an exclusion (e.g. not only "!10.0.0.1") + var hasInclusion = false; + if (value == null) return new ValidationResult(false, Strings.EnterValidIPScanRange); - foreach (var ipHostOrRange in ((string)value) + foreach (var input in ((string)value) .Replace(" ", "") .Split([';', '\r', '\n'], StringSplitOptions.RemoveEmptyEntries)) { + // Exclusion (e.g. "!10.0.0.1"), validated like the other inputs + var ipHostOrRange = HostRangeHelper.IsExclusion(input) ? input[1..] : input; + + if (!HostRangeHelper.IsExclusion(input)) + hasInclusion = true; + // 192.168.0.1 if (RegexHelper.IPv4AddressRegex().IsMatch(ipHostOrRange)) continue; @@ -104,7 +113,7 @@ public override ValidationResult Validate(object value, CultureInfo cultureInfo) isValid = false; } - return isValid + return isValid && hasInclusion ? ValidationResult.ValidResult : new ValidationResult(false, Strings.EnterValidIPScanRange); } diff --git a/Website/docs/application/ip-scanner.md b/Website/docs/application/ip-scanner.md index 255816b315..973dd324e8 100644 --- a/Website/docs/application/ip-scanner.md +++ b/Website/docs/application/ip-scanner.md @@ -32,11 +32,14 @@ With the **IP Scanner** you can scan for active devices based on the hostname or | `borntoberoot.net` | Single IP address resolved from a host (`10.0.0.1`) | | `borntoberoot.net/24` | All IP addresses in a subnet resolved from a host (`10.0.0.0`, ..., `10.0.0.255`) | | `borntoberoot.net/255.255.255.0` | All IP addresses in a subnet resolved from a host (`10.0.0.0`, ..., `10.0.0.255`) | +| `!10.0.0.1` | Excludes an IP address (also a range, subnet or host, e.g. `!10.0.0.0/28`) from the other inputs| :::note Multiple inputs can be combined with a semicolon (`;`). A column pasted from Excel (one entry per line) is converted to the semicolon-separated form automatically. +An input starting with `!` excludes its IP addresses from the other inputs, e.g. `10.0.0.0/24; !10.0.0.1; !10.0.0.200-254` (all formats of the table above, a hostname is resolved first). At least one input without `!` is required. + Example: `10.0.0.0/24; 10.0.[10-20].1; 10.0.0.100-199` IP addresses that are part of multiple inputs (e.g. `10.0.0.1; 10.0.0.0/24`) or entered with different hostnames (e.g. two names of one server) are scanned only once. The hostname is resolved via DNS (`PTR`) or NetBIOS, not taken from the input. The network and broadcast address of a subnet are scanned as well. diff --git a/Website/docs/application/ping-monitor.md b/Website/docs/application/ping-monitor.md index 5932d56444..bd0576a8a7 100644 --- a/Website/docs/application/ping-monitor.md +++ b/Website/docs/application/ping-monitor.md @@ -29,11 +29,14 @@ ICMP (Internet Control Message Protocol) is a network-layer protocol used to sen | `borntoberoot.net` | Single IP address resolved from a host (`10.0.0.1`) | | `borntoberoot.net/24` | All IP addresses in a subnet resolved from a host (`10.0.0.0`, ..., `10.0.0.255`) | | `borntoberoot.net/255.255.255.0` | All IP addresses in a subnet resolved from a host (`10.0.0.0`, ..., `10.0.0.255`) | +| `!10.0.0.1` | Excludes an IP address (also a range, subnet or host, e.g. `!10.0.0.0/28`) from the other inputs| :::note Multiple inputs can be combined with a semicolon (`;`). A column pasted from Excel (one entry per line) is converted to the semicolon-separated form automatically. +An input starting with `!` excludes its IP addresses from the other inputs, e.g. `10.0.0.0/24; !10.0.0.1; !10.0.0.200-254` (all formats of the table above, a hostname is resolved first). At least one input without `!` is required. + Example: `10.0.0.0/24; 10.0.[10-20].1; 10.0.0.100-199` ::: diff --git a/Website/docs/application/port-scanner.md b/Website/docs/application/port-scanner.md index 8e6f65eb78..cfe55084e5 100644 --- a/Website/docs/application/port-scanner.md +++ b/Website/docs/application/port-scanner.md @@ -38,6 +38,7 @@ TCP (Transmission Control Protocol) is a connection-oriented transport-layer pro | `borntoberoot.net` | Single IP address resolved from a host (`10.0.0.1`) | | `borntoberoot.net/24` | All IP addresses in a subnet resolved from a host (`10.0.0.0`, ..., `10.0.0.255`) | | `borntoberoot.net/255.255.255.0` | All IP addresses in a subnet resolved from a host (`10.0.0.0`, ..., `10.0.0.255`) | +| `!10.0.0.1` | Excludes an IP address (also a range, subnet or host, e.g. `!10.0.0.0/28`) from the other inputs| | Port | Description | | --------------------- | -------------------------------------------------- | @@ -49,6 +50,8 @@ TCP (Transmission Control Protocol) is a connection-oriented transport-layer pro Multiple inputs can be combined with a semicolon (`;`). A column pasted from Excel (one entry per line) is converted to the semicolon-separated form automatically. +An input starting with `!` excludes its IP addresses from the other inputs, e.g. `10.0.0.0/24; !10.0.0.1; !10.0.0.200-254` (all formats of the table above, a hostname is resolved first). At least one input without `!` is required. + Example: `10.0.0.0/24; 10.0.[10-20].1; 10.0.0.100-199` or `1-1024; 8080; 8443` IP addresses and ports that are part of multiple inputs (e.g. `10.0.0.1; 10.0.0.0/24` or `80; 1-100`) are scanned only once, also an IP address entered with different hostnames (e.g. two names of one server). The hostname is resolved via DNS (`PTR`), not taken from the input. diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index 31a293fa34..ecb3103ade 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -91,6 +91,7 @@ Release date: **xx.xx.2026** - New **Ping hosts** setting (default on) to scan without ping, e.g. if ICMP is blocked or rate limited in the network: hosts are found by their ports or NetBIOS answer, and the scan is faster because an unreachable host doesn't wait for the ping attempts. At least one of ping, port scan or NetBIOS must be enabled. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) - New **Find hosts in the local subnet** setting (default on): hosts in a local subnet that don't answer the ping, a port or NetBIOS (e.g. a Windows client in a public network, a phone or an IoT device with a firewall that drops everything) are found by their ARP / NDP entry, which Windows creates when the probes resolve the MAC address of the host. No additional packets are sent, except to check an entry from earlier traffic again. Answers with the MAC address of the default gateway (proxy ARP) are ignored. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) - The **Detect local subnet** button opens a menu: **Detect local subnet** (as before), **Subnet of network interface** to select the IPv4 subnet of a specific network interface (e.g. LAN, Wi-Fi, VPN, Hyper-V) and **All local subnets** to scan the subnets of all network interfaces. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) +- IP addresses can be excluded from the scan with `!` in the host input (also in profiles and the run command), e.g. `10.0.0.0/24; !10.0.0.1; !10.0.0.200-254`. Works with IP addresses, ranges, subnets and hostnames. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) **Port Scanner** @@ -120,6 +121,7 @@ Release date: **xx.xx.2026** - Groups: new **Ports** setting (ports, port ranges and port profiles) for the Port Scanner, used by the profiles of the group that don't override them (new **Ports** checkbox in the profile, like the other settings that can be set in the group). Existing profiles with ports keep them (the checkbox is enabled when the profile file is loaded). [#3667](https://github.com/BornToBeRoot/NETworkManager/pull/3667) - The export runs in the background, so the window stays responsive while a large result is exported, and uses the order shown in the result (sorting, grouping) instead of the order in which the ports were scanned. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) - Ports are started one after another (every 1 ms, shorter with short timeouts and many threads) instead of all at the same moment. This avoids bursts of hundreds of connection requests within a few milliseconds and makes the progress smoother. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) +- IP addresses can be excluded from the scan with `!` in the host input (also in profiles and the run command), e.g. `10.0.0.0/24; !10.0.0.1; !10.0.0.200-254`. Works with IP addresses, ranges, subnets and hostnames. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) **Ping Monitor** @@ -129,6 +131,7 @@ Release date: **xx.xx.2026** - Reworked each monitored host's card: the collapsed quick info now shows labeled values (`Received: X · Lost: Y · Packet loss: Z%`) instead of unlabeled numbers, the expanded view shows only the latency chart (with more room, since Hostname/IP address are already visible in the header) and the last status change time moved to a tooltip on the connectivity icon. The **Status change** field was also renamed to **Last status change**. [#3572](https://github.com/BornToBeRoot/NETworkManager/pull/3572) - Host input now accepts newline-separated hosts (one per line, e.g. a column pasted from Excel), converted automatically to the semicolon-separated form. [#3568](https://github.com/BornToBeRoot/NETworkManager/pull/3568) - Host input now supports shorthand IPv4 ranges like `192.168.0.1-100`, in addition to the existing `192.168.0.0-192.168.0.100` and `192.168.[0-100].1` range formats. [#3568](https://github.com/BornToBeRoot/NETworkManager/pull/3568) +- IP addresses can be excluded with `!` in the host input (also in profiles and the run command), e.g. `10.0.0.0/24; !10.0.0.1; !10.0.0.200-254`. Works with IP addresses, ranges, subnets and hostnames. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) **Traceroute** diff --git a/Website/docs/run-command.md b/Website/docs/run-command.md index bc2348226e..bbab7d4110 100644 --- a/Website/docs/run-command.md +++ b/Website/docs/run-command.md @@ -78,7 +78,7 @@ Type only the target (e.g. `10.0.0.1`) to get the commands that accept it, e.g. | AS number (`AS13335`) | RDAP | | URL (`https://...`) | Web Console | -A command always has priority (e.g. `web` is the Web Console). A list (e.g. `10.0.0.1; server01`) is a target if at least one entry is an IP address, a host with a domain, a range or a subnet; with a range or subnet, it is handled like an IP range. Ports are `1` to `65535`. A host without a domain (e.g. `server01`) can also be a mistyped command, so its commands are only suggested if no command starts with the word (e.g. not for `pi`), and none is selected: select one with the arrow keys. +A command always has priority (e.g. `web` is the Web Console). A list (e.g. `10.0.0.1; server01`) is a target if at least one entry is an IP address, a host with a domain, a range or a subnet; with a range or subnet, it is handled like an IP range. A list with an exclusion (e.g. `10.0.0.0/24; !10.0.0.1`, see the IP Scanner) is an IP range as well. Ports are `1` to `65535`. A host without a domain (e.g. `server01`) can also be a mistyped command, so its commands are only suggested if no command starts with the word (e.g. not for `pi`), and none is selected: select one with the arrow keys. **Clipboard:** If enabled in [Settings > General](./settings/general.md#suggest-the-clipboard-content-as-target), the commands for a target in the clipboard (e.g. a copied IP address) are suggested when the dialog is opened (up to 3), and `Enter` runs the first one. While typing the command, the commands for the clipboard that start with it are suggested (e.g. `pi` → `ping 10.0.0.1`). The clipboard is read when the dialog is opened; a single word (host without a domain) is not suggested. Disabled by default. From d10ea632a455bd4c4358232044e42a91e82c5eb3 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:50:15 +0100 Subject: [PATCH 008/131] Feature: IP Scanner: OS guess from TTL (B1) New column OS (guess): operating system guessed from the TTL of the ping reply (initial TTL 64 = Linux / Unix, 128 = Windows, 255 = network device; hops in the tooltip), computed from the existing ping result (no traffic). Setting IPScanner_OSGuessEnabled ("Guess the operating system from the TTL", default on) in the Ping section, column via IPScannerResultOptions. Search, Copy and export (OSGuess, last column). IPScannerOSGuessHelper + unit tests. Co-Authored-By: Claude Opus 5.5 --- .../IPScannerOSGuessToStringConverter.cs | 63 +++++++++++++++++++ .../Resources/Strings.Designer.cs | 63 +++++++++++++++++++ .../Resources/Strings.resx | 21 +++++++ .../Export/ExportManager.IPScannerHostInfo.cs | 25 ++++++-- .../Network/IPScannerHostInfo.cs | 23 +++++++ .../Network/IPScannerOSGuess.cs | 27 ++++++++ .../Network/IPScannerOSGuessHelper.cs | 28 +++++++++ .../Network/IPScannerOptions.cs | 5 ++ .../Network/IPScannerResultOptions.cs | 4 +- .../GlobalStaticConfiguration.cs | 1 + .../NETworkManager.Settings/SettingsInfo.cs | 13 ++++ .../Network/IPScannerOSGuessHelperTests.cs | 35 +++++++++++ .../ViewModels/IPScannerSettingsViewModel.cs | 20 ++++++ .../ViewModels/IPScannerViewModel.cs | 6 ++ .../Views/IPScannerSettingsView.xaml | 13 +++- .../NETworkManager/Views/IPScannerView.xaml | 50 +++++++++++++++ Website/docs/application/ip-scanner.md | 22 ++++++- Website/docs/changelog/next-release.md | 1 + 18 files changed, 413 insertions(+), 7 deletions(-) create mode 100644 Source/NETworkManager.Converters/IPScannerOSGuessToStringConverter.cs create mode 100644 Source/NETworkManager.Models/Network/IPScannerOSGuess.cs create mode 100644 Source/NETworkManager.Models/Network/IPScannerOSGuessHelper.cs create mode 100644 Source/NETworkManager.Tests/Network/IPScannerOSGuessHelperTests.cs diff --git a/Source/NETworkManager.Converters/IPScannerOSGuessToStringConverter.cs b/Source/NETworkManager.Converters/IPScannerOSGuessToStringConverter.cs new file mode 100644 index 0000000000..31b7a36850 --- /dev/null +++ b/Source/NETworkManager.Converters/IPScannerOSGuessToStringConverter.cs @@ -0,0 +1,63 @@ +using System; +using System.Globalization; +using System.Windows.Data; +using NETworkManager.Localization.Resources; +using NETworkManager.Models.Network; + +namespace NETworkManager.Converters; + +/// +/// Convert the and the hops of a host to a translated . +/// +public sealed class IPScannerOSGuessToStringConverter : IMultiValueConverter +{ + /// + /// Convert the and the hops of a host to a translated . + /// + /// and the hops (). + /// + /// "ToolTip" to get the tooltip ("Based on the TTL of the ping reply (2 hops)"). + /// + /// Translated operating system ("-/-" without guess), or the tooltip (null without guess). + public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture) + { + var osGuess = values.Length > 0 && values[0] is IPScannerOSGuess value ? value : IPScannerOSGuess.None; + + if (parameter as string != "ToolTip") + return osGuess == IPScannerOSGuess.None ? "-/-" : ToLocalizedString(osGuess); + + if (osGuess == IPScannerOSGuess.None) + return null; + + return string.Format(Strings.OSGuessBasedOnTheTTLXHops, values.Length > 1 && values[1] is int hops ? hops : 0); + } + + /// + /// Gets the translated name of a guessed operating system. + /// + /// Guessed operating system. + /// Translated name, empty without guess. + public static string ToLocalizedString(IPScannerOSGuess osGuess) + { + return osGuess switch + { + IPScannerOSGuess.LinuxUnix => Strings.LinuxUnix, + IPScannerOSGuess.Windows => Strings.Windows, + IPScannerOSGuess.NetworkDevice => Strings.NetworkDevice, + _ => string.Empty + }; + } + + /// + /// !!! Method not implemented !!! + /// + /// + /// + /// + /// + /// + public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} diff --git a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs index 7f28fb2ded..b077cd3f24 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs +++ b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs @@ -5797,6 +5797,15 @@ public static string Groups { } } + /// + /// Looks up a localized string similar to Guess the operating system from the TTL. + /// + public static string GuessTheOperatingSystemFromTheTTL { + get { + return ResourceManager.GetString("GuessTheOperatingSystemFromTheTTL", resourceCulture); + } + } + /// /// Looks up a localized string similar to Handle. /// @@ -5984,6 +5993,15 @@ public static string HelpMessage_FirewallShowAllRules { } } + /// + /// Looks up a localized string similar to Guess the operating system from the TTL of the ping reply: every operating system starts with a typical TTL, 64 (Linux, Unix, macOS, Android, iOS), 128 (Windows) or 255 (network devices like routers, switches and printers), and every router on the way reduces it by one. No additional packets are sent. It's a hint, not a detection: the TTL can be changed on the host, NAT and VPN can change it. Not available for IPv6 (the ping reply has no TTL).. + /// + public static string HelpMessage_GuessTheOperatingSystemFromTheTTL { + get { + return ResourceManager.GetString("HelpMessage_GuessTheOperatingSystemFromTheTTL", resourceCulture); + } + } + /// /// Looks up a localized string similar to Number of backups that are retained before the oldest one is deleted.. /// @@ -7437,6 +7455,15 @@ public static string Lime { } } + /// + /// Looks up a localized string similar to Linux / Unix. + /// + public static string LinuxUnix { + get { + return ResourceManager.GetString("LinuxUnix", resourceCulture); + } + } + /// /// Looks up a localized string similar to Listeners. /// @@ -8211,6 +8238,15 @@ public static string NetworkConnectionType { } } + /// + /// Looks up a localized string similar to Network device. + /// + public static string NetworkDevice { + get { + return ResourceManager.GetString("NetworkDevice", resourceCulture); + } + } + /// /// Looks up a localized string similar to Network Interface. /// @@ -8950,6 +8986,24 @@ public static string Organization { } } + /// + /// Looks up a localized string similar to OS (guess). + /// + public static string OSGuess { + get { + return ResourceManager.GetString("OSGuess", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Based on the TTL of the ping reply ({0} hops). + /// + public static string OSGuessBasedOnTheTTLXHops { + get { + return ResourceManager.GetString("OSGuessBasedOnTheTTLXHops", resourceCulture); + } + } + /// /// Looks up a localized string similar to OUI. /// @@ -14911,6 +14965,15 @@ public static string Window { } } + /// + /// Looks up a localized string similar to Windows. + /// + public static string Windows { + get { + return ResourceManager.GetString("Windows", resourceCulture); + } + } + /// /// Looks up a localized string similar to Windows DNS settings. /// diff --git a/Source/NETworkManager.Localization/Resources/Strings.resx b/Source/NETworkManager.Localization/Resources/Strings.resx index e761a52f73..7d71112ada 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.resx +++ b/Source/NETworkManager.Localization/Resources/Strings.resx @@ -5191,4 +5191,25 @@ Do you want to start the scan? All local subnets + + Guess the operating system from the TTL + + + Guess the operating system from the TTL of the ping reply: every operating system starts with a typical TTL, 64 (Linux, Unix, macOS, Android, iOS), 128 (Windows) or 255 (network devices like routers, switches and printers), and every router on the way reduces it by one. No additional packets are sent. It's a hint, not a detection: the TTL can be changed on the host, NAT and VPN can change it. Not available for IPv6 (the ping reply has no TTL). + + + OS (guess) + + + Linux / Unix + + + Network device + + + Windows + + + Based on the TTL of the ping reply ({0} hops) + \ No newline at end of file diff --git a/Source/NETworkManager.Models/Export/ExportManager.IPScannerHostInfo.cs b/Source/NETworkManager.Models/Export/ExportManager.IPScannerHostInfo.cs index 4f101ca9ad..27a375f9a9 100644 --- a/Source/NETworkManager.Models/Export/ExportManager.IPScannerHostInfo.cs +++ b/Source/NETworkManager.Models/Export/ExportManager.IPScannerHostInfo.cs @@ -75,7 +75,8 @@ private static void CreateCsv(IEnumerable collection, IPScann $"{nameof(IPScannerHostInfo.Vendor)}," + // Added later - last column, so the position of the other columns doesn't change $"{nameof(IPScannerHostInfo.DNSHostname)}," + - $"{nameof(IPScannerHostInfo.DetectedBy)}" + $"{nameof(IPScannerHostInfo.DetectedBy)}," + + $"{nameof(IPScannerHostInfo.OSGuess)}" ); foreach (var info in collection) @@ -119,7 +120,8 @@ private static void CreateCsv(IEnumerable collection, IPScann $"{info.MACAddress}," + $"{CsvHelper.QuoteString(info.Vendor)}," + $"{CsvHelper.QuoteString(info.DNSHostname)}," + - $"{CsvHelper.QuoteString(GetDetectedBy(info))}" + $"{CsvHelper.QuoteString(GetDetectedBy(info))}," + + $"{GetOSGuess(info, options)}" ); } @@ -176,7 +178,8 @@ from info in collection new XElement(nameof(IPScannerHostInfo.Vendor), info.Vendor), // Added later - last element, so the position of the other elements doesn't change new XElement(nameof(IPScannerHostInfo.DNSHostname), info.DNSHostname), - new XElement(nameof(IPScannerHostInfo.DetectedBy), GetDetectedBy(info)) + new XElement(nameof(IPScannerHostInfo.DetectedBy), GetDetectedBy(info)), + new XElement(nameof(IPScannerHostInfo.OSGuess), GetOSGuess(info, options)) ) ) ) @@ -247,7 +250,8 @@ private static void CreateJson(IReadOnlyList collection, IPSc NetBIOSVendor = netBIOSInfo?.Vendor, info.MACAddress, info.Vendor, - DetectedBy = GetDetectedBy(info) + DetectedBy = GetDetectedBy(info), + OSGuess = GetOSGuess(info, options) }; } @@ -264,6 +268,19 @@ private static string GetDetectedBy(IPScannerHostInfo info) return info.DetectedBy == IPScannerDetectionMethod.None ? null : info.DetectedBy.ToString(); } + /// + /// Gets the operating system guessed from the TTL (e.g. "Windows"), null if there is no guess or it was disabled. + /// + /// Host to get the guess for. + /// Options of the scan the host is from. + /// Guessed operating system or null. + private static string GetOSGuess(IPScannerHostInfo info, IPScannerResultOptions options) + { + return options.ICMPEnabled && options.OSGuessEnabled && info.OSGuess != IPScannerOSGuess.None + ? info.OSGuess.ToString() + : null; + } + /// /// Gets the result of the ping to a host - null while the ping is still running or if it was disabled /// (placeholder). diff --git a/Source/NETworkManager.Models/Network/IPScannerHostInfo.cs b/Source/NETworkManager.Models/Network/IPScannerHostInfo.cs index 6ef514cb37..38cfe22082 100644 --- a/Source/NETworkManager.Models/Network/IPScannerHostInfo.cs +++ b/Source/NETworkManager.Models/Network/IPScannerHostInfo.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Net; +using System.Net.NetworkInformation; using NETworkManager.Utilities; namespace NETworkManager.Models.Network; @@ -85,9 +86,29 @@ internal set field = value; OnPropertyChanged(); + OnPropertyChanged(nameof(OSGuess)); + OnPropertyChanged(nameof(OSGuessHops)); } } + /// + /// Operating system guessed from the TTL of the ping reply (see ), + /// without a ping reply (or TTL, e.g. IPv6). + /// + public IPScannerOSGuess OSGuess => GetOSGuess().osGuess; + + /// + /// Number of hops to the host based on the TTL of the ping reply (see ). + /// + public int OSGuessHops => GetOSGuess().hops; + + private (IPScannerOSGuess osGuess, int initialTTL, int hops) GetOSGuess() + { + return !IsPingPending && PingInfo?.Status == IPStatus.Success + ? IPScannerOSGuessHelper.GetFromTTL(PingInfo.TTL) + : (IPScannerOSGuess.None, 0, 0); + } + /// /// Hostname of the host: DNS hostname, fallback to the NetBIOS computer name. /// @@ -176,6 +197,8 @@ internal set field = value; OnPropertyChanged(); + OnPropertyChanged(nameof(OSGuess)); + OnPropertyChanged(nameof(OSGuessHops)); } } diff --git a/Source/NETworkManager.Models/Network/IPScannerOSGuess.cs b/Source/NETworkManager.Models/Network/IPScannerOSGuess.cs new file mode 100644 index 0000000000..ce217d26c4 --- /dev/null +++ b/Source/NETworkManager.Models/Network/IPScannerOSGuess.cs @@ -0,0 +1,27 @@ +namespace NETworkManager.Models.Network; + +/// +/// Operating system of a host guessed from the TTL of its ping reply, see . +/// +public enum IPScannerOSGuess +{ + /// + /// No guess (no ping reply, or no TTL like for IPv6). + /// + None, + + /// + /// Initial TTL 64: Linux, Unix, macOS, Android, iOS. + /// + LinuxUnix, + + /// + /// Initial TTL 128: Windows. + /// + Windows, + + /// + /// Initial TTL 255: network device (e.g. router, switch, printer), Solaris. + /// + NetworkDevice +} diff --git a/Source/NETworkManager.Models/Network/IPScannerOSGuessHelper.cs b/Source/NETworkManager.Models/Network/IPScannerOSGuessHelper.cs new file mode 100644 index 0000000000..e767dd55c6 --- /dev/null +++ b/Source/NETworkManager.Models/Network/IPScannerOSGuessHelper.cs @@ -0,0 +1,28 @@ +namespace NETworkManager.Models.Network; + +/// +/// Guesses the operating system of a host from the TTL of its ping reply. Every operating system starts with a +/// typical TTL (64, 128 or 255) and every router on the way reduces it by one - the initial TTL is the smallest +/// typical TTL that is not lower than the received TTL. It's a hint, not a detection: the TTL can be changed on +/// the host, NAT and VPN can change it. +/// +public static class IPScannerOSGuessHelper +{ + /// + /// Gets the guessed operating system and the number of hops from the TTL of a ping reply. + /// + /// TTL of the ping reply, 0 if there is none (e.g. IPv6). + /// Guessed operating system, initial TTL and hops (0 if there is no guess). + public static (IPScannerOSGuess osGuess, int initialTTL, int hops) GetFromTTL(int ttl) + { + var (osGuess, initialTTL) = ttl switch + { + <= 0 or > 255 => (IPScannerOSGuess.None, 0), + <= 64 => (IPScannerOSGuess.LinuxUnix, 64), + <= 128 => (IPScannerOSGuess.Windows, 128), + _ => (IPScannerOSGuess.NetworkDevice, 255) + }; + + return osGuess == IPScannerOSGuess.None ? (osGuess, 0, 0) : (osGuess, initialTTL, initialTTL - ttl); + } +} diff --git a/Source/NETworkManager.Models/Network/IPScannerOptions.cs b/Source/NETworkManager.Models/Network/IPScannerOptions.cs index e3c8613181..b467a8c495 100644 --- a/Source/NETworkManager.Models/Network/IPScannerOptions.cs +++ b/Source/NETworkManager.Models/Network/IPScannerOptions.cs @@ -37,6 +37,11 @@ public record IPScannerOptions /// public required int ICMPBufferSize { get; init; } + /// + /// Guess the operating system from the TTL of the ping reply (only shown in the result, no extra traffic). + /// + public required bool OSGuessEnabled { get; init; } + /// /// Resolve the hostname for an IP address. /// diff --git a/Source/NETworkManager.Models/Network/IPScannerResultOptions.cs b/Source/NETworkManager.Models/Network/IPScannerResultOptions.cs index cb61a3b4ae..dbe06ceafa 100644 --- a/Source/NETworkManager.Models/Network/IPScannerResultOptions.cs +++ b/Source/NETworkManager.Models/Network/IPScannerResultOptions.cs @@ -5,12 +5,14 @@ /// , without the ports and the ICMP buffer of the scan. /// /// The hosts were pinged. +/// The operating system was guessed from the TTL of the ping reply. /// The hostname was resolved via DNS. /// The ports of the hosts were scanned. /// The NetBIOS information was requested. /// The MAC address and vendor were resolved. public sealed record IPScannerResultOptions( bool ICMPEnabled, + bool OSGuessEnabled, bool ResolveHostname, bool PortScanEnabled, bool NetBIOSEnabled, @@ -21,7 +23,7 @@ public sealed record IPScannerResultOptions( /// /// Options of the scan. public IPScannerResultOptions(IPScannerOptions options) - : this(options.ICMPEnabled, options.ResolveHostname, options.PortScanEnabled, options.NetBIOSEnabled, options.ResolveMACAddress) + : this(options.ICMPEnabled, options.OSGuessEnabled, options.ResolveHostname, options.PortScanEnabled, options.NetBIOSEnabled, options.ResolveMACAddress) { } } diff --git a/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs b/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs index 2cc661ad6e..313bf64408 100644 --- a/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs +++ b/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs @@ -152,6 +152,7 @@ public static class GlobalStaticConfiguration public static int IPScanner_ICMPAttempts => 2; public static int IPScanner_ICMPTimeout => 2000; public static int IPScanner_ICMPBuffer => 32; + public static bool IPScanner_OSGuessEnabled => true; public static bool IPScanner_ResolveHostname => true; public static bool IPScanner_PortScanEnabled => true; public static string IPScanner_PortScanPorts => "21; 22; 23; 53; 80; 135; 139; 389; 443; 445; 554; 631; 636; 3389; 5900; 5985; 7000; 8009; 8080; 8443; 9100; 62078"; diff --git a/Source/NETworkManager.Settings/SettingsInfo.cs b/Source/NETworkManager.Settings/SettingsInfo.cs index f58cd5a73f..a3f9c30476 100644 --- a/Source/NETworkManager.Settings/SettingsInfo.cs +++ b/Source/NETworkManager.Settings/SettingsInfo.cs @@ -1272,6 +1272,19 @@ public int IPScanner_ICMPBuffer } } = GlobalStaticConfiguration.IPScanner_ICMPBuffer; + public bool IPScanner_OSGuessEnabled + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } = GlobalStaticConfiguration.IPScanner_OSGuessEnabled; + public ObservableCollection IPScanner_HostHistory { get; diff --git a/Source/NETworkManager.Tests/Network/IPScannerOSGuessHelperTests.cs b/Source/NETworkManager.Tests/Network/IPScannerOSGuessHelperTests.cs new file mode 100644 index 0000000000..d069cc6c58 --- /dev/null +++ b/Source/NETworkManager.Tests/Network/IPScannerOSGuessHelperTests.cs @@ -0,0 +1,35 @@ +using NETworkManager.Models.Network; +using Xunit; + +namespace NETworkManager.Tests.Network; + +public class IPScannerOSGuessHelperTests +{ + [Theory] + [InlineData(64, IPScannerOSGuess.LinuxUnix, 64, 0)] + [InlineData(61, IPScannerOSGuess.LinuxUnix, 64, 3)] + [InlineData(1, IPScannerOSGuess.LinuxUnix, 64, 63)] + [InlineData(128, IPScannerOSGuess.Windows, 128, 0)] + [InlineData(127, IPScannerOSGuess.Windows, 128, 1)] + [InlineData(65, IPScannerOSGuess.Windows, 128, 63)] + [InlineData(255, IPScannerOSGuess.NetworkDevice, 255, 0)] + [InlineData(250, IPScannerOSGuess.NetworkDevice, 255, 5)] + [InlineData(129, IPScannerOSGuess.NetworkDevice, 255, 126)] + public void GetFromTTL(int ttl, IPScannerOSGuess expectedOSGuess, int expectedInitialTTL, int expectedHops) + { + var (osGuess, initialTTL, hops) = IPScannerOSGuessHelper.GetFromTTL(ttl); + + Assert.Equal(expectedOSGuess, osGuess); + Assert.Equal(expectedInitialTTL, initialTTL); + Assert.Equal(expectedHops, hops); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(256)] + public void GetFromTTL_NoTTL_NoGuess(int ttl) + { + Assert.Equal((IPScannerOSGuess.None, 0, 0), IPScannerOSGuessHelper.GetFromTTL(ttl)); + } +} diff --git a/Source/NETworkManager/ViewModels/IPScannerSettingsViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerSettingsViewModel.cs index 72ac87ad00..882a3fd71c 100644 --- a/Source/NETworkManager/ViewModels/IPScannerSettingsViewModel.cs +++ b/Source/NETworkManager/ViewModels/IPScannerSettingsViewModel.cs @@ -254,6 +254,25 @@ public bool ARPEnabled } } + /// + /// Gets or sets a value indicating whether the operating system is guessed from the TTL of the ping reply. + /// + public bool OSGuessEnabled + { + get; + set + { + if (value == field) + return; + + if (!_isLoading) + SettingsManager.Current.IPScanner_OSGuessEnabled = value; + + field = value; + OnPropertyChanged(); + } + } + /// /// Gets or sets a value indicating whether NetBIOS is enabled. /// @@ -417,6 +436,7 @@ private void LoadSettings() ICMPAttempts = SettingsManager.Current.IPScanner_ICMPAttempts; ICMPTimeout = SettingsManager.Current.IPScanner_ICMPTimeout; ICMPBuffer = SettingsManager.Current.IPScanner_ICMPBuffer; + OSGuessEnabled = SettingsManager.Current.IPScanner_OSGuessEnabled; ResolveHostname = SettingsManager.Current.IPScanner_ResolveHostname; PortScanEnabled = SettingsManager.Current.IPScanner_PortScanEnabled; PortScanPorts = SettingsManager.Current.IPScanner_PortScanPorts; diff --git a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs index 13caeb67d0..de74635ac9 100644 --- a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs @@ -2,6 +2,7 @@ using MahApps.Metro.Controls; using MahApps.Metro.SimpleChildWindow; using NETworkManager.Controls; +using NETworkManager.Converters; using NETworkManager.Localization; using NETworkManager.Localization.Resources; using NETworkManager.Models; @@ -453,6 +454,8 @@ public IPScannerViewModel(Guid tabId, string hostOrIPRange, bool start = true) Matches(info.Hostname) || Matches(info.MACAddress) || Matches(info.Vendor) || + (ScanOptions.OSGuessEnabled && + Matches(IPScannerOSGuessToStringConverter.ToLocalizedString(info.OSGuess))) || info.Ports.Any(x => Matches(x.Port.ToString()) || Matches(x.LookupInfo.Service)); }; @@ -464,6 +467,7 @@ public IPScannerViewModel(Guid tabId, string hostOrIPRange, bool start = true) liveShaping.LiveFilteringProperties.Add(nameof(IPScannerHostInfo.MACAddress)); liveShaping.LiveFilteringProperties.Add(nameof(IPScannerHostInfo.Vendor)); liveShaping.LiveFilteringProperties.Add(nameof(IPScannerHostInfo.Ports)); + liveShaping.LiveFilteringProperties.Add(nameof(IPScannerHostInfo.OSGuess)); _searchDelay = new SearchDelay(GlobalStaticConfiguration.SearchDispatcherTimerTimeSpan, ApplySearch); } @@ -964,6 +968,7 @@ private static IPScannerOptions CreateScanOptions(int[] ports) MaxHostThreads = SettingsManager.Current.IPScanner_MaxHostThreads, MaxPortThreads = SettingsManager.Current.IPScanner_MaxPortThreads, ICMPEnabled = SettingsManager.Current.IPScanner_ICMPEnabled, + OSGuessEnabled = SettingsManager.Current.IPScanner_OSGuessEnabled, ICMPAttempts = SettingsManager.Current.IPScanner_ICMPAttempts, ICMPTimeout = SettingsManager.Current.IPScanner_ICMPTimeout, ICMPBufferSize = GlobalStaticConfiguration.LimitICMPBuffer(SettingsManager.Current.IPScanner_ICMPBuffer), @@ -1000,6 +1005,7 @@ private static IPScannerResultOptions CreateResultOptions() { return new IPScannerResultOptions( SettingsManager.Current.IPScanner_ICMPEnabled, + SettingsManager.Current.IPScanner_OSGuessEnabled, SettingsManager.Current.IPScanner_ResolveHostname, SettingsManager.Current.IPScanner_PortScanEnabled, SettingsManager.Current.IPScanner_NetBIOSEnabled, diff --git a/Source/NETworkManager/Views/IPScannerSettingsView.xaml b/Source/NETworkManager/Views/IPScannerSettingsView.xaml index 1326d75918..2dc226e768 100644 --- a/Source/NETworkManager/Views/IPScannerSettingsView.xaml +++ b/Source/NETworkManager/Views/IPScannerSettingsView.xaml @@ -41,7 +41,18 @@ - + + + + + + + + all exports the filtered results; the up / down counters always count all hosts. Results can also be exported while the scan is running; values of probes that are still running (shown as `...`) are exported as empty values. +The search box right of the **Result** header filters the results by IP address, hostname, MAC address, vendor, OS (guess) and open ports (number or service), also while the scan is running (hosts whose hostname or MAC address arrives later are filtered again). **Export...** > all exports the filtered results; the up / down counters always count all hosts. Results can also be exported while the scan is running; values of probes that are still running (shown as `...`) are exported as empty values. Expand a row with the button in front of it to see its details, or enable [Expand host view](#expand-host-view) to expand all hosts. @@ -205,6 +205,26 @@ Size of the buffer for each ICMP request in bytes. **Default:** `32` +### Guess the operating system from the TTL + +Shows the operating system guessed from the TTL of the ping reply in the **OS (guess)** column: every operating system starts with a typical TTL and every router on the way reduces it by one. The guess is the smallest typical TTL that is not lower than the received TTL, the tooltip shows the number of hops (e.g. TTL `126` → `Windows`, 2 hops). No additional packets are sent. + +| Initial TTL | OS (guess) | Examples | +| ----------- | ---------------- | -------------------------------------------- | +| `64` | `Linux / Unix` | Linux, macOS, Android, iOS, many IoT devices | +| `128` | `Windows` | Windows | +| `255` | `Network device` | Routers, switches, printers, Solaris | + +**Type:** `Boolean` + +**Default:** `Enabled` + +:::note + +It's a hint, not a detection: the TTL can be changed on the host (e.g. some devices use `64` or `128`), NAT and VPN can change it. Only available for hosts that answered the ping and only for IPv4 (the ping reply of IPv6 has no TTL). The export contains it as `OSGuess` (`LinuxUnix`, `Windows`, `NetworkDevice`). + +::: + ### Resolve hostname Resolve the hostname (PTR) for each IP address. diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index ecb3103ade..10b9c7c24c 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -90,6 +90,7 @@ Release date: **xx.xx.2026** - The probes a host answered (**Ping**, **TCP**, **NetBIOS**) are shown in the tooltip of the status icon and as **Detected by** in the row details, e.g. to see why a host is up. The export contains them as `DetectedBy` (last column / element). [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) - New **Ping hosts** setting (default on) to scan without ping, e.g. if ICMP is blocked or rate limited in the network: hosts are found by their ports or NetBIOS answer, and the scan is faster because an unreachable host doesn't wait for the ping attempts. At least one of ping, port scan or NetBIOS must be enabled. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) - New **Find hosts in the local subnet** setting (default on): hosts in a local subnet that don't answer the ping, a port or NetBIOS (e.g. a Windows client in a public network, a phone or an IoT device with a firewall that drops everything) are found by their ARP / NDP entry, which Windows creates when the probes resolve the MAC address of the host. No additional packets are sent, except to check an entry from earlier traffic again. Answers with the MAC address of the default gateway (proxy ARP) are ignored. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) +- New **OS (guess)** column with the operating system guessed from the TTL of the ping reply (`Linux / Unix`, `Windows` or `Network device`, the tooltip shows the number of hops), can be disabled with the new setting **Guess the operating system from the TTL** (default on). No additional packets are sent; IPv4 only. Also in the search, **Copy** and the export (`OSGuess`). [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) - The **Detect local subnet** button opens a menu: **Detect local subnet** (as before), **Subnet of network interface** to select the IPv4 subnet of a specific network interface (e.g. LAN, Wi-Fi, VPN, Hyper-V) and **All local subnets** to scan the subnets of all network interfaces. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) - IP addresses can be excluded from the scan with `!` in the host input (also in profiles and the run command), e.g. `10.0.0.0/24; !10.0.0.1; !10.0.0.200-254`. Works with IP addresses, ranges, subnets and hostnames. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) From 7a33c1045d61bd65b7c9ee9134e82527b9605388 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:52:11 +0100 Subject: [PATCH 009/131] Feature: IP Scanner: Mark own computer, default gateway, DNS and DHCP server (B2) The roles of the hosts for the local computer (own IP addresses, default gateways, DNS and DHCP servers of the operational network interfaces) are read once per scan (IPScannerHostRoleHelper) and set on the host (Role). New icon column after the status icon (most important role: this computer, gateway, DNS, DHCP), all roles in the tooltip. Search and export (Role, last column). The network interfaces are now always read for a scan. Unit tests for the roles. Co-Authored-By: Claude Opus 5.5 --- .../IPScannerHostRoleToStringConverter.cs | 68 ++++++++++++++++ .../Resources/Strings.Designer.cs | 9 +++ .../Resources/Strings.resx | 3 + .../Export/ExportManager.IPScannerHostInfo.cs | 22 ++++- .../Network/IPScanner.cs | 15 ++-- .../Network/IPScannerHostInfo.cs | 25 ++++++ .../Network/IPScannerHostRole.cs | 35 ++++++++ .../Network/IPScannerHostRoleHelper.cs | 80 +++++++++++++++++++ .../Network/IPScannerHostRoleHelperTests.cs | 58 ++++++++++++++ .../ViewModels/IPScannerViewModel.cs | 1 + .../NETworkManager/Views/IPScannerView.xaml | 62 ++++++++++++++ Website/docs/application/ip-scanner.md | 2 + Website/docs/changelog/next-release.md | 1 + 13 files changed, 372 insertions(+), 9 deletions(-) create mode 100644 Source/NETworkManager.Converters/IPScannerHostRoleToStringConverter.cs create mode 100644 Source/NETworkManager.Models/Network/IPScannerHostRole.cs create mode 100644 Source/NETworkManager.Models/Network/IPScannerHostRoleHelper.cs create mode 100644 Source/NETworkManager.Tests/Network/IPScannerHostRoleHelperTests.cs diff --git a/Source/NETworkManager.Converters/IPScannerHostRoleToStringConverter.cs b/Source/NETworkManager.Converters/IPScannerHostRoleToStringConverter.cs new file mode 100644 index 0000000000..d6c683246a --- /dev/null +++ b/Source/NETworkManager.Converters/IPScannerHostRoleToStringConverter.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Windows.Data; +using NETworkManager.Localization.Resources; +using NETworkManager.Models.Network; + +namespace NETworkManager.Converters; + +/// +/// Convert the of a host to a translated +/// (e.g. "Default gateway, DNS server"). +/// +public sealed class IPScannerHostRoleToStringConverter : IValueConverter +{ + /// + /// Convert the of a host to a translated . + /// + /// Roles of the host as . + /// + /// + /// + /// Translated, comma-separated roles, null if the host has none (no tooltip). + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is not IPScannerHostRole role || role == IPScannerHostRole.None) + return null; + + return ToLocalizedString(role); + } + + /// + /// Gets the translated, comma-separated roles of a host. + /// + /// Roles of the host. + /// Translated roles, empty if the host has none. + public static string ToLocalizedString(IPScannerHostRole role) + { + var names = new List(4); + + if (role.HasFlag(IPScannerHostRole.LocalComputer)) + names.Add(Strings.ThisComputer); + + if (role.HasFlag(IPScannerHostRole.DefaultGateway)) + names.Add(Strings.DefaultGateway); + + if (role.HasFlag(IPScannerHostRole.DNSServer)) + names.Add(Strings.DNSServer); + + if (role.HasFlag(IPScannerHostRole.DHCPServer)) + names.Add(Strings.DHCPServer); + + return string.Join(", ", names); + } + + /// + /// !!! Method not implemented !!! + /// + /// + /// + /// + /// + /// + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} diff --git a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs index b077cd3f24..79c4bfc756 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs +++ b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs @@ -13942,6 +13942,15 @@ public static string TheSettingsLocationIsNotAffected { } } + /// + /// Looks up a localized string similar to This computer. + /// + public static string ThisComputer { + get { + return ResourceManager.GetString("ThisComputer", resourceCulture); + } + } + /// /// Looks up a localized string similar to This will reset all settings!. /// diff --git a/Source/NETworkManager.Localization/Resources/Strings.resx b/Source/NETworkManager.Localization/Resources/Strings.resx index 7d71112ada..b5ba69afb8 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.resx +++ b/Source/NETworkManager.Localization/Resources/Strings.resx @@ -5212,4 +5212,7 @@ Do you want to start the scan? Based on the TTL of the ping reply ({0} hops) + + This computer + \ No newline at end of file diff --git a/Source/NETworkManager.Models/Export/ExportManager.IPScannerHostInfo.cs b/Source/NETworkManager.Models/Export/ExportManager.IPScannerHostInfo.cs index 27a375f9a9..111267ff64 100644 --- a/Source/NETworkManager.Models/Export/ExportManager.IPScannerHostInfo.cs +++ b/Source/NETworkManager.Models/Export/ExportManager.IPScannerHostInfo.cs @@ -76,7 +76,8 @@ private static void CreateCsv(IEnumerable collection, IPScann // Added later - last column, so the position of the other columns doesn't change $"{nameof(IPScannerHostInfo.DNSHostname)}," + $"{nameof(IPScannerHostInfo.DetectedBy)}," + - $"{nameof(IPScannerHostInfo.OSGuess)}" + $"{nameof(IPScannerHostInfo.OSGuess)}," + + $"{nameof(IPScannerHostInfo.Role)}" ); foreach (var info in collection) @@ -121,7 +122,8 @@ private static void CreateCsv(IEnumerable collection, IPScann $"{CsvHelper.QuoteString(info.Vendor)}," + $"{CsvHelper.QuoteString(info.DNSHostname)}," + $"{CsvHelper.QuoteString(GetDetectedBy(info))}," + - $"{GetOSGuess(info, options)}" + $"{GetOSGuess(info, options)}," + + $"{CsvHelper.QuoteString(GetRole(info))}" ); } @@ -179,7 +181,8 @@ from info in collection // Added later - last element, so the position of the other elements doesn't change new XElement(nameof(IPScannerHostInfo.DNSHostname), info.DNSHostname), new XElement(nameof(IPScannerHostInfo.DetectedBy), GetDetectedBy(info)), - new XElement(nameof(IPScannerHostInfo.OSGuess), GetOSGuess(info, options)) + new XElement(nameof(IPScannerHostInfo.OSGuess), GetOSGuess(info, options)), + new XElement(nameof(IPScannerHostInfo.Role), GetRole(info)) ) ) ) @@ -251,7 +254,8 @@ private static void CreateJson(IReadOnlyList collection, IPSc info.MACAddress, info.Vendor, DetectedBy = GetDetectedBy(info), - OSGuess = GetOSGuess(info, options) + OSGuess = GetOSGuess(info, options), + Role = GetRole(info) }; } @@ -268,6 +272,16 @@ private static string GetDetectedBy(IPScannerHostInfo info) return info.DetectedBy == IPScannerDetectionMethod.None ? null : info.DetectedBy.ToString(); } + /// + /// Gets the roles of a host for the local computer (e.g. "DefaultGateway, DNSServer"), null if it has none. + /// + /// Host to get the roles for. + /// Roles or null. + private static string GetRole(IPScannerHostInfo info) + { + return info.Role == IPScannerHostRole.None ? null : info.Role.ToString(); + } + /// /// Gets the operating system guessed from the TTL (e.g. "Windows"), null if there is no guess or it was disabled. /// diff --git a/Source/NETworkManager.Models/Network/IPScanner.cs b/Source/NETworkManager.Models/Network/IPScanner.cs index 288d336994..bd783dfacb 100644 --- a/Source/NETworkManager.Models/Network/IPScanner.cs +++ b/Source/NETworkManager.Models/Network/IPScanner.cs @@ -56,6 +56,10 @@ public sealed class IPScanner(IPScannerOptions options) private readonly SemaphoreSlim _neighborVerificationSemaphore = new(MaxConcurrentNeighborVerifications, MaxConcurrentNeighborVerifications); + // Roles of the hosts for the local computer (own IP addresses, gateways, DNS / DHCP servers), set before the + // hosts are scanned and only read afterwards + private Dictionary _hostRoles = []; + /// /// Gets the number of hosts finished so far (all probes and lookups). Thread-safe; may be read from /// any thread while the scan is running. @@ -145,11 +149,11 @@ public void ScanAsync(IEnumerable<(IPAddress ipAddress, string hostname)> hosts, try { - // Get all network interfaces (for the MAC address lookup and the neighbor check of hosts in a local - // subnet) - var networkInterfaces = options.ResolveMACAddress || options.ARPEnabled - ? NetworkInterface.GetNetworkInterfaces() - : []; + // Get all network interfaces (for the roles of the hosts, the MAC address lookup and the neighbor + // check of hosts in a local subnet) + var networkInterfaces = NetworkInterface.GetNetworkInterfaces(); + + _hostRoles = IPScannerHostRoleHelper.GetRoles(networkInterfaces); var hostParallelOptions = new ParallelOptions { @@ -464,6 +468,7 @@ private IPScannerHostInfo CreateHostInfo(IPAddress ipAddress, IPScannerDetection netBIOSInfo ?? new NetBIOSInfo(ipAddress)) { DetectedBy = detectedBy, + Role = IPScannerHostRoleHelper.GetRole(_hostRoles, ipAddress), IsPingPending = pingInfo == null && options.ICMPEnabled, IsPortScanPending = ports == null, IsNetBIOSPending = netBIOSInfo == null diff --git a/Source/NETworkManager.Models/Network/IPScannerHostInfo.cs b/Source/NETworkManager.Models/Network/IPScannerHostInfo.cs index 38cfe22082..39faac1145 100644 --- a/Source/NETworkManager.Models/Network/IPScannerHostInfo.cs +++ b/Source/NETworkManager.Models/Network/IPScannerHostInfo.cs @@ -73,6 +73,29 @@ internal set } } + /// + /// Roles of the host for the local computer (e.g. its default gateway), see + /// . + /// + public IPScannerHostRole Role + { + get; + internal set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(PrimaryRole)); + } + } + + /// + /// Most important role of the host (for its icon), see . + /// + public IPScannerHostRole PrimaryRole => IPScannerHostRoleHelper.GetPrimaryRole(Role); + /// /// Information about the ping to the host. /// @@ -294,6 +317,7 @@ public void CopyFrom(IPScannerHostInfo source) { IsReachable = source.IsReachable; DetectedBy = source.DetectedBy; + Role = source.Role; PingInfo = source.PingInfo; IsAnyPortOpen = source.IsAnyPortOpen; Ports = source.Ports; @@ -316,6 +340,7 @@ public IPScannerHostInfo Clone() return new IPScannerHostInfo(IPAddress, IsReachable, PingInfo, IsAnyPortOpen, Ports, NetBIOSInfo) { DetectedBy = DetectedBy, + Role = Role, IsPingPending = IsPingPending, IsPortScanPending = IsPortScanPending, IsNetBIOSPending = IsNetBIOSPending, diff --git a/Source/NETworkManager.Models/Network/IPScannerHostRole.cs b/Source/NETworkManager.Models/Network/IPScannerHostRole.cs new file mode 100644 index 0000000000..c030d177fc --- /dev/null +++ b/Source/NETworkManager.Models/Network/IPScannerHostRole.cs @@ -0,0 +1,35 @@ +using System; + +namespace NETworkManager.Models.Network; + +/// +/// Roles of a host for the local computer (e.g. its default gateway), see . +/// +[Flags] +public enum IPScannerHostRole +{ + /// + /// No role. + /// + None = 0, + + /// + /// IP address of the local computer. + /// + LocalComputer = 1, + + /// + /// Default gateway of a local network interface. + /// + DefaultGateway = 2, + + /// + /// DNS server of a local network interface. + /// + DNSServer = 4, + + /// + /// DHCP server of a local network interface. + /// + DHCPServer = 8 +} diff --git a/Source/NETworkManager.Models/Network/IPScannerHostRoleHelper.cs b/Source/NETworkManager.Models/Network/IPScannerHostRoleHelper.cs new file mode 100644 index 0000000000..915c3320c7 --- /dev/null +++ b/Source/NETworkManager.Models/Network/IPScannerHostRoleHelper.cs @@ -0,0 +1,80 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net; +using NETworkManager.Utilities; + +namespace NETworkManager.Models.Network; + +/// +/// Gets the roles of hosts for the local computer (own IP addresses, default gateways, DNS and DHCP servers) from +/// the configuration of the local network interfaces. +/// +public static class IPScannerHostRoleHelper +{ + /// + /// Gets the roles of the IP addresses known from the operational local network interfaces. + /// + /// + /// Local network interfaces, see . + /// + /// Roles per IP address (without IPv6 scope id, see ). + public static Dictionary GetRoles( + IEnumerable networkInterfaces) + { + var roles = new Dictionary(); + + void Add(IEnumerable ipAddresses, IPScannerHostRole role) + { + foreach (var ipAddress in ipAddresses ?? []) + { + var key = IPAddressHelper.WithoutScopeId(ipAddress); + + roles[key] = roles.GetValueOrDefault(key) | role; + } + } + + foreach (var networkInterface in networkInterfaces.Where(x => x.IsOperational)) + { + Add(networkInterface.IPv4Address?.Select(x => x.Item1), IPScannerHostRole.LocalComputer); + Add(networkInterface.IPv6Address, IPScannerHostRole.LocalComputer); + Add(networkInterface.IPv6AddressLinkLocal, IPScannerHostRole.LocalComputer); + Add(networkInterface.IPv4Gateway, IPScannerHostRole.DefaultGateway); + Add(networkInterface.IPv6Gateway, IPScannerHostRole.DefaultGateway); + Add(networkInterface.DNSServer, IPScannerHostRole.DNSServer); + Add(networkInterface.DhcpServer, IPScannerHostRole.DHCPServer); + } + + return roles; + } + + /// + /// Gets the roles of a host. + /// + /// Roles per IP address, see . + /// IP address of the host (an IPv6 address may have a scope id). + /// Roles of the host, if it has none. + public static IPScannerHostRole GetRole(IReadOnlyDictionary roles, + IPAddress ipAddress) + { + return roles.GetValueOrDefault(IPAddressHelper.WithoutScopeId(ipAddress)); + } + + /// + /// Gets the most important role of a host (for its icon): local computer, default gateway, DNS server, DHCP + /// server. + /// + /// Roles of the host. + /// Most important role, if it has none. + public static IPScannerHostRole GetPrimaryRole(IPScannerHostRole role) + { + foreach (var primaryRole in (IPScannerHostRole[]) + [IPScannerHostRole.LocalComputer, IPScannerHostRole.DefaultGateway, IPScannerHostRole.DNSServer, + IPScannerHostRole.DHCPServer]) + { + if (role.HasFlag(primaryRole)) + return primaryRole; + } + + return IPScannerHostRole.None; + } +} diff --git a/Source/NETworkManager.Tests/Network/IPScannerHostRoleHelperTests.cs b/Source/NETworkManager.Tests/Network/IPScannerHostRoleHelperTests.cs new file mode 100644 index 0000000000..3fd264af64 --- /dev/null +++ b/Source/NETworkManager.Tests/Network/IPScannerHostRoleHelperTests.cs @@ -0,0 +1,58 @@ +using System; +using System.Net; +using NETworkManager.Models.Network; +using Xunit; + +namespace NETworkManager.Tests.Network; + +public class IPScannerHostRoleHelperTests +{ + private static NetworkInterfaceInfo CreateInterface(bool isOperational = true) + { + return new NetworkInterfaceInfo + { + IsOperational = isOperational, + IPv4Address = + [new Tuple(IPAddress.Parse("192.168.1.23"), IPAddress.Parse("255.255.255.0"))], + IPv6Address = [IPAddress.Parse("2001:db8::23")], + IPv6AddressLinkLocal = [IPAddress.Parse("fe80::23%12")], + IPv4Gateway = [IPAddress.Parse("192.168.1.1")], + IPv6Gateway = [IPAddress.Parse("fe80::1%12")], + DNSServer = [IPAddress.Parse("192.168.1.1"), IPAddress.Parse("192.168.1.2")], + DhcpServer = [IPAddress.Parse("192.168.1.1")] + }; + } + + [Theory] + [InlineData("192.168.1.23", IPScannerHostRole.LocalComputer)] + [InlineData("2001:db8::23", IPScannerHostRole.LocalComputer)] + [InlineData("fe80::23", IPScannerHostRole.LocalComputer)] + [InlineData("fe80::23%12", IPScannerHostRole.LocalComputer)] + [InlineData("192.168.1.1", + IPScannerHostRole.DefaultGateway | IPScannerHostRole.DNSServer | IPScannerHostRole.DHCPServer)] + [InlineData("fe80::1%3", IPScannerHostRole.DefaultGateway)] + [InlineData("192.168.1.2", IPScannerHostRole.DNSServer)] + [InlineData("192.168.1.50", IPScannerHostRole.None)] + public void GetRole(string ipAddress, IPScannerHostRole expected) + { + var roles = IPScannerHostRoleHelper.GetRoles([CreateInterface()]); + + Assert.Equal(expected, IPScannerHostRoleHelper.GetRole(roles, IPAddress.Parse(ipAddress))); + } + + [Fact] + public void GetRoles_NotOperational_Skipped() + { + Assert.Empty(IPScannerHostRoleHelper.GetRoles([CreateInterface(false)])); + } + + [Theory] + [InlineData(IPScannerHostRole.None, IPScannerHostRole.None)] + [InlineData(IPScannerHostRole.DHCPServer | IPScannerHostRole.DNSServer, IPScannerHostRole.DNSServer)] + [InlineData(IPScannerHostRole.DNSServer | IPScannerHostRole.DefaultGateway, IPScannerHostRole.DefaultGateway)] + [InlineData(IPScannerHostRole.LocalComputer | IPScannerHostRole.DNSServer, IPScannerHostRole.LocalComputer)] + public void GetPrimaryRole(IPScannerHostRole role, IPScannerHostRole expected) + { + Assert.Equal(expected, IPScannerHostRoleHelper.GetPrimaryRole(role)); + } +} diff --git a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs index de74635ac9..5ddc8023cd 100644 --- a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs @@ -456,6 +456,7 @@ public IPScannerViewModel(Guid tabId, string hostOrIPRange, bool start = true) Matches(info.Vendor) || (ScanOptions.OSGuessEnabled && Matches(IPScannerOSGuessToStringConverter.ToLocalizedString(info.OSGuess))) || + Matches(IPScannerHostRoleToStringConverter.ToLocalizedString(info.Role)) || info.Ports.Any(x => Matches(x.Port.ToString()) || Matches(x.LookupInfo.Service)); }; diff --git a/Source/NETworkManager/Views/IPScannerView.xaml b/Source/NETworkManager/Views/IPScannerView.xaml index fe04105699..f064c5d204 100644 --- a/Source/NETworkManager/Views/IPScannerView.xaml +++ b/Source/NETworkManager/Views/IPScannerView.xaml @@ -26,6 +26,7 @@ + @@ -736,6 +737,67 @@ + + + + + + + + + + + + Date: Fri, 30 Oct 2026 19:53:29 +0100 Subject: [PATCH 010/131] Refactor: IP Scanner: UdpRequest for NetBIOS (C1a) The UDP request / answer part of NetBIOSResolver (send, receive the datagram of the host until the timeout, exception-free) moves to UdpRequest, so the mDNS resolver (C1) can use it as well. No change in behaviour. Co-Authored-By: Claude Opus 5.5 --- .../Network/NetBIOSResolver.cs | 50 ++----------- .../Network/UdpRequest.cs | 72 +++++++++++++++++++ 2 files changed, 77 insertions(+), 45 deletions(-) create mode 100644 Source/NETworkManager.Models/Network/UdpRequest.cs diff --git a/Source/NETworkManager.Models/Network/NetBIOSResolver.cs b/Source/NETworkManager.Models/Network/NetBIOSResolver.cs index 8fdc11ba91..b776ecd094 100644 --- a/Source/NETworkManager.Models/Network/NetBIOSResolver.cs +++ b/Source/NETworkManager.Models/Network/NetBIOSResolver.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.Linq; using System.Net; -using System.Net.Sockets; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -44,58 +43,19 @@ public static class NetBIOSResolver 0x00, 0x01 }; - /// - /// Maximum size of a NetBIOS name status response (57 bytes header + 255 names x 18 bytes + statistics). - /// - private const int ResponseBufferSize = 8192; - public static async Task ResolveAsync(IPAddress ipAddress, int timeout, CancellationToken cancellationToken) { try { - using var socket = new Socket(ipAddress.AddressFamily, SocketType.Dgram, ProtocolType.Udp); - - // Closing the socket on timeout or cancellation aborts a pending send / receive, it completes with - // OperationAborted. The results are reported as SocketError, no exception if the host doesn't - // answer (see SocketOperation). - using var timeoutCts = new CancellationTokenSource(timeout); - using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); - await using var registration = linkedCts.Token.Register(socket.Dispose); + // Name status response: 57 bytes header + 255 names x 18 bytes + statistics (max. 8192 bytes) + var response = await UdpRequest.SendAsync(ipAddress, NetBIOSUdpPort, RequestData, timeout, + cancellationToken).ConfigureAwait(false); - using var sendArgs = new SocketAsyncEventArgs(); - sendArgs.RemoteEndPoint = new IPEndPoint(ipAddress, NetBIOSUdpPort); - sendArgs.SetBuffer(RequestData); - - if (await SocketOperation.RunAsync(sendArgs, socket.SendToAsync).ConfigureAwait(false) != - SocketError.Success) + // No answer (timeout), port unreachable, etc. + if (response == null) return new NetBIOSInfo(ipAddress); - var buffer = new byte[ResponseBufferSize]; - byte[] response; - - // Wait for the answer of the host - a datagram from another address (e.g. a stray or spoofed one) is - // ignored, until the timeout - while (true) - { - using var receiveArgs = new SocketAsyncEventArgs(); - receiveArgs.RemoteEndPoint = new IPEndPoint( - ipAddress.AddressFamily == AddressFamily.InterNetworkV6 ? IPAddress.IPv6Any : IPAddress.Any, 0); - receiveArgs.SetBuffer(buffer); - - // No answer (timeout), port unreachable, etc. - if (await SocketOperation.RunAsync(receiveArgs, socket.ReceiveFromAsync).ConfigureAwait(false) != - SocketError.Success) - return new NetBIOSInfo(ipAddress); - - if (receiveArgs.RemoteEndPoint is not IPEndPoint remoteEndPoint || - IPAddressHelper.CompareIPAddresses(remoteEndPoint.Address, ipAddress) != 0) - continue; - - response = buffer[..receiveArgs.BytesTransferred]; - break; - } - if (response.Length < ResponseBaseLen || response[ResponseTypePos] != ResponseTypeNbstat) return new NetBIOSInfo(ipAddress); // response was too short diff --git a/Source/NETworkManager.Models/Network/UdpRequest.cs b/Source/NETworkManager.Models/Network/UdpRequest.cs new file mode 100644 index 0000000000..e0681c52f2 --- /dev/null +++ b/Source/NETworkManager.Models/Network/UdpRequest.cs @@ -0,0 +1,72 @@ +using System.Net; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +using NETworkManager.Utilities; + +namespace NETworkManager.Models.Network; + +/// +/// Sends a UDP request to a host and waits for its answer (e.g. NetBIOS, mDNS). No exception if the host doesn't +/// answer, see . +/// +public static class UdpRequest +{ + /// + /// Maximum size of an answer. + /// + private const int ResponseBufferSize = 8192; + + /// + /// Sends to of the host and returns the first datagram the + /// host answers with. Datagrams from other addresses (e.g. a stray or spoofed one) are ignored. + /// + /// IP address of the host. + /// UDP port of the host. + /// Data to send. + /// Timeout in milliseconds to wait for the answer. + /// Token to monitor for cancellation requests. + /// Answer of the host, null if it didn't answer within the timeout (or port unreachable, etc.). + public static async Task SendAsync(IPAddress ipAddress, int port, byte[] request, int timeout, + CancellationToken cancellationToken) + { + using var socket = new Socket(ipAddress.AddressFamily, SocketType.Dgram, ProtocolType.Udp); + + // Closing the socket on timeout or cancellation aborts a pending send / receive, it completes with + // OperationAborted. The results are reported as SocketError, no exception if the host doesn't + // answer (see SocketOperation). + using var timeoutCts = new CancellationTokenSource(timeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); + await using var registration = linkedCts.Token.Register(socket.Dispose); + + using var sendArgs = new SocketAsyncEventArgs(); + sendArgs.RemoteEndPoint = new IPEndPoint(ipAddress, port); + sendArgs.SetBuffer(request); + + if (await SocketOperation.RunAsync(sendArgs, socket.SendToAsync).ConfigureAwait(false) != SocketError.Success) + return null; + + var buffer = new byte[ResponseBufferSize]; + + // Wait for the answer of the host - a datagram from another address (e.g. a stray or spoofed one) is + // ignored, until the timeout + while (true) + { + using var receiveArgs = new SocketAsyncEventArgs(); + receiveArgs.RemoteEndPoint = new IPEndPoint( + ipAddress.AddressFamily == AddressFamily.InterNetworkV6 ? IPAddress.IPv6Any : IPAddress.Any, 0); + receiveArgs.SetBuffer(buffer); + + // No answer (timeout), port unreachable, etc. + if (await SocketOperation.RunAsync(receiveArgs, socket.ReceiveFromAsync).ConfigureAwait(false) != + SocketError.Success) + return null; + + if (receiveArgs.RemoteEndPoint is not IPEndPoint remoteEndPoint || + IPAddressHelper.CompareIPAddresses(remoteEndPoint.Address, ipAddress) != 0) + continue; + + return buffer[..receiveArgs.BytesTransferred]; + } + } +} From 255ff79f3190b899fa293d7ef357beb159341957 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:56:17 +0100 Subject: [PATCH 011/131] Feature: IP Scanner: Resolve mDNS hostname (C1) New settings IPScanner_MDNSEnabled (default off) and IPScanner_MDNSTimeout (2000 ms). For reachable hosts, a reverse lookup (PTR) is sent as unicast query to UDP 5353 of the host (MDNSResolver, raw UDP via UdpRequest, exception-free); the answer (legacy unicast response) is parsed incl. name compression. Shown in the new mDNS hostname column and used as Hostname (DNS -> mDNS -> NetBIOS); not passed to other tools (like NetBIOS). Search and export (MDNSHostname, last column). Unit tests for the query / response parsing. Co-Authored-By: Claude Opus 5.5 --- .../Resources/Strings.Designer.cs | 36 +++ .../Resources/Strings.resx | 12 + .../Export/ExportManager.IPScannerHostInfo.cs | 13 +- .../Network/IPScanner.cs | 26 ++- .../Network/IPScannerHostInfo.cs | 26 ++- .../Network/IPScannerOptions.cs | 10 + .../Network/IPScannerResultOptions.cs | 7 +- .../Network/MDNSResolver.cs | 210 ++++++++++++++++++ .../Network/UdpRequest.cs | 4 +- .../GlobalStaticConfiguration.cs | 2 + .../NETworkManager.Settings/SettingsInfo.cs | 26 +++ .../Network/MDNSResolverTests.cs | 144 ++++++++++++ .../ViewModels/IPScannerSettingsViewModel.cs | 40 ++++ .../ViewModels/IPScannerViewModel.cs | 7 +- .../Views/IPScannerSettingsView.xaml | 15 ++ .../NETworkManager/Views/IPScannerView.xaml | 9 + Website/docs/application/ip-scanner.md | 24 +- Website/docs/changelog/next-release.md | 1 + 18 files changed, 594 insertions(+), 18 deletions(-) create mode 100644 Source/NETworkManager.Models/Network/MDNSResolver.cs create mode 100644 Source/NETworkManager.Tests/Network/MDNSResolverTests.cs diff --git a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs index 79c4bfc756..c9ff5056d2 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs +++ b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs @@ -6176,6 +6176,15 @@ public static string HelpMessage_RDPAuthenticationLevel { } } + /// + /// Looks up a localized string similar to Resolve the mDNS hostname (e.g. macbook.local) of every reachable host with a reverse lookup sent to UDP port 5353 of the host. Answered by Apple devices, Linux (Avahi), Android, printers and many IoT devices, usually only in the local subnet. Used as hostname if there is no DNS hostname.. + /// + public static string HelpMessage_ResolveMDNSHostname { + get { + return ResourceManager.GetString("HelpMessage_ResolveMDNSHostname", resourceCulture); + } + } + /// /// Looks up a localized string similar to Displays a dialog to save credentials after a successful sign-in.. /// @@ -7833,6 +7842,24 @@ public static string MaxPortThreads { } } + /// + /// Looks up a localized string similar to mDNS. + /// + public static string MDNS { + get { + return ResourceManager.GetString("MDNS", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to mDNS hostname. + /// + public static string MDNSHostname { + get { + return ResourceManager.GetString("MDNSHostname", resourceCulture); + } + } + /// /// Looks up a localized string similar to Measured time. /// @@ -11607,6 +11634,15 @@ public static string ResolveMACAddressIsDisabled { } } + /// + /// Looks up a localized string similar to Resolve mDNS hostname. + /// + public static string ResolveMDNSHostname { + get { + return ResourceManager.GetString("ResolveMDNSHostname", resourceCulture); + } + } + /// /// Looks up a localized string similar to Resolve PTR. /// diff --git a/Source/NETworkManager.Localization/Resources/Strings.resx b/Source/NETworkManager.Localization/Resources/Strings.resx index b5ba69afb8..dd6c05836a 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.resx +++ b/Source/NETworkManager.Localization/Resources/Strings.resx @@ -5215,4 +5215,16 @@ Do you want to start the scan? This computer + + mDNS + + + mDNS hostname + + + Resolve mDNS hostname + + + Resolve the mDNS hostname (e.g. macbook.local) of every reachable host with a reverse lookup sent to UDP port 5353 of the host. Answered by Apple devices, Linux (Avahi), Android, printers and many IoT devices, usually only in the local subnet. Used as hostname if there is no DNS hostname. + \ No newline at end of file diff --git a/Source/NETworkManager.Models/Export/ExportManager.IPScannerHostInfo.cs b/Source/NETworkManager.Models/Export/ExportManager.IPScannerHostInfo.cs index 111267ff64..5033488f52 100644 --- a/Source/NETworkManager.Models/Export/ExportManager.IPScannerHostInfo.cs +++ b/Source/NETworkManager.Models/Export/ExportManager.IPScannerHostInfo.cs @@ -77,7 +77,8 @@ private static void CreateCsv(IEnumerable collection, IPScann $"{nameof(IPScannerHostInfo.DNSHostname)}," + $"{nameof(IPScannerHostInfo.DetectedBy)}," + $"{nameof(IPScannerHostInfo.OSGuess)}," + - $"{nameof(IPScannerHostInfo.Role)}" + $"{nameof(IPScannerHostInfo.Role)}," + + $"{nameof(IPScannerHostInfo.MDNSHostname)}" ); foreach (var info in collection) @@ -123,7 +124,8 @@ private static void CreateCsv(IEnumerable collection, IPScann $"{CsvHelper.QuoteString(info.DNSHostname)}," + $"{CsvHelper.QuoteString(GetDetectedBy(info))}," + $"{GetOSGuess(info, options)}," + - $"{CsvHelper.QuoteString(GetRole(info))}" + $"{CsvHelper.QuoteString(GetRole(info))}," + + $"{CsvHelper.QuoteString(options.MDNSEnabled ? info.MDNSHostname : null)}" ); } @@ -182,7 +184,9 @@ from info in collection new XElement(nameof(IPScannerHostInfo.DNSHostname), info.DNSHostname), new XElement(nameof(IPScannerHostInfo.DetectedBy), GetDetectedBy(info)), new XElement(nameof(IPScannerHostInfo.OSGuess), GetOSGuess(info, options)), - new XElement(nameof(IPScannerHostInfo.Role), GetRole(info)) + new XElement(nameof(IPScannerHostInfo.Role), GetRole(info)), + new XElement(nameof(IPScannerHostInfo.MDNSHostname), + options.MDNSEnabled ? info.MDNSHostname : null) ) ) ) @@ -255,7 +259,8 @@ private static void CreateJson(IReadOnlyList collection, IPSc info.Vendor, DetectedBy = GetDetectedBy(info), OSGuess = GetOSGuess(info, options), - Role = GetRole(info) + Role = GetRole(info), + MDNSHostname = options.MDNSEnabled ? info.MDNSHostname : null }; } diff --git a/Source/NETworkManager.Models/Network/IPScanner.cs b/Source/NETworkManager.Models/Network/IPScanner.cs index bd783dfacb..e09e2a07b8 100644 --- a/Source/NETworkManager.Models/Network/IPScanner.cs +++ b/Source/NETworkManager.Models/Network/IPScanner.cs @@ -476,9 +476,10 @@ private IPScannerHostInfo CreateHostInfo(IPAddress ipAddress, IPScannerDetection } /// - /// Enriches a found host with the DNS hostname (PTR), MAC address and vendor. Each result is raised as - /// as soon as it arrives; the task completes only when all lookups are finished, so - /// the host (and the scan) is never finished while a lookup is still running. + /// Enriches a found host with the DNS hostname (PTR), MAC address and vendor and the mDNS hostname (only + /// reachable hosts). Each result is raised as as soon as it arrives; the task + /// completes only when all lookups are finished, so the host (and the scan) is never finished while a lookup is + /// still running. /// /// IP address of the host. /// Result of the host, already raised in . @@ -497,6 +498,15 @@ async Task UpdateHostnameAsync() OnHostUpdated(new IPScannerHostUpdatedArgs(host, x => x.DNSHostname = dnsHostname)); } + async Task UpdateMDNSHostnameAsync() + { + var mdnsHostname = await MDNSResolver.ResolveAsync(ipAddress, options.MDNSTimeout, cancellationToken) + .ConfigureAwait(false); + + if (!string.IsNullOrEmpty(mdnsHostname)) + OnHostUpdated(new IPScannerHostUpdatedArgs(host, x => x.MDNSHostname = mdnsHostname)); + } + async Task UpdateMACAddressAsync() { var (macAddress, vendor) = await ResolveMACAddressAsync(ipAddress, discoveredAt, networkInterfaces, @@ -510,11 +520,15 @@ async Task UpdateMACAddressAsync() })); } - // DNS and MAC address lookup don't depend on each other - run them concurrently, the host is finished - // after the slower one instead of after both + // Requests sent to the host (e.g. mDNS) only for reachable hosts + var isReachable = discoveredAt != DateTime.MinValue; + + // The lookups don't depend on each other - run them concurrently, the host is finished after the slowest one + // instead of after all of them await Task.WhenAll( options.ResolveHostname ? UpdateHostnameAsync() : Task.CompletedTask, - options.ResolveMACAddress ? UpdateMACAddressAsync() : Task.CompletedTask).ConfigureAwait(false); + options.ResolveMACAddress ? UpdateMACAddressAsync() : Task.CompletedTask, + options.MDNSEnabled && isReachable ? UpdateMDNSHostnameAsync() : Task.CompletedTask).ConfigureAwait(false); } /// diff --git a/Source/NETworkManager.Models/Network/IPScannerHostInfo.cs b/Source/NETworkManager.Models/Network/IPScannerHostInfo.cs index 39faac1145..77d799f987 100644 --- a/Source/NETworkManager.Models/Network/IPScannerHostInfo.cs +++ b/Source/NETworkManager.Models/Network/IPScannerHostInfo.cs @@ -133,11 +133,31 @@ internal set } /// - /// Hostname of the host: DNS hostname, fallback to the NetBIOS computer name. + /// Hostname of the host: DNS hostname, fallback to the mDNS hostname and the NetBIOS computer name. /// public string Hostname => !string.IsNullOrEmpty(DNSHostname) ? DNSHostname - : NetBIOSInfo?.ComputerName ?? string.Empty; + : !string.IsNullOrEmpty(MDNSHostname) + ? MDNSHostname + : NetBIOSInfo?.ComputerName ?? string.Empty; + + /// + /// mDNS hostname of the host (e.g. "macbook.local", see ), empty if not resolved + /// (yet). + /// + public string MDNSHostname + { + get; + internal set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(Hostname)); + } + } = string.Empty; /// /// DNS hostname of the host (PTR record), empty if not resolved (yet). @@ -326,6 +346,7 @@ public void CopyFrom(IPScannerHostInfo source) IsPortScanPending = source.IsPortScanPending; IsNetBIOSPending = source.IsNetBIOSPending; DNSHostname = source.DNSHostname; + MDNSHostname = source.MDNSHostname; NeighborMACAddress = source.NeighborMACAddress; NeighborVendor = source.NeighborVendor; } @@ -345,6 +366,7 @@ public IPScannerHostInfo Clone() IsPortScanPending = IsPortScanPending, IsNetBIOSPending = IsNetBIOSPending, DNSHostname = DNSHostname, + MDNSHostname = MDNSHostname, NeighborMACAddress = NeighborMACAddress, NeighborVendor = NeighborVendor }; diff --git a/Source/NETworkManager.Models/Network/IPScannerOptions.cs b/Source/NETworkManager.Models/Network/IPScannerOptions.cs index b467a8c495..6b55d048e9 100644 --- a/Source/NETworkManager.Models/Network/IPScannerOptions.cs +++ b/Source/NETworkManager.Models/Network/IPScannerOptions.cs @@ -88,6 +88,16 @@ public record IPScannerOptions /// public required bool ResolveMACAddress { get; init; } + /// + /// Resolve the mDNS hostname of reachable hosts, see . + /// + public required bool MDNSEnabled { get; init; } + + /// + /// Timeout in milliseconds after which an mDNS request is considered lost. + /// + public required int MDNSTimeout { get; init; } + /// /// Include unreachable IP addresses in the result. /// diff --git a/Source/NETworkManager.Models/Network/IPScannerResultOptions.cs b/Source/NETworkManager.Models/Network/IPScannerResultOptions.cs index dbe06ceafa..282642adc7 100644 --- a/Source/NETworkManager.Models/Network/IPScannerResultOptions.cs +++ b/Source/NETworkManager.Models/Network/IPScannerResultOptions.cs @@ -10,20 +10,23 @@ /// The ports of the hosts were scanned. /// The NetBIOS information was requested. /// The MAC address and vendor were resolved. +/// The mDNS hostname was resolved. public sealed record IPScannerResultOptions( bool ICMPEnabled, bool OSGuessEnabled, bool ResolveHostname, bool PortScanEnabled, bool NetBIOSEnabled, - bool ResolveMACAddress) + bool ResolveMACAddress, + bool MDNSEnabled) { /// /// Creates the result options from the options of a scan. /// /// Options of the scan. public IPScannerResultOptions(IPScannerOptions options) - : this(options.ICMPEnabled, options.OSGuessEnabled, options.ResolveHostname, options.PortScanEnabled, options.NetBIOSEnabled, options.ResolveMACAddress) + : this(options.ICMPEnabled, options.OSGuessEnabled, options.ResolveHostname, options.PortScanEnabled, options.NetBIOSEnabled, options.ResolveMACAddress, + options.MDNSEnabled) { } } diff --git a/Source/NETworkManager.Models/Network/MDNSResolver.cs b/Source/NETworkManager.Models/Network/MDNSResolver.cs new file mode 100644 index 0000000000..f7d98acdd5 --- /dev/null +++ b/Source/NETworkManager.Models/Network/MDNSResolver.cs @@ -0,0 +1,210 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace NETworkManager.Models.Network; + +/// +/// Resolves the mDNS hostname (e.g. "macbook.local") of a host: reverse lookup (PTR) sent as unicast query +/// to UDP port 5353 of the host (RFC 6762: a query from a port other than 5353 is answered with a unicast +/// response to the sender, which repeats the ID and the question). Answered by Apple devices, Linux (Avahi), +/// Android, printers and many IoT devices - usually only for requests from the local subnet. +/// +public static class MDNSResolver +{ + private const int MDNSPort = 5353; + + private const ushort TypePTR = 12; + private const ushort ClassIN = 1; + + // Maximum number of compression pointers followed in a name (protection against loops) + private const int MaxCompressionPointers = 32; + + /// + /// Resolves the mDNS hostname of a host. + /// + /// IP address of the host. + /// Timeout in milliseconds to wait for the answer. + /// Token to monitor for cancellation requests. + /// Hostname without trailing dot (e.g. "macbook.local"), empty if the host didn't answer. + public static async Task ResolveAsync(IPAddress ipAddress, int timeout, + CancellationToken cancellationToken) + { + try + { + var id = (ushort)Random.Shared.Next(1, ushort.MaxValue); + + var response = await UdpRequest.SendAsync(ipAddress, MDNSPort, CreateReverseQuery(ipAddress, id), + timeout, cancellationToken).ConfigureAwait(false); + + return response == null ? string.Empty : ParsePTRResponse(response, id); + } + catch (Exception) + { + return string.Empty; + } + } + + /// + /// Gets the reverse lookup name of an IP address (e.g. "4.3.2.1.in-addr.arpa", nibbles + "ip6.arpa" for IPv6). + /// + /// IP address. + /// Reverse lookup name. + public static string GetReverseName(IPAddress ipAddress) + { + var bytes = ipAddress.GetAddressBytes(); + + if (ipAddress.AddressFamily == AddressFamily.InterNetwork) + return $"{bytes[3]}.{bytes[2]}.{bytes[1]}.{bytes[0]}.in-addr.arpa"; + + var builder = new StringBuilder(); + + for (var i = bytes.Length - 1; i >= 0; i--) + builder.Append($"{bytes[i] & 0x0F:x}.{bytes[i] >> 4:x}."); + + return builder.Append("ip6.arpa").ToString(); + } + + /// + /// Creates a DNS query for the PTR record of the reverse lookup name of an IP address. + /// + /// IP address to look up. + /// ID of the query (repeated in the answer). + /// DNS message. + public static byte[] CreateReverseQuery(IPAddress ipAddress, ushort id) + { + var query = new List + { + // Header: ID, flags (standard query), 1 question, no answer / authority / additional records + (byte)(id >> 8), (byte)id, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 + }; + + foreach (var label in GetReverseName(ipAddress).Split('.')) + { + query.Add((byte)label.Length); + query.AddRange(Encoding.ASCII.GetBytes(label)); + } + + query.AddRange([0x00, TypePTR >> 8, TypePTR & 0xFF, ClassIN >> 8, ClassIN & 0xFF]); + + return [.. query]; + } + + /// + /// Gets the hostname from the answer to : the first PTR record of the + /// answer section. + /// + /// DNS message of the answer. + /// ID of the query. + /// + /// Hostname without trailing dot, empty if the answer is invalid or contains no PTR record. + /// + public static string ParsePTRResponse(byte[] response, ushort id) + { + // Header (12 bytes), must be a response (QR bit) to the query (ID, some responders use 0) + if (response.Length < 12 || (response[2] & 0x80) == 0) + return string.Empty; + + var responseId = (ushort)((response[0] << 8) | response[1]); + + if (responseId != id && responseId != 0) + return string.Empty; + + var questions = (response[4] << 8) | response[5]; + var answers = (response[6] << 8) | response[7]; + + var offset = 12; + + // Skip the question(s): name, type, class + for (var i = 0; i < questions; i++) + { + if (ReadName(response, ref offset) == null || offset + 4 > response.Length) + return string.Empty; + + offset += 4; + } + + for (var i = 0; i < answers; i++) + { + // Name, type, class (cache flush bit in mDNS), TTL, data length, data + if (ReadName(response, ref offset) == null || offset + 10 > response.Length) + return string.Empty; + + var type = (response[offset] << 8) | response[offset + 1]; + var dataLength = (response[offset + 8] << 8) | response[offset + 9]; + + offset += 10; + + if (offset + dataLength > response.Length) + return string.Empty; + + if (type == TypePTR) + { + var dataOffset = offset; + + return ReadName(response, ref dataOffset) ?? string.Empty; + } + + offset += dataLength; + } + + return string.Empty; + } + + /// + /// Reads a (compressed) domain name at and moves the offset behind it. + /// + /// DNS message. + /// Offset of the name, behind the name afterwards. + /// Name without trailing dot, null if the name is invalid. + private static string ReadName(byte[] message, ref int offset) + { + var labels = new List(); + var position = offset; + var endOffset = -1; + var pointers = 0; + + while (true) + { + if (position >= message.Length) + return null; + + var length = message[position]; + + // End of the name + if (length == 0) + { + position++; + break; + } + + // Compression pointer (2 bytes) to a name earlier in the message + if ((length & 0xC0) == 0xC0) + { + if (position + 1 >= message.Length || ++pointers > MaxCompressionPointers) + return null; + + // The name ends after the first pointer + if (endOffset < 0) + endOffset = position + 2; + + position = ((length & 0x3F) << 8) | message[position + 1]; + continue; + } + + if ((length & 0xC0) != 0 || position + 1 + length > message.Length) + return null; + + labels.Add(Encoding.UTF8.GetString(message, position + 1, length)); + position += 1 + length; + } + + offset = endOffset >= 0 ? endOffset : position; + + return string.Join(".", labels); + } +} diff --git a/Source/NETworkManager.Models/Network/UdpRequest.cs b/Source/NETworkManager.Models/Network/UdpRequest.cs index e0681c52f2..d25a11d812 100644 --- a/Source/NETworkManager.Models/Network/UdpRequest.cs +++ b/Source/NETworkManager.Models/Network/UdpRequest.cs @@ -18,8 +18,8 @@ public static class UdpRequest private const int ResponseBufferSize = 8192; /// - /// Sends to of the host and returns the first datagram the - /// host answers with. Datagrams from other addresses (e.g. a stray or spoofed one) are ignored. + /// Sends to of the host and returns the first datagram + /// the host answers with. Datagrams from other addresses (e.g. a stray or spoofed one) are ignored. /// /// IP address of the host. /// UDP port of the host. diff --git a/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs b/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs index 313bf64408..e1d6119d06 100644 --- a/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs +++ b/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs @@ -164,6 +164,8 @@ public static class GlobalStaticConfiguration public static int IPScanner_NetBIOSTimeout => 2000; public static bool IPScanner_ARPEnabled => true; public static bool IPScanner_ResolveMACAddress => true; + public static bool IPScanner_MDNSEnabled => false; + public static int IPScanner_MDNSTimeout => 2000; public static bool IPScanner_ExpandHostView => false; public static ExportFileType IPScanner_ExportFileType => ExportFileType.Csv; // Larger scans need a confirmation, even larger ones are refused (every IP address / port is kept in memory) diff --git a/Source/NETworkManager.Settings/SettingsInfo.cs b/Source/NETworkManager.Settings/SettingsInfo.cs index a3f9c30476..3c6fcb757d 100644 --- a/Source/NETworkManager.Settings/SettingsInfo.cs +++ b/Source/NETworkManager.Settings/SettingsInfo.cs @@ -1416,6 +1416,32 @@ public bool IPScanner_ResolveMACAddress } } = GlobalStaticConfiguration.IPScanner_ResolveMACAddress; + public bool IPScanner_MDNSEnabled + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } = GlobalStaticConfiguration.IPScanner_MDNSEnabled; + + public int IPScanner_MDNSTimeout + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } = GlobalStaticConfiguration.IPScanner_MDNSTimeout; + public ObservableCollection IPScanner_CustomCommands { get; diff --git a/Source/NETworkManager.Tests/Network/MDNSResolverTests.cs b/Source/NETworkManager.Tests/Network/MDNSResolverTests.cs new file mode 100644 index 0000000000..7b8d23db76 --- /dev/null +++ b/Source/NETworkManager.Tests/Network/MDNSResolverTests.cs @@ -0,0 +1,144 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Text; +using NETworkManager.Models.Network; +using Xunit; + +namespace NETworkManager.Tests.Network; + +public class MDNSResolverTests +{ + private const ushort Id = 0x1234; + + private static byte[] EncodeName(string name) + { + var bytes = new List(); + + foreach (var label in name.Split('.')) + { + bytes.Add((byte)label.Length); + bytes.AddRange(Encoding.ASCII.GetBytes(label)); + } + + bytes.Add(0); + + return [.. bytes]; + } + + // Legacy unicast response: question repeated, answer name as compression pointer to the question (offset 12) + private static byte[] CreateResponse(ushort id, string hostname, ushort type = 12, bool compressed = true) + { + var question = CreateQuestion(); + + var response = new List + { + (byte)(id >> 8), (byte)id, 0x84, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00 + }; + + response.AddRange(question); + + // Answer: name (pointer to the question name), type, class (cache flush), TTL, data length, data + response.AddRange(compressed ? [0xC0, 0x0C] : question[..^4]); + response.AddRange([(byte)(type >> 8), (byte)type, 0x80, 0x01, 0x00, 0x00, 0x00, 0x78]); + + var data = EncodeName(hostname); + + response.AddRange([(byte)(data.Length >> 8), (byte)data.Length]); + response.AddRange(data); + + return [.. response]; + } + + private static byte[] CreateQuestion() + { + return [.. EncodeName("10.1.168.192.in-addr.arpa"), 0x00, 0x0C, 0x00, 0x01]; + } + + [Theory] + [InlineData("192.168.1.10", "10.1.168.192.in-addr.arpa")] + [InlineData("2001:db8::1", + "1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa")] + public void GetReverseName(string ipAddress, string expected) + { + Assert.Equal(expected, MDNSResolver.GetReverseName(IPAddress.Parse(ipAddress))); + } + + [Fact] + public void CreateReverseQuery() + { + var query = MDNSResolver.CreateReverseQuery(IPAddress.Parse("192.168.1.10"), Id); + + Assert.Equal(new byte[] { 0x12, 0x34, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, + query[..12]); + Assert.Equal(CreateQuestion(), query[12..]); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ParsePTRResponse(bool compressed) + { + Assert.Equal("macbook.local", + MDNSResolver.ParsePTRResponse(CreateResponse(Id, "macbook.local", compressed: compressed), Id)); + } + + [Fact] + public void ParsePTRResponse_IdZero_Accepted() + { + Assert.Equal("printer.local", MDNSResolver.ParsePTRResponse(CreateResponse(0, "printer.local"), Id)); + } + + [Fact] + public void ParsePTRResponse_OtherId_Empty() + { + Assert.Equal(string.Empty, MDNSResolver.ParsePTRResponse(CreateResponse(0x9999, "macbook.local"), Id)); + } + + [Fact] + public void ParsePTRResponse_NoPTR_Empty() + { + Assert.Equal(string.Empty, MDNSResolver.ParsePTRResponse(CreateResponse(Id, "macbook.local", 1), Id)); + } + + [Fact] + public void ParsePTRResponse_Query_Empty() + { + var query = MDNSResolver.CreateReverseQuery(IPAddress.Parse("192.168.1.10"), Id); + + Assert.Equal(string.Empty, MDNSResolver.ParsePTRResponse(query, Id)); + } + + [Theory] + [InlineData(0)] + [InlineData(5)] + [InlineData(20)] + [InlineData(60)] + public void ParsePTRResponse_Truncated_Empty(int length) + { + var response = CreateResponse(Id, "macbook.local"); + + Assert.Equal(string.Empty, MDNSResolver.ParsePTRResponse(response[..length], Id)); + } + + [Fact] + public void ParsePTRResponse_PointerLoop_Empty() + { + // Answer name points to itself + var response = CreateResponse(Id, "macbook.local"); + var answerOffset = 12 + CreateQuestion().Length; + + response[answerOffset] = 0xC0; + response[answerOffset + 1] = (byte)answerOffset; + + Assert.Equal(string.Empty, MDNSResolver.ParsePTRResponse(response, Id)); + } + + [Fact] + public void ParsePTRResponse_TrailingGarbage_Ignored() + { + var response = CreateResponse(Id, "macbook.local").Concat(new byte[] { 0xFF, 0xFF }).ToArray(); + + Assert.Equal("macbook.local", MDNSResolver.ParsePTRResponse(response, Id)); + } +} diff --git a/Source/NETworkManager/ViewModels/IPScannerSettingsViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerSettingsViewModel.cs index 882a3fd71c..d52cd37714 100644 --- a/Source/NETworkManager/ViewModels/IPScannerSettingsViewModel.cs +++ b/Source/NETworkManager/ViewModels/IPScannerSettingsViewModel.cs @@ -330,6 +330,44 @@ public bool ResolveMACAddress } } + /// + /// Gets or sets a value indicating whether the mDNS hostname is resolved. + /// + public bool MDNSEnabled + { + get; + set + { + if (value == field) + return; + + if (!_isLoading) + SettingsManager.Current.IPScanner_MDNSEnabled = value; + + field = value; + OnPropertyChanged(); + } + } + + /// + /// Gets or sets the mDNS timeout in milliseconds. + /// + public int MDNSTimeout + { + get; + set + { + if (value == field) + return; + + if (!_isLoading) + SettingsManager.Current.IPScanner_MDNSTimeout = value; + + field = value; + OnPropertyChanged(); + } + } + /// /// Gets the collection view of custom commands. /// @@ -446,6 +484,8 @@ private void LoadSettings() NetBIOSTimeout = SettingsManager.Current.IPScanner_NetBIOSTimeout; ARPEnabled = SettingsManager.Current.IPScanner_ARPEnabled; ResolveMACAddress = SettingsManager.Current.IPScanner_ResolveMACAddress; + MDNSEnabled = SettingsManager.Current.IPScanner_MDNSEnabled; + MDNSTimeout = SettingsManager.Current.IPScanner_MDNSTimeout; MaxHostThreads = SettingsManager.Current.IPScanner_MaxHostThreads; MaxPortThreads = SettingsManager.Current.IPScanner_MaxPortThreads; } diff --git a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs index 5ddc8023cd..cfd997ea53 100644 --- a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs @@ -452,6 +452,7 @@ public IPScannerViewModel(Guid tabId, string hostOrIPRange, bool start = true) return Matches(info.PingInfo.IPAddress.ToString()) || Matches(info.Hostname) || + Matches(info.MDNSHostname) || Matches(info.MACAddress) || Matches(info.Vendor) || (ScanOptions.OSGuessEnabled && @@ -465,6 +466,7 @@ public IPScannerViewModel(Guid tabId, string hostOrIPRange, bool start = true) // while searching, see ApplySearch. var liveShaping = (ICollectionViewLiveShaping)ResultsView; liveShaping.LiveFilteringProperties.Add(nameof(IPScannerHostInfo.Hostname)); + liveShaping.LiveFilteringProperties.Add(nameof(IPScannerHostInfo.MDNSHostname)); liveShaping.LiveFilteringProperties.Add(nameof(IPScannerHostInfo.MACAddress)); liveShaping.LiveFilteringProperties.Add(nameof(IPScannerHostInfo.Vendor)); liveShaping.LiveFilteringProperties.Add(nameof(IPScannerHostInfo.Ports)); @@ -982,6 +984,8 @@ private static IPScannerOptions CreateScanOptions(int[] ports) NetBIOSTimeout = SettingsManager.Current.IPScanner_NetBIOSTimeout, ARPEnabled = SettingsManager.Current.IPScanner_ARPEnabled, ResolveMACAddress = SettingsManager.Current.IPScanner_ResolveMACAddress, + MDNSEnabled = SettingsManager.Current.IPScanner_MDNSEnabled, + MDNSTimeout = SettingsManager.Current.IPScanner_MDNSTimeout, ShowAllResults = SettingsManager.Current.IPScanner_ShowAllResults, ReportAllHosts = false }; @@ -1010,7 +1014,8 @@ private static IPScannerResultOptions CreateResultOptions() SettingsManager.Current.IPScanner_ResolveHostname, SettingsManager.Current.IPScanner_PortScanEnabled, SettingsManager.Current.IPScanner_NetBIOSEnabled, - SettingsManager.Current.IPScanner_ResolveMACAddress); + SettingsManager.Current.IPScanner_ResolveMACAddress, + SettingsManager.Current.IPScanner_MDNSEnabled); } /// diff --git a/Source/NETworkManager/Views/IPScannerSettingsView.xaml b/Source/NETworkManager/Views/IPScannerSettingsView.xaml index 2dc226e768..bbc27fe972 100644 --- a/Source/NETworkManager/Views/IPScannerSettingsView.xaml +++ b/Source/NETworkManager/Views/IPScannerSettingsView.xaml @@ -135,6 +135,21 @@ + + + + + + + + **IP address** / **Hostname** | Looks up the IP address (`PTR`) or the DNS hostname in [DNS Lookup](./dns-lookup) | | **RDAP...** > **IP address** / **Hostname** | Queries the registration data of the IP address or the registered domain of the DNS hostname in [RDAP](./rdap) | | **Remote Desktop** / **PowerShell** / **PuTTY** / **TigerVNC** / **SNMP** | Opens the host (DNS hostname if resolved, otherwise the IP address) in the tool | -| **Web Console** | Opens the host in the [Web Console](./web-console): https if port `443` is open, otherwise http on `80`, `8443` (https) or `8080`, http if none of them is open (or the port scan is disabled) | +| **Web Console** | Opens the host in the [Web Console](./web-console): the port of the web page whose title was read (see [Read the title of web pages](#read-the-title-of-web-pages)), otherwise https if port `443` is open, http on `80`, `8443` (https) or `8080`, http if none of them is open (or the port scan is disabled) | | **Wake on LAN** | Sends a magic packet to the MAC address of the host with [Wake on LAN](./wake-on-lan) (only with a MAC address) | | **Custom commands** | Executes a [custom command](#custom-commands) for the host | | **Add profile...** | Creates a new profile with the host (IP address), the hostname as name and the MAC address for Wake on LAN | @@ -100,7 +100,7 @@ The probes a host answered are shown in the tooltip of its status icon (e.g. `Up Only reachable hosts are shown, unless [Show unreachable IP addresses and ports](#show-unreachable-ip-addresses-and-ports) is enabled. A message below the input is shown if no reachable host was found, the scan was canceled, a hostname could not be resolved or an error occurred. -The search box right of the **Result** header filters the results by IP address, hostname, MAC address, vendor, OS (guess) and open ports (number or service), also while the scan is running (hosts whose hostname or MAC address arrives later are filtered again). **Export...** > all exports the filtered results; the up / down counters always count all hosts. Results can also be exported while the scan is running; values of probes that are still running (shown as `...`) are exported as empty values. +The search box right of the **Result** header filters the results by IP address, hostname, MAC address, vendor, OS (guess), mDNS hostname, web title and open ports (number or service), also while the scan is running (hosts whose hostname or MAC address arrives later are filtered again). **Export...** > all exports the filtered results; the up / down counters always count all hosts. Results can also be exported while the scan is running; values of probes that are still running (shown as `...`) are exported as empty values. Expand a row with the button in front of it to see its details, or enable [Expand host view](#expand-host-view) to expand all hosts. @@ -357,6 +357,28 @@ Timeout in milliseconds after which an mDNS request is considered lost. **Default:** `2000` +### Read the title of web pages + +Read the title of the web page (`GET /`) of every reachable host with an open web port and show it in the **Web title** column (the URL in the tooltip). The ports are tried in the order `443`, `80`, `8443`, `8080` (https on `443` / `8443`), the first one that answers is used; redirects on the same host (e.g. http to https, `/` to `/login.html`) are followed. If the page has no title, the `Server` header is shown. **Web Console** in the context menu opens the port that answered. + +**Type:** `Boolean` + +**Default:** `Disabled` + +:::note + +Needs the [port scan](#scan-ports) with the web ports in the [Ports](#ports) list. No cookies or credentials are sent, no proxy is used and certificate errors are accepted (only the title is read). The request appears in the log of the web server. At most 64 KB of the page are read. + +::: + +### Timeout (ms) + +Timeout in milliseconds of a web request (per port and redirect). + +**Type:** `Integer` [Min `100`, Max `15000`] + +**Default:** `2000` + ### Custom commands Custom commands that can be executed with a right click on the selected result. diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index 4b0475f612..6e6f1119b8 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -93,6 +93,7 @@ Release date: **xx.xx.2026** - New **OS (guess)** column with the operating system guessed from the TTL of the ping reply (`Linux / Unix`, `Windows` or `Network device`, the tooltip shows the number of hops), can be disabled with the new setting **Guess the operating system from the TTL** (default on). No additional packets are sent; IPv4 only. Also in the search, **Copy** and the export (`OSGuess`). [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) - An icon in the result marks this computer, the default gateway and the DNS and DHCP servers of the connected network interfaces (all roles in the tooltip), e.g. to find the router in a `/24`. Also in the search and the export (`Role`). [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) - New **Resolve mDNS hostname** setting (default off, with **Timeout**): the mDNS hostname (e.g. `macbook.local`) of reachable hosts is resolved with a reverse lookup sent to UDP port `5353` of the host (Apple devices, Linux with Avahi, Android, printers, IoT devices). Shown in the new **mDNS hostname** column and used as **Hostname** if there is no DNS hostname (before the NetBIOS name). Also in the search and the export (`MDNSHostname`). [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) +- New **Read the title of web pages** setting (default off, with **Timeout**): the title of the web page (`GET /`) of reachable hosts with an open web port (`443`, `80`, `8443`, `8080`) is shown in the new **Web title** column (URL in the tooltip, `Server` header if the page has no title), e.g. to identify routers, printers and NAS. Redirects on the same host are followed, certificate errors are accepted, no cookies or credentials are sent. **Web Console** in the context menu opens the port that answered. Also in the search and the export (`WebTitle`, `WebURL`). [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) - The **Detect local subnet** button opens a menu: **Detect local subnet** (as before), **Subnet of network interface** to select the IPv4 subnet of a specific network interface (e.g. LAN, Wi-Fi, VPN, Hyper-V) and **All local subnets** to scan the subnets of all network interfaces. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) - IP addresses can be excluded from the scan with `!` in the host input (also in profiles and the run command), e.g. `10.0.0.0/24; !10.0.0.1; !10.0.0.200-254`. Works with IP addresses, ranges, subnets and hostnames. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) From 9007e684bacd18258c0ed8ada7c3b800172bd7f5 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Fri, 30 Oct 2026 20:03:15 +0100 Subject: [PATCH 013/131] Feature: IP Scanner: Read the SNMP system information (C3) New settings IPScanner_SNMPEnabled (default off), IPScanner_SNMPVersion (v1 / v2c, default v2c), IPScanner_SNMPCommunity (default "public", saved as plain text - warning in the settings and docs) and IPScanner_SNMPTimeout (2000 ms). For reachable hosts, an SNMP GET of sysName, sysDescr and sysLocation (SNMPSystemInfoResolver: request built and parsed with SharpSnmpLib, sent via UdpRequest - exception-free) is shown in the new SNMP name / description / location columns. Search and export (SNMPName, SNMPDescription, SNMPLocation). Unit tests for the request and the response parsing. Co-Authored-By: Claude Opus 5.5 --- .../Resources/Strings.Designer.cs | 62 ++++++++- .../Resources/Strings.resx | 18 +++ .../Export/ExportManager.IPScannerHostInfo.cs | 22 ++- .../Network/IPScanner.cs | 21 ++- .../Network/IPScannerHostInfo.cs | 54 ++++++++ .../Network/IPScannerOptions.cs | 20 +++ .../Network/IPScannerResultOptions.cs | 6 +- .../Network/SNMPSystemInfo.cs | 9 ++ .../Network/SNMPSystemInfoResolver.cs | 126 ++++++++++++++++++ .../GlobalStaticConfiguration.cs | 4 + .../NETworkManager.Settings/SettingsInfo.cs | 52 ++++++++ .../Network/SNMPSystemInfoResolverTests.cs | 89 +++++++++++++ .../ViewModels/IPScannerSettingsViewModel.cs | 86 ++++++++++++ .../ViewModels/IPScannerViewModel.cs | 13 +- .../Views/IPScannerSettingsView.xaml | 36 +++++ .../NETworkManager/Views/IPScannerView.xaml | 27 ++++ Website/docs/application/ip-scanner.md | 51 ++++++- Website/docs/changelog/next-release.md | 1 + 18 files changed, 682 insertions(+), 15 deletions(-) create mode 100644 Source/NETworkManager.Models/Network/SNMPSystemInfo.cs create mode 100644 Source/NETworkManager.Models/Network/SNMPSystemInfoResolver.cs create mode 100644 Source/NETworkManager.Tests/Network/SNMPSystemInfoResolverTests.cs diff --git a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs index afdb283640..b717c5112d 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs +++ b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs @@ -5856,7 +5856,7 @@ public static string HelpMessage_AdjustThreadsAutomatically { /// ///Only the PowerShell consoles configured in the PowerShell settings are modified. Both Windows PowerShell and PWSH (PowerShell 7 and higher) are supported. /// - ///Click in the upper right corner on the help / documentation button ( [rest of string was truncated]";. + ///Click in the upper right corner on the help / documentation button (left [rest of string was truncated]";. /// public static string HelpMessage_ApplyThemeToPowerShellConsole { get { @@ -5976,7 +5976,7 @@ public static string HelpMessage_ExperimentalFeatures { } /// - /// Looks up a localized string similar to Find hosts in a local subnet that don't answer the ping, a port or NetBIOS (e.g. a firewall drops everything) by their entry in the neighbor table (ARP / NDP). To reach a host in a local subnet, Windows has to resolve its MAC address first, and a host answers this request even if its firewall drops everything else. No additional packets are sent, except to check an entry from earlier traffic again. Hosts behind a router can't be found this way.. + /// Looks up a localized string similar to Find hosts in a local subnet that don't answer the ping, a port or NetBIOS (e.g. a firewall drops everything) by their entry in the neighbor table (ARP / NDP). To reach a host in a local subnet, Windows has to resolve its MAC address first, and a host answers this request even if its firewall drops everything else. No additional packets are sent, except to check an entry from earlier traffic again. Hosts behind a router can't be found this way.. /// public static string HelpMessage_FindHostsInTheLocalSubnet { get { @@ -5994,7 +5994,7 @@ public static string HelpMessage_FirewallShowAllRules { } /// - /// Looks up a localized string similar to Guess the operating system from the TTL of the ping reply: every operating system starts with a typical TTL, 64 (Linux, Unix, macOS, Android, iOS), 128 (Windows) or 255 (network devices like routers, switches and printers), and every router on the way reduces it by one. No additional packets are sent. It's a hint, not a detection: the TTL can be changed on the host, NAT and VPN can change it. Not available for IPv6 (the ping reply has no TTL).. + /// Looks up a localized string similar to Guess the operating system from the TTL of the ping reply: every operating system starts with a typical TTL, 64 (Linux, Unix, macOS, Android, iOS), 128 (Windows) or 255 (network devices like routers, switches and printers), and every router on the way reduces it by one. No additional packets are sent. It's a hint, not a detection: the TTL can be changed on the host, NAT and VPN can change it. Not available for IPv6 (the ping reply has no TTL).. /// public static string HelpMessage_GuessTheOperatingSystemFromTheTTL { get { @@ -6176,6 +6176,15 @@ public static string HelpMessage_RDPAuthenticationLevel { } } + /// + /// Looks up a localized string similar to Read the name, description and location (sysName, sysDescr, sysLocation) of every reachable host with an SNMP v1 / v2c request to UDP port 161. A host with another community usually doesn't answer (timeout). Many intrusion detection systems report requests with common communities.. + /// + public static string HelpMessage_ReadTheSNMPSystemInformation { + get { + return ResourceManager.GetString("HelpMessage_ReadTheSNMPSystemInformation", resourceCulture); + } + } + /// /// Looks up a localized string similar to Read the title of the web page (GET /) of every reachable host with an open web port (443, 80, 8443, 8080 - the port scan must be enabled and the ports must be in the list). The first port that answers is used, redirects on the same host are followed. No cookies or credentials are sent, certificate errors are accepted. The request appears in the log of the web server. If the page has no title, the Server header is shown.. /// @@ -9655,7 +9664,7 @@ public static string Profile { /// /// Looks up a localized string similar to Profile files are encrypted on disk using AES with a key size of 256 bits and a block size of 128 bits in CBC mode. The encryption key is derived from a master password using Rfc2898DeriveBytes (PBKDF2) with 1,000,000 iterations. At runtime, passwords are stored as SecureString once the profile file is loaded. For some functions, the password must be converted to a normal string and remains unencrypted in memory until the garbage collector cleans them up. /// - ///First make a backup copy of your profile files be [rest of string was truncated]";. + ///First make a backup copy of your profile files befo [rest of string was truncated]";. /// public static string ProfileEncryptionDisclaimer { get { @@ -10275,6 +10284,15 @@ public static string RDAPXIsNotARegisteredDomainShowingY { } } + /// + /// Looks up a localized string similar to Read the SNMP system information. + /// + public static string ReadTheSNMPSystemInformation { + get { + return ResourceManager.GetString("ReadTheSNMPSystemInformation", resourceCulture); + } + } + /// /// Looks up a localized string similar to Read the title of web pages. /// @@ -13122,6 +13140,15 @@ public static string SNMP { } } + /// + /// Looks up a localized string similar to SNMP description. + /// + public static string SNMPDescription { + get { + return ResourceManager.GetString("SNMPDescription", resourceCulture); + } + } + /// /// Looks up a localized string similar to Authentication failed. Please check the community string!. /// @@ -13293,6 +13320,24 @@ public static string SNMPErrorCode_WrongValue { } } + /// + /// Looks up a localized string similar to SNMP location. + /// + public static string SNMPLocation { + get { + return ResourceManager.GetString("SNMPLocation", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to SNMP name. + /// + public static string SNMPName { + get { + return ResourceManager.GetString("SNMPName", resourceCulture); + } + } + /// /// Looks up a localized string similar to Authentication failure (incorrect password, community or key).. /// @@ -14817,6 +14862,15 @@ public static string WarnMessage_MultipleInstances { } } + /// + /// Looks up a localized string similar to The community is saved as plain text in the settings file (not encrypted). Use a read-only community (e.g. public), not one with write access.. + /// + public static string WarnMessage_SNMPCommunity { + get { + return ResourceManager.GetString("WarnMessage_SNMPCommunity", resourceCulture); + } + } + /// /// Looks up a localized string similar to Web Console. /// diff --git a/Source/NETworkManager.Localization/Resources/Strings.resx b/Source/NETworkManager.Localization/Resources/Strings.resx index e6e12f01b0..c098511d14 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.resx +++ b/Source/NETworkManager.Localization/Resources/Strings.resx @@ -5236,4 +5236,22 @@ Do you want to start the scan? Read the title of the web page (GET /) of every reachable host with an open web port (443, 80, 8443, 8080 - the port scan must be enabled and the ports must be in the list). The first port that answers is used, redirects on the same host are followed. No cookies or credentials are sent, certificate errors are accepted. The request appears in the log of the web server. If the page has no title, the Server header is shown. + + SNMP name + + + SNMP description + + + SNMP location + + + Read the SNMP system information + + + Read the name, description and location (sysName, sysDescr, sysLocation) of every reachable host with an SNMP v1 / v2c request to UDP port 161. A host with another community usually doesn't answer (timeout). Many intrusion detection systems report requests with common communities. + + + The community is saved as plain text in the settings file (not encrypted). Use a read-only community (e.g. public), not one with write access. + \ No newline at end of file diff --git a/Source/NETworkManager.Models/Export/ExportManager.IPScannerHostInfo.cs b/Source/NETworkManager.Models/Export/ExportManager.IPScannerHostInfo.cs index 58bfb511a9..0d0251d513 100644 --- a/Source/NETworkManager.Models/Export/ExportManager.IPScannerHostInfo.cs +++ b/Source/NETworkManager.Models/Export/ExportManager.IPScannerHostInfo.cs @@ -80,7 +80,10 @@ private static void CreateCsv(IEnumerable collection, IPScann $"{nameof(IPScannerHostInfo.Role)}," + $"{nameof(IPScannerHostInfo.MDNSHostname)}," + $"{nameof(IPScannerHostInfo.WebTitle)}," + - $"{nameof(IPScannerHostInfo.WebURL)}" + $"{nameof(IPScannerHostInfo.WebURL)}," + + $"{nameof(IPScannerHostInfo.SNMPName)}," + + $"{nameof(IPScannerHostInfo.SNMPDescription)}," + + $"{nameof(IPScannerHostInfo.SNMPLocation)}" ); foreach (var info in collection) @@ -129,7 +132,10 @@ private static void CreateCsv(IEnumerable collection, IPScann $"{CsvHelper.QuoteString(GetRole(info))}," + $"{CsvHelper.QuoteString(options.MDNSEnabled ? info.MDNSHostname : null)}," + $"{CsvHelper.QuoteString(options.HttpTitleEnabled ? info.WebTitle : null)}," + - $"{CsvHelper.QuoteString(options.HttpTitleEnabled ? info.WebURL : null)}" + $"{CsvHelper.QuoteString(options.HttpTitleEnabled ? info.WebURL : null)}," + + $"{CsvHelper.QuoteString(options.SNMPEnabled ? info.SNMPName : null)}," + + $"{CsvHelper.QuoteString(options.SNMPEnabled ? info.SNMPDescription : null)}," + + $"{CsvHelper.QuoteString(options.SNMPEnabled ? info.SNMPLocation : null)}" ); } @@ -193,7 +199,12 @@ from info in collection options.MDNSEnabled ? info.MDNSHostname : null), new XElement(nameof(IPScannerHostInfo.WebTitle), options.HttpTitleEnabled ? info.WebTitle : null), - new XElement(nameof(IPScannerHostInfo.WebURL), options.HttpTitleEnabled ? info.WebURL : null) + new XElement(nameof(IPScannerHostInfo.WebURL), options.HttpTitleEnabled ? info.WebURL : null), + new XElement(nameof(IPScannerHostInfo.SNMPName), options.SNMPEnabled ? info.SNMPName : null), + new XElement(nameof(IPScannerHostInfo.SNMPDescription), + options.SNMPEnabled ? info.SNMPDescription : null), + new XElement(nameof(IPScannerHostInfo.SNMPLocation), + options.SNMPEnabled ? info.SNMPLocation : null) ) ) ) @@ -269,7 +280,10 @@ private static void CreateJson(IReadOnlyList collection, IPSc Role = GetRole(info), MDNSHostname = options.MDNSEnabled ? info.MDNSHostname : null, WebTitle = options.HttpTitleEnabled ? info.WebTitle : null, - WebURL = options.HttpTitleEnabled ? info.WebURL : null + WebURL = options.HttpTitleEnabled ? info.WebURL : null, + SNMPName = options.SNMPEnabled ? info.SNMPName : null, + SNMPDescription = options.SNMPEnabled ? info.SNMPDescription : null, + SNMPLocation = options.SNMPEnabled ? info.SNMPLocation : null }; } diff --git a/Source/NETworkManager.Models/Network/IPScanner.cs b/Source/NETworkManager.Models/Network/IPScanner.cs index e7568b2900..c2c1c42dd2 100644 --- a/Source/NETworkManager.Models/Network/IPScanner.cs +++ b/Source/NETworkManager.Models/Network/IPScanner.cs @@ -486,8 +486,8 @@ private IPScannerHostInfo CreateHostInfo(IPAddress ipAddress, IPScannerDetection } /// - /// Enriches a found host with the DNS hostname (PTR), MAC address and vendor and the mDNS hostname (only - /// reachable hosts). Each result is raised as as soon as it arrives; the task + /// Enriches a found host with the DNS hostname (PTR), MAC address and vendor, the mDNS hostname and the SNMP + /// system information (only reachable hosts). Each result is raised as as soon as it arrives; the task /// completes only when all lookups are finished, so the host (and the scan) is never finished while a lookup is /// still running. /// @@ -517,6 +517,20 @@ async Task UpdateMDNSHostnameAsync() OnHostUpdated(new IPScannerHostUpdatedArgs(host, x => x.MDNSHostname = mdnsHostname)); } + async Task UpdateSNMPSystemInfoAsync() + { + var snmpSystemInfo = await SNMPSystemInfoResolver.ResolveAsync(ipAddress, options.SNMPVersion, + options.SNMPCommunity, options.SNMPTimeout, cancellationToken).ConfigureAwait(false); + + if (snmpSystemInfo != null) + OnHostUpdated(new IPScannerHostUpdatedArgs(host, x => + { + x.SNMPName = snmpSystemInfo.Name; + x.SNMPDescription = snmpSystemInfo.Description; + x.SNMPLocation = snmpSystemInfo.Location; + })); + } + async Task UpdateMACAddressAsync() { var (macAddress, vendor) = await ResolveMACAddressAsync(ipAddress, discoveredAt, networkInterfaces, @@ -538,7 +552,8 @@ async Task UpdateMACAddressAsync() await Task.WhenAll( options.ResolveHostname ? UpdateHostnameAsync() : Task.CompletedTask, options.ResolveMACAddress ? UpdateMACAddressAsync() : Task.CompletedTask, - options.MDNSEnabled && isReachable ? UpdateMDNSHostnameAsync() : Task.CompletedTask).ConfigureAwait(false); + options.MDNSEnabled && isReachable ? UpdateMDNSHostnameAsync() : Task.CompletedTask, + options.SNMPEnabled && isReachable ? UpdateSNMPSystemInfoAsync() : Task.CompletedTask).ConfigureAwait(false); } /// diff --git a/Source/NETworkManager.Models/Network/IPScannerHostInfo.cs b/Source/NETworkManager.Models/Network/IPScannerHostInfo.cs index cf22992c27..7f2566cc02 100644 --- a/Source/NETworkManager.Models/Network/IPScannerHostInfo.cs +++ b/Source/NETworkManager.Models/Network/IPScannerHostInfo.cs @@ -361,6 +361,54 @@ internal set } } = string.Empty; + /// + /// Name of the host read via SNMP (sysName), empty if not read (yet). + /// + public string SNMPName + { + get; + internal set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } = string.Empty; + + /// + /// Description of the host read via SNMP (sysDescr), empty if not read (yet). + /// + public string SNMPDescription + { + get; + internal set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } = string.Empty; + + /// + /// Location of the host read via SNMP (sysLocation), empty if not read (yet). + /// + public string SNMPLocation + { + get; + internal set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } = string.Empty; + /// /// Takes over all values of another result of the same host (e.g. a rescan), so the shown row keeps its /// identity (selection, expanded details). Call it on the thread that owns the host (UI thread). @@ -382,6 +430,9 @@ public void CopyFrom(IPScannerHostInfo source) MDNSHostname = source.MDNSHostname; WebTitle = source.WebTitle; WebURL = source.WebURL; + SNMPName = source.SNMPName; + SNMPDescription = source.SNMPDescription; + SNMPLocation = source.SNMPLocation; NeighborMACAddress = source.NeighborMACAddress; NeighborVendor = source.NeighborVendor; } @@ -404,6 +455,9 @@ public IPScannerHostInfo Clone() MDNSHostname = MDNSHostname, WebTitle = WebTitle, WebURL = WebURL, + SNMPName = SNMPName, + SNMPDescription = SNMPDescription, + SNMPLocation = SNMPLocation, NeighborMACAddress = NeighborMACAddress, NeighborVendor = NeighborVendor }; diff --git a/Source/NETworkManager.Models/Network/IPScannerOptions.cs b/Source/NETworkManager.Models/Network/IPScannerOptions.cs index 163a0d59de..766b3a632d 100644 --- a/Source/NETworkManager.Models/Network/IPScannerOptions.cs +++ b/Source/NETworkManager.Models/Network/IPScannerOptions.cs @@ -109,6 +109,26 @@ public record IPScannerOptions /// public required int HttpTitleTimeout { get; init; } + /// + /// Read the SNMP system information of reachable hosts, see . + /// + public required bool SNMPEnabled { get; init; } + + /// + /// SNMP version ( or ). + /// + public required SNMPVersion SNMPVersion { get; init; } + + /// + /// SNMP community. + /// + public required string SNMPCommunity { get; init; } + + /// + /// Timeout in milliseconds after which an SNMP request is considered lost. + /// + public required int SNMPTimeout { get; init; } + /// /// Include unreachable IP addresses in the result. /// diff --git a/Source/NETworkManager.Models/Network/IPScannerResultOptions.cs b/Source/NETworkManager.Models/Network/IPScannerResultOptions.cs index 29735d2197..127f32db20 100644 --- a/Source/NETworkManager.Models/Network/IPScannerResultOptions.cs +++ b/Source/NETworkManager.Models/Network/IPScannerResultOptions.cs @@ -12,6 +12,7 @@ /// The MAC address and vendor were resolved. /// The mDNS hostname was resolved. /// The title of the web pages was read. +/// The SNMP system information was read. public sealed record IPScannerResultOptions( bool ICMPEnabled, bool OSGuessEnabled, @@ -20,7 +21,8 @@ public sealed record IPScannerResultOptions( bool NetBIOSEnabled, bool ResolveMACAddress, bool MDNSEnabled, - bool HttpTitleEnabled) + bool HttpTitleEnabled, + bool SNMPEnabled) { /// /// Creates the result options from the options of a scan. @@ -28,7 +30,7 @@ public sealed record IPScannerResultOptions( /// Options of the scan. public IPScannerResultOptions(IPScannerOptions options) : this(options.ICMPEnabled, options.OSGuessEnabled, options.ResolveHostname, options.PortScanEnabled, options.NetBIOSEnabled, options.ResolveMACAddress, - options.MDNSEnabled, options.HttpTitleEnabled) + options.MDNSEnabled, options.HttpTitleEnabled, options.SNMPEnabled) { } } diff --git a/Source/NETworkManager.Models/Network/SNMPSystemInfo.cs b/Source/NETworkManager.Models/Network/SNMPSystemInfo.cs new file mode 100644 index 0000000000..443980680a --- /dev/null +++ b/Source/NETworkManager.Models/Network/SNMPSystemInfo.cs @@ -0,0 +1,9 @@ +namespace NETworkManager.Models.Network; + +/// +/// System information of a host read via SNMP, see . +/// +/// Name of the host (sysName). +/// Description of the host, e.g. model and firmware (sysDescr). +/// Location of the host (sysLocation). +public sealed record SNMPSystemInfo(string Name, string Description, string Location); diff --git a/Source/NETworkManager.Models/Network/SNMPSystemInfoResolver.cs b/Source/NETworkManager.Models/Network/SNMPSystemInfoResolver.cs new file mode 100644 index 0000000000..3322d83a9b --- /dev/null +++ b/Source/NETworkManager.Models/Network/SNMPSystemInfoResolver.cs @@ -0,0 +1,126 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using Lextm.SharpSnmpLib; +using Lextm.SharpSnmpLib.Messaging; +using Lextm.SharpSnmpLib.Security; + +namespace NETworkManager.Models.Network; + +/// +/// Reads the system information (sysName, sysDescr, sysLocation of the MIB-II system group) of a host with an SNMP +/// v1 / v2c GET request to UDP port 161. A host with another community usually doesn't answer at all (timeout). +/// +public static class SNMPSystemInfoResolver +{ + private const int SNMPPort = 161; + + /// + /// OID of sysName. + /// + public const string SysNameOID = "1.3.6.1.2.1.1.5.0"; + + /// + /// OID of sysDescr. + /// + public const string SysDescrOID = "1.3.6.1.2.1.1.1.0"; + + /// + /// OID of sysLocation. + /// + public const string SysLocationOID = "1.3.6.1.2.1.1.6.0"; + + /// + /// Reads the system information of a host. + /// + /// IP address of the host. + /// SNMP version ( or ). + /// Community (e.g. "public"). + /// Timeout in milliseconds to wait for the answer. + /// Token to monitor for cancellation requests. + /// System information, null if the host didn't answer or the answer is invalid. + public static async Task ResolveAsync(IPAddress ipAddress, SNMPVersion version, string community, + int timeout, CancellationToken cancellationToken) + { + try + { + var requestId = Messenger.NextRequestId; + + var request = CreateRequest(requestId, version, community); + + var response = await UdpRequest.SendAsync(ipAddress, SNMPPort, request.ToBytes(), timeout, + cancellationToken).ConfigureAwait(false); + + return response == null ? null : ParseResponse(response, requestId); + } + catch (Exception) + { + return null; + } + } + + /// + /// Creates the GET request for sysName, sysDescr and sysLocation. + /// + /// ID of the request (repeated in the answer). + /// SNMP version ( or ). + /// Community (e.g. "public"). + /// SNMP message. + public static GetRequestMessage CreateRequest(int requestId, SNMPVersion version, string community) + { + List variables = + [ + new(new ObjectIdentifier(SysNameOID)), + new(new ObjectIdentifier(SysDescrOID)), + new(new ObjectIdentifier(SysLocationOID)) + ]; + + return new GetRequestMessage(requestId, version == SNMPVersion.V1 ? VersionCode.V1 : VersionCode.V2, + new OctetString(community ?? string.Empty), variables); + } + + /// + /// Gets the system information from the answer to . + /// + /// SNMP message of the answer. + /// ID of the request. + /// System information, null if the answer is invalid, for another request or an error. + public static SNMPSystemInfo ParseResponse(byte[] response, int requestId) + { + ISnmpMessage message; + + try + { + message = MessageFactory.ParseMessages(response, new UserRegistry()).FirstOrDefault(); + } + catch (Exception) + { + return null; + } + + if (message is not ResponseMessage || message.RequestId() != requestId || + message.Pdu().ErrorStatus.ToInt32() != 0) + return null; + + var values = message.Variables().ToDictionary(x => x.Id.ToString(), x => GetValue(x.Data)); + + return new SNMPSystemInfo(values.GetValueOrDefault(SysNameOID, string.Empty), + values.GetValueOrDefault(SysDescrOID, string.Empty), + values.GetValueOrDefault(SysLocationOID, string.Empty)); + } + + /// + /// Gets the value of a variable as text, empty for "no such object / instance" (v2c) and non-text values. + /// + private static string GetValue(ISnmpData data) + { + if (data is not OctetString octetString) + return string.Empty; + + // Control characters (e.g. line breaks in sysDescr) are shown as spaces + return new string([.. octetString.ToString().Select(x => char.IsControl(x) ? ' ' : x)]).Trim(); + } +} diff --git a/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs b/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs index 0159fd3c70..eee3dd0d7b 100644 --- a/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs +++ b/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs @@ -168,6 +168,10 @@ public static class GlobalStaticConfiguration public static int IPScanner_MDNSTimeout => 2000; public static bool IPScanner_HttpTitleEnabled => false; public static int IPScanner_HttpTitleTimeout => 2000; + public static bool IPScanner_SNMPEnabled => false; + public static SNMPVersion IPScanner_SNMPVersion => SNMPVersion.V2C; + public static string IPScanner_SNMPCommunity => "public"; + public static int IPScanner_SNMPTimeout => 2000; public static bool IPScanner_ExpandHostView => false; public static ExportFileType IPScanner_ExportFileType => ExportFileType.Csv; // Larger scans need a confirmation, even larger ones are refused (every IP address / port is kept in memory) diff --git a/Source/NETworkManager.Settings/SettingsInfo.cs b/Source/NETworkManager.Settings/SettingsInfo.cs index 4f5fae364b..cac3bebd1d 100644 --- a/Source/NETworkManager.Settings/SettingsInfo.cs +++ b/Source/NETworkManager.Settings/SettingsInfo.cs @@ -1468,6 +1468,58 @@ public int IPScanner_HttpTitleTimeout } } = GlobalStaticConfiguration.IPScanner_HttpTitleTimeout; + public bool IPScanner_SNMPEnabled + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } = GlobalStaticConfiguration.IPScanner_SNMPEnabled; + + public SNMPVersion IPScanner_SNMPVersion + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } = GlobalStaticConfiguration.IPScanner_SNMPVersion; + + public string IPScanner_SNMPCommunity + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } = GlobalStaticConfiguration.IPScanner_SNMPCommunity; + + public int IPScanner_SNMPTimeout + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } = GlobalStaticConfiguration.IPScanner_SNMPTimeout; + public ObservableCollection IPScanner_CustomCommands { get; diff --git a/Source/NETworkManager.Tests/Network/SNMPSystemInfoResolverTests.cs b/Source/NETworkManager.Tests/Network/SNMPSystemInfoResolverTests.cs new file mode 100644 index 0000000000..159c6ea440 --- /dev/null +++ b/Source/NETworkManager.Tests/Network/SNMPSystemInfoResolverTests.cs @@ -0,0 +1,89 @@ +using System.Collections.Generic; +using System.Linq; +using Lextm.SharpSnmpLib; +using Lextm.SharpSnmpLib.Messaging; +using NETworkManager.Models.Network; +using Xunit; + +namespace NETworkManager.Tests.Network; + +public class SNMPSystemInfoResolverTests +{ + private const int RequestId = 4711; + + private static byte[] CreateResponse(int requestId, ErrorCode errorCode, + params (string oid, ISnmpData data)[] values) + { + var variables = new List(); + + foreach (var (oid, data) in values) + variables.Add(new Variable(new ObjectIdentifier(oid), data)); + + return new ResponseMessage(requestId, VersionCode.V2, new OctetString("public"), errorCode, 0, variables) + .ToBytes(); + } + + [Fact] + public void CreateRequest() + { + var request = SNMPSystemInfoResolver.CreateRequest(RequestId, SNMPVersion.V1, "private"); + + Assert.Equal(VersionCode.V1, request.Version); + Assert.Equal("private", request.Community().ToString()); + Assert.Equal(RequestId, request.RequestId()); + Assert.Equal( + [SNMPSystemInfoResolver.SysNameOID, SNMPSystemInfoResolver.SysDescrOID, SNMPSystemInfoResolver.SysLocationOID], + request.Variables().Select(x => x.Id.ToString())); + } + + [Fact] + public void ParseResponse() + { + var response = CreateResponse(RequestId, ErrorCode.NoError, + (SNMPSystemInfoResolver.SysNameOID, new OctetString("switch-01")), + (SNMPSystemInfoResolver.SysDescrOID, new OctetString("Switch\r\nFirmware 1.2")), + (SNMPSystemInfoResolver.SysLocationOID, new OctetString("Server room"))); + + Assert.Equal(new SNMPSystemInfo("switch-01", "Switch Firmware 1.2", "Server room"), + SNMPSystemInfoResolver.ParseResponse(response, RequestId)); + } + + [Fact] + public void ParseResponse_NoSuchObject_Empty() + { + var response = CreateResponse(RequestId, ErrorCode.NoError, + (SNMPSystemInfoResolver.SysNameOID, new OctetString("printer")), + (SNMPSystemInfoResolver.SysDescrOID, new NoSuchObject()), + (SNMPSystemInfoResolver.SysLocationOID, new NoSuchInstance())); + + Assert.Equal(new SNMPSystemInfo("printer", string.Empty, string.Empty), + SNMPSystemInfoResolver.ParseResponse(response, RequestId)); + } + + [Fact] + public void ParseResponse_OtherRequest_Null() + { + var response = CreateResponse(RequestId + 1, ErrorCode.NoError, + (SNMPSystemInfoResolver.SysNameOID, new OctetString("switch-01"))); + + Assert.Null(SNMPSystemInfoResolver.ParseResponse(response, RequestId)); + } + + [Fact] + public void ParseResponse_Error_Null() + { + var response = CreateResponse(RequestId, ErrorCode.NoSuchName, + (SNMPSystemInfoResolver.SysNameOID, new Null())); + + Assert.Null(SNMPSystemInfoResolver.ParseResponse(response, RequestId)); + } + + [Theory] + [InlineData(new byte[] { })] + [InlineData(new byte[] { 0x30, 0x03, 0x02, 0x01 })] + [InlineData(new byte[] { 0xFF, 0xFF, 0xFF })] + public void ParseResponse_Invalid_Null(byte[] response) + { + Assert.Null(SNMPSystemInfoResolver.ParseResponse(response, RequestId)); + } +} diff --git a/Source/NETworkManager/ViewModels/IPScannerSettingsViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerSettingsViewModel.cs index 1a3fb07fbe..35baea1535 100644 --- a/Source/NETworkManager/ViewModels/IPScannerSettingsViewModel.cs +++ b/Source/NETworkManager/ViewModels/IPScannerSettingsViewModel.cs @@ -1,5 +1,6 @@ using MahApps.Metro.SimpleChildWindow; using NETworkManager.Localization.Resources; +using NETworkManager.Models.Network; using NETworkManager.Settings; using NETworkManager.Utilities; using NETworkManager.Views; @@ -406,6 +407,87 @@ public int HttpTitleTimeout } } + /// + /// Gets or sets a value indicating whether the SNMP system information is read. + /// + public bool SNMPEnabled + { + get; + set + { + if (value == field) + return; + + if (!_isLoading) + SettingsManager.Current.IPScanner_SNMPEnabled = value; + + field = value; + OnPropertyChanged(); + } + } + + /// + /// Gets the SNMP versions that can be used to read the system information (v1, v2c - v3 needs credentials). + /// + public SNMPVersion[] SNMPVersions { get; } = [SNMPVersion.V1, SNMPVersion.V2C]; + + /// + /// Gets or sets the SNMP version (v1 or v2c). + /// + public SNMPVersion SNMPVersion + { + get; + set + { + if (value == field) + return; + + if (!_isLoading) + SettingsManager.Current.IPScanner_SNMPVersion = value; + + field = value; + OnPropertyChanged(); + } + } + + /// + /// Gets or sets the SNMP community (saved as plain text). + /// + public string SNMPCommunity + { + get; + set + { + if (value == field) + return; + + if (!_isLoading) + SettingsManager.Current.IPScanner_SNMPCommunity = value; + + field = value; + OnPropertyChanged(); + } + } + + /// + /// Gets or sets the SNMP timeout in milliseconds. + /// + public int SNMPTimeout + { + get; + set + { + if (value == field) + return; + + if (!_isLoading) + SettingsManager.Current.IPScanner_SNMPTimeout = value; + + field = value; + OnPropertyChanged(); + } + } + /// /// Gets the collection view of custom commands. /// @@ -526,6 +608,10 @@ private void LoadSettings() MDNSTimeout = SettingsManager.Current.IPScanner_MDNSTimeout; HttpTitleEnabled = SettingsManager.Current.IPScanner_HttpTitleEnabled; HttpTitleTimeout = SettingsManager.Current.IPScanner_HttpTitleTimeout; + SNMPEnabled = SettingsManager.Current.IPScanner_SNMPEnabled; + SNMPVersion = SettingsManager.Current.IPScanner_SNMPVersion; + SNMPCommunity = SettingsManager.Current.IPScanner_SNMPCommunity; + SNMPTimeout = SettingsManager.Current.IPScanner_SNMPTimeout; MaxHostThreads = SettingsManager.Current.IPScanner_MaxHostThreads; MaxPortThreads = SettingsManager.Current.IPScanner_MaxPortThreads; } diff --git a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs index 9054c3d61d..6879ec878b 100644 --- a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs @@ -454,6 +454,9 @@ public IPScannerViewModel(Guid tabId, string hostOrIPRange, bool start = true) Matches(info.Hostname) || Matches(info.MDNSHostname) || Matches(info.WebTitle) || + Matches(info.SNMPName) || + Matches(info.SNMPDescription) || + Matches(info.SNMPLocation) || Matches(info.MACAddress) || Matches(info.Vendor) || (ScanOptions.OSGuessEnabled && @@ -469,6 +472,9 @@ public IPScannerViewModel(Guid tabId, string hostOrIPRange, bool start = true) liveShaping.LiveFilteringProperties.Add(nameof(IPScannerHostInfo.Hostname)); liveShaping.LiveFilteringProperties.Add(nameof(IPScannerHostInfo.MDNSHostname)); liveShaping.LiveFilteringProperties.Add(nameof(IPScannerHostInfo.WebTitle)); + liveShaping.LiveFilteringProperties.Add(nameof(IPScannerHostInfo.SNMPName)); + liveShaping.LiveFilteringProperties.Add(nameof(IPScannerHostInfo.SNMPDescription)); + liveShaping.LiveFilteringProperties.Add(nameof(IPScannerHostInfo.SNMPLocation)); liveShaping.LiveFilteringProperties.Add(nameof(IPScannerHostInfo.MACAddress)); liveShaping.LiveFilteringProperties.Add(nameof(IPScannerHostInfo.Vendor)); liveShaping.LiveFilteringProperties.Add(nameof(IPScannerHostInfo.Ports)); @@ -1001,6 +1007,10 @@ private static IPScannerOptions CreateScanOptions(int[] ports) MDNSTimeout = SettingsManager.Current.IPScanner_MDNSTimeout, HttpTitleEnabled = SettingsManager.Current.IPScanner_HttpTitleEnabled, HttpTitleTimeout = SettingsManager.Current.IPScanner_HttpTitleTimeout, + SNMPEnabled = SettingsManager.Current.IPScanner_SNMPEnabled, + SNMPVersion = SettingsManager.Current.IPScanner_SNMPVersion, + SNMPCommunity = SettingsManager.Current.IPScanner_SNMPCommunity, + SNMPTimeout = SettingsManager.Current.IPScanner_SNMPTimeout, ShowAllResults = SettingsManager.Current.IPScanner_ShowAllResults, ReportAllHosts = false }; @@ -1031,7 +1041,8 @@ private static IPScannerResultOptions CreateResultOptions() SettingsManager.Current.IPScanner_NetBIOSEnabled, SettingsManager.Current.IPScanner_ResolveMACAddress, SettingsManager.Current.IPScanner_MDNSEnabled, - SettingsManager.Current.IPScanner_HttpTitleEnabled); + SettingsManager.Current.IPScanner_HttpTitleEnabled, + SettingsManager.Current.IPScanner_SNMPEnabled); } /// diff --git a/Source/NETworkManager/Views/IPScannerSettingsView.xaml b/Source/NETworkManager/Views/IPScannerSettingsView.xaml index cfd7879fed..464d24f2ad 100644 --- a/Source/NETworkManager/Views/IPScannerSettingsView.xaml +++ b/Source/NETworkManager/Views/IPScannerSettingsView.xaml @@ -166,6 +166,42 @@ Margin="0,0,0,10" /> + + + + + + + + + + + + + + all exports the filtered results; the up / down counters always count all hosts. Results can also be exported while the scan is running; values of probes that are still running (shown as `...`) are exported as empty values. +The search box right of the **Result** header filters the results by IP address, hostname, MAC address, vendor, OS (guess), mDNS hostname, web title, SNMP name / description / location and open ports (number or service), also while the scan is running (hosts whose hostname or MAC address arrives later are filtered again). **Export...** > all exports the filtered results; the up / down counters always count all hosts. Results can also be exported while the scan is running; values of probes that are still running (shown as `...`) are exported as empty values. Expand a row with the button in front of it to see its details, or enable [Expand host view](#expand-host-view) to expand all hosts. @@ -379,6 +379,55 @@ Timeout in milliseconds of a web request (per port and redirect). **Default:** `2000` +### Read the SNMP system information + +Read the name, description and location (`sysName`, `sysDescr`, `sysLocation`) of every reachable host with an SNMP `GET` request to UDP port `161` and show them in the **SNMP name**, **SNMP description** (full text in the tooltip) and **SNMP location** columns, e.g. to identify switches, printers and access points. + +**Type:** `Boolean` + +**Default:** `Disabled` + +:::note + +A host with another community usually doesn't answer at all (no value after the timeout). Many intrusion detection systems report requests with common communities like `public`. SNMP v3 is not supported (it needs credentials), use the [SNMP](./snmp) tool for it. + +::: + +### Version + +SNMP version of the request. + +**Type:** `NETworkManager.Models.Network.SNMPVersion` + +**Default:** `V2C` + +**Possible values:** + +- `V1` +- `V2C` + +### Community + +Community of the request. + +**Type:** `String` + +**Default:** `public` + +:::warning + +The community is saved as plain text in the settings file (not encrypted). Use a read-only community (e.g. `public`), not one with write access. + +::: + +### Timeout (ms) + +Timeout in milliseconds after which an SNMP request is considered lost. + +**Type:** `Integer` [Min `100`, Max `15000`] + +**Default:** `2000` + ### Custom commands Custom commands that can be executed with a right click on the selected result. diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index 6e6f1119b8..ce857087b2 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -94,6 +94,7 @@ Release date: **xx.xx.2026** - An icon in the result marks this computer, the default gateway and the DNS and DHCP servers of the connected network interfaces (all roles in the tooltip), e.g. to find the router in a `/24`. Also in the search and the export (`Role`). [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) - New **Resolve mDNS hostname** setting (default off, with **Timeout**): the mDNS hostname (e.g. `macbook.local`) of reachable hosts is resolved with a reverse lookup sent to UDP port `5353` of the host (Apple devices, Linux with Avahi, Android, printers, IoT devices). Shown in the new **mDNS hostname** column and used as **Hostname** if there is no DNS hostname (before the NetBIOS name). Also in the search and the export (`MDNSHostname`). [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) - New **Read the title of web pages** setting (default off, with **Timeout**): the title of the web page (`GET /`) of reachable hosts with an open web port (`443`, `80`, `8443`, `8080`) is shown in the new **Web title** column (URL in the tooltip, `Server` header if the page has no title), e.g. to identify routers, printers and NAS. Redirects on the same host are followed, certificate errors are accepted, no cookies or credentials are sent. **Web Console** in the context menu opens the port that answered. Also in the search and the export (`WebTitle`, `WebURL`). [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) +- New **Read the SNMP system information** setting (default off, with **Version** `v1` / `v2c`, **Community** and **Timeout**): name, description and location (`sysName`, `sysDescr`, `sysLocation`) of reachable hosts are shown in the new **SNMP name**, **SNMP description** and **SNMP location** columns, e.g. to identify switches and printers. The community is saved as plain text in the settings (warning icon next to the field, use a read-only community). Also in the search and the export (`SNMPName`, `SNMPDescription`, `SNMPLocation`). [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) - The **Detect local subnet** button opens a menu: **Detect local subnet** (as before), **Subnet of network interface** to select the IPv4 subnet of a specific network interface (e.g. LAN, Wi-Fi, VPN, Hyper-V) and **All local subnets** to scan the subnets of all network interfaces. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) - IP addresses can be excluded from the scan with `!` in the host input (also in profiles and the run command), e.g. `10.0.0.0/24; !10.0.0.1; !10.0.0.200-254`. Works with IP addresses, ranges, subnets and hostnames. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) From a6a11de79bb44460a8d06db14bbbd667e8ca7c12 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Fri, 30 Oct 2026 20:33:15 +0100 Subject: [PATCH 014/131] Feature: IP Scanner: Configurable ports for the web page title (C2b) New setting IPScanner_HttpTitlePorts (default "443; 80; 8443; 8080") with a restore-default button, like the ports of the port scan. The ports are tried in this order, only if open in the port scan: http on 80 / 8080, https and then http on any other port. Web Console opens the port and scheme that answered (WebConsoleUrlHelper.CreateUrl with scheme). Co-Authored-By: Claude Opus 5.5 --- .../Resources/Strings.Designer.cs | 2 +- .../Resources/Strings.resx | 2 +- .../Network/HttpTitleResolver.cs | 32 +++++++++---- .../Network/IPScanner.cs | 15 +++--- .../Network/IPScannerOptions.cs | 5 ++ .../Network/WebConsoleUrlHelper.cs | 33 +++++++------ .../GlobalStaticConfiguration.cs | 1 + .../NETworkManager.Settings/SettingsInfo.cs | 13 +++++ .../Network/HttpTitleResolverTests.cs | 10 ++++ .../Network/WebConsoleUrlHelperTests.cs | 12 +++-- .../ViewModels/IPScannerSettingsViewModel.cs | 28 +++++++++++ .../ViewModels/IPScannerViewModel.cs | 14 ++++-- .../Views/IPScannerSettingsView.xaml | 47 +++++++++++++++++-- Website/docs/application/ip-scanner.md | 16 +++++-- Website/docs/changelog/next-release.md | 2 +- 15 files changed, 181 insertions(+), 51 deletions(-) diff --git a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs index b717c5112d..5ea9db0390 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs +++ b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs @@ -6186,7 +6186,7 @@ public static string HelpMessage_ReadTheSNMPSystemInformation { } /// - /// Looks up a localized string similar to Read the title of the web page (GET /) of every reachable host with an open web port (443, 80, 8443, 8080 - the port scan must be enabled and the ports must be in the list). The first port that answers is used, redirects on the same host are followed. No cookies or credentials are sent, certificate errors are accepted. The request appears in the log of the web server. If the page has no title, the Server header is shown.. + /// Looks up a localized string similar to Read the title of the web page (GET /) of every reachable host with an open port of the list below, in this order (the port scan must be enabled and the ports must also be in its list). The first port that answers is used (http on 80 and 8080, https and then http on other ports), redirects on the same host are followed. No cookies or credentials are sent, certificate errors are accepted. The request appears in the log of the web server. If the page has no title, the Server header is shown.. /// public static string HelpMessage_ReadTheTitleOfWebPages { get { diff --git a/Source/NETworkManager.Localization/Resources/Strings.resx b/Source/NETworkManager.Localization/Resources/Strings.resx index c098511d14..85bacdbd8d 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.resx +++ b/Source/NETworkManager.Localization/Resources/Strings.resx @@ -5234,7 +5234,7 @@ Do you want to start the scan? Read the title of web pages - Read the title of the web page (GET /) of every reachable host with an open web port (443, 80, 8443, 8080 - the port scan must be enabled and the ports must be in the list). The first port that answers is used, redirects on the same host are followed. No cookies or credentials are sent, certificate errors are accepted. The request appears in the log of the web server. If the page has no title, the Server header is shown. + Read the title of the web page (GET /) of every reachable host with an open port of the list below, in this order (the port scan must be enabled and the ports must also be in its list). The first port that answers is used (http on 80 and 8080, https and then http on other ports), redirects on the same host are followed. No cookies or credentials are sent, certificate errors are accepted. The request appears in the log of the web server. If the page has no title, the Server header is shown. SNMP name diff --git a/Source/NETworkManager.Models/Network/HttpTitleResolver.cs b/Source/NETworkManager.Models/Network/HttpTitleResolver.cs index db82acdb5a..077a00ea48 100644 --- a/Source/NETworkManager.Models/Network/HttpTitleResolver.cs +++ b/Source/NETworkManager.Models/Network/HttpTitleResolver.cs @@ -63,29 +63,43 @@ public static partial class HttpTitleResolver private static partial Regex WhitespaceRegex(); /// - /// Reads the title of the web page of a host from the first open web port that answers (443, 80, 8443, 8080). + /// Reads the title of the web page of a host from the first port that answers. /// /// IP address of the host. - /// Open ports of the host (from the port scan). + /// Open ports of the host to request, in the order to try them. /// Timeout in milliseconds per request. /// Token to monitor for cancellation requests. - /// URL and title, null if no web port is open or none answered. - public static async Task ResolveAsync(IPAddress ipAddress, IEnumerable openPorts, + /// URL and title, null if no port answered. + public static async Task ResolveAsync(IPAddress ipAddress, IEnumerable ports, int timeout, CancellationToken cancellationToken) { - foreach (var port in WebConsoleUrlHelper.GetOpenWebPorts(openPorts)) + foreach (var port in ports) { - var url = WebConsoleUrlHelper.CreateUrl(ipAddress.ToString(), port); + foreach (var scheme in GetSchemes(port)) + { + var url = WebConsoleUrlHelper.CreateUrl(ipAddress.ToString(), port, scheme); - var title = await GetTitleAsync(new Uri(url), timeout, cancellationToken).ConfigureAwait(false); + var title = await GetTitleAsync(new Uri(url), timeout, cancellationToken).ConfigureAwait(false); - if (title != null) - return new HttpTitleInfo(url, title); + if (title != null) + return new HttpTitleInfo(url, title); + } } return null; } + /// + /// Gets the schemes to try for a port: http for 80 / 8080, https and then http for any other port (a TLS + /// handshake with a plain http server fails right away). + /// + /// Port number. + /// Schemes in the order to try them. + public static string[] GetSchemes(int port) + { + return port is 80 or 8080 ? ["http"] : ["https", "http"]; + } + /// /// Requests the web page and reads its title, follows redirects on the same host. /// diff --git a/Source/NETworkManager.Models/Network/IPScanner.cs b/Source/NETworkManager.Models/Network/IPScanner.cs index c2c1c42dd2..b4096287c7 100644 --- a/Source/NETworkManager.Models/Network/IPScanner.cs +++ b/Source/NETworkManager.Models/Network/IPScanner.cs @@ -390,11 +390,14 @@ await IsNeighborAsync(ipAddress, networkInterfaces, cancellationToken).Configure // Title of the web page - needs the open ports, so after the port scan if (options.HttpTitleEnabled && detectedBy != IPScannerDetectionMethod.None && ports != null) { - var openPorts = ports.Where(x => x.State == PortState.Open).Select(x => x.Port).ToList(); + var openPorts = ports.Where(x => x.State == PortState.Open).Select(x => x.Port).ToHashSet(); - if (WebConsoleUrlHelper.GetOpenWebPorts(openPorts).Count > 0) + // Configured ports that are open, in the configured order + var webPorts = options.HttpTitlePorts.Where(openPorts.Contains).ToList(); + + if (webPorts.Count > 0) enrichTask = Task.WhenAll(enrichTask, - UpdateHttpTitleAsync(ipAddress, hostInfo, openPorts, cancellationToken)); + UpdateHttpTitleAsync(ipAddress, hostInfo, webPorts, cancellationToken)); } if (hostInfo == null) @@ -561,12 +564,12 @@ await Task.WhenAll( /// /// IP address of the host. /// Result of the host, already raised in . - /// Open ports of the host. + /// Open ports to request, in the order to try them. /// Token to monitor for cancellation requests. - private async Task UpdateHttpTitleAsync(IPAddress ipAddress, IPScannerHostInfo host, List openPorts, + private async Task UpdateHttpTitleAsync(IPAddress ipAddress, IPScannerHostInfo host, List webPorts, CancellationToken cancellationToken) { - var httpTitleInfo = await HttpTitleResolver.ResolveAsync(ipAddress, openPorts, options.HttpTitleTimeout, + var httpTitleInfo = await HttpTitleResolver.ResolveAsync(ipAddress, webPorts, options.HttpTitleTimeout, cancellationToken).ConfigureAwait(false); if (httpTitleInfo != null) diff --git a/Source/NETworkManager.Models/Network/IPScannerOptions.cs b/Source/NETworkManager.Models/Network/IPScannerOptions.cs index 766b3a632d..9b2de4da53 100644 --- a/Source/NETworkManager.Models/Network/IPScannerOptions.cs +++ b/Source/NETworkManager.Models/Network/IPScannerOptions.cs @@ -104,6 +104,11 @@ public record IPScannerOptions /// public required bool HttpTitleEnabled { get; init; } + /// + /// Ports whose web page title is read, in this order (only open ports, the first that answers is used). + /// + public required int[] HttpTitlePorts { get; init; } + /// /// Timeout in milliseconds of a web request. /// diff --git a/Source/NETworkManager.Models/Network/WebConsoleUrlHelper.cs b/Source/NETworkManager.Models/Network/WebConsoleUrlHelper.cs index 532645de71..fcf1aa3931 100644 --- a/Source/NETworkManager.Models/Network/WebConsoleUrlHelper.cs +++ b/Source/NETworkManager.Models/Network/WebConsoleUrlHelper.cs @@ -27,20 +27,6 @@ public static bool IsWebPort(int port) return WebPorts.Contains(port); } - /// - /// Gets the open web ports of a host in the order of preference (443, 80, 8443, 8080). - /// - /// Open ports of the host. - /// Open web ports. - public static List GetOpenWebPorts(IEnumerable openPorts) - { - ArgumentNullException.ThrowIfNull(openPorts); - - var ports = openPorts.ToHashSet(); - - return [.. WebPorts.Where(ports.Contains)]; - } - /// /// Creates the URL for a host: the preferred open web port (443, 80, 8443, 8080), http://host/ if /// none of them is open. @@ -68,6 +54,19 @@ public static string CreateUrl(string host, IEnumerable openPorts) /// Port number. /// URL. public static string CreateUrl(string host, int port) + { + return CreateUrl(host, port, port is 443 or 8443 ? "https" : "http"); + } + + /// + /// Creates the URL for a host, a port and a scheme (e.g. of a web page that answered): the default port of the + /// scheme (80 / 443) is left out, IPv6 addresses in brackets, the trailing dot of a hostname is removed. + /// + /// Hostname or IP address. + /// Port number. + /// "http" or "https". + /// URL. + public static string CreateUrl(string host, int port, string scheme) { // The browser resolves a hostname via Windows (e.g. "gateway." -> "gateway") if (!IPAddress.TryParse(host, out var ipAddress)) @@ -76,12 +75,12 @@ public static string CreateUrl(string host, int port) // Also a hostname of only dots ArgumentException.ThrowIfNullOrWhiteSpace(host); - var scheme = port is 443 or 8443 ? "https" : "http"; - // The zone id of an IPv6 address has to be encoded ("%" -> "%25", RFC 6874) if (ipAddress?.AddressFamily == AddressFamily.InterNetworkV6) host = $"[{ipAddress.ToString().Replace("%", "%25")}]"; - return port is 80 or 443 ? $"{scheme}://{host}/" : $"{scheme}://{host}:{port}/"; + var isDefaultPort = (scheme == "http" && port == 80) || (scheme == "https" && port == 443); + + return isDefaultPort ? $"{scheme}://{host}/" : $"{scheme}://{host}:{port}/"; } } diff --git a/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs b/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs index eee3dd0d7b..957e267514 100644 --- a/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs +++ b/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs @@ -167,6 +167,7 @@ public static class GlobalStaticConfiguration public static bool IPScanner_MDNSEnabled => false; public static int IPScanner_MDNSTimeout => 2000; public static bool IPScanner_HttpTitleEnabled => false; + public static string IPScanner_HttpTitlePorts => "443; 80; 8443; 8080"; public static int IPScanner_HttpTitleTimeout => 2000; public static bool IPScanner_SNMPEnabled => false; public static SNMPVersion IPScanner_SNMPVersion => SNMPVersion.V2C; diff --git a/Source/NETworkManager.Settings/SettingsInfo.cs b/Source/NETworkManager.Settings/SettingsInfo.cs index cac3bebd1d..9022accef4 100644 --- a/Source/NETworkManager.Settings/SettingsInfo.cs +++ b/Source/NETworkManager.Settings/SettingsInfo.cs @@ -1455,6 +1455,19 @@ public bool IPScanner_HttpTitleEnabled } } = GlobalStaticConfiguration.IPScanner_HttpTitleEnabled; + public string IPScanner_HttpTitlePorts + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } = GlobalStaticConfiguration.IPScanner_HttpTitlePorts; + public int IPScanner_HttpTitleTimeout { get; diff --git a/Source/NETworkManager.Tests/Network/HttpTitleResolverTests.cs b/Source/NETworkManager.Tests/Network/HttpTitleResolverTests.cs index 1f00fe23a7..d65afda91f 100644 --- a/Source/NETworkManager.Tests/Network/HttpTitleResolverTests.cs +++ b/Source/NETworkManager.Tests/Network/HttpTitleResolverTests.cs @@ -30,6 +30,16 @@ public void ParseTitle_TooLong_Shortened() Assert.Equal(new string('a', 200) + "...", title); } + [Theory] + [InlineData(80, new[] { "http" })] + [InlineData(8080, new[] { "http" })] + [InlineData(443, new[] { "https", "http" })] + [InlineData(9000, new[] { "https", "http" })] + public void GetSchemes(int port, string[] expected) + { + Assert.Equal(expected, HttpTitleResolver.GetSchemes(port)); + } + [Fact] public void Decode_DefaultUTF8() { diff --git a/Source/NETworkManager.Tests/Network/WebConsoleUrlHelperTests.cs b/Source/NETworkManager.Tests/Network/WebConsoleUrlHelperTests.cs index 14c5d505f9..7742edc9bd 100644 --- a/Source/NETworkManager.Tests/Network/WebConsoleUrlHelperTests.cs +++ b/Source/NETworkManager.Tests/Network/WebConsoleUrlHelperTests.cs @@ -55,11 +55,13 @@ public void DetectsWebPorts(int port, bool expected) } [Theory] - [InlineData(new[] { 22, 8080, 80, 443 }, new[] { 443, 80, 8080 })] - [InlineData(new[] { 8443 }, new[] { 8443 })] - [InlineData(new[] { 22, 3389 }, new int[0])] - public void GetOpenWebPorts(int[] openPorts, int[] expected) + [InlineData("10.0.0.1", 443, "https", "https://10.0.0.1/")] + [InlineData("10.0.0.1", 443, "http", "http://10.0.0.1:443/")] + [InlineData("10.0.0.1", 80, "https", "https://10.0.0.1:80/")] + [InlineData("10.0.0.1", 9443, "https", "https://10.0.0.1:9443/")] + [InlineData("2001:db8::1", 8081, "http", "http://[2001:db8::1]:8081/")] + public void CreateUrl_Scheme(string host, int port, string scheme, string expected) { - Assert.Equal(expected, WebConsoleUrlHelper.GetOpenWebPorts(openPorts)); + Assert.Equal(expected, WebConsoleUrlHelper.CreateUrl(host, port, scheme)); } } diff --git a/Source/NETworkManager/ViewModels/IPScannerSettingsViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerSettingsViewModel.cs index 35baea1535..32cf6c654f 100644 --- a/Source/NETworkManager/ViewModels/IPScannerSettingsViewModel.cs +++ b/Source/NETworkManager/ViewModels/IPScannerSettingsViewModel.cs @@ -388,6 +388,25 @@ public bool HttpTitleEnabled } } + /// + /// Gets or sets the ports whose web page title is read (in this order, only open ports). + /// + public string HttpTitlePorts + { + get; + set + { + if (value == field) + return; + + if (!_isLoading) + SettingsManager.Current.IPScanner_HttpTitlePorts = value; + + field = value; + OnPropertyChanged(); + } + } + /// /// Gets or sets the timeout of a web request in milliseconds. /// @@ -607,6 +626,7 @@ private void LoadSettings() MDNSEnabled = SettingsManager.Current.IPScanner_MDNSEnabled; MDNSTimeout = SettingsManager.Current.IPScanner_MDNSTimeout; HttpTitleEnabled = SettingsManager.Current.IPScanner_HttpTitleEnabled; + HttpTitlePorts = SettingsManager.Current.IPScanner_HttpTitlePorts; HttpTitleTimeout = SettingsManager.Current.IPScanner_HttpTitleTimeout; SNMPEnabled = SettingsManager.Current.IPScanner_SNMPEnabled; SNMPVersion = SettingsManager.Current.IPScanner_SNMPVersion; @@ -628,6 +648,14 @@ private void LoadSettings() _ => PortScanPorts = GlobalStaticConfiguration.IPScanner_PortScanPorts, _ => PortScanPorts != GlobalStaticConfiguration.IPScanner_PortScanPorts); + /// + /// Gets the command to restore the default ports whose web page title is read. Disabled while the default ports are + /// set. + /// + public ICommand RestoreDefaultHttpTitlePortsCommand => new RelayCommand( + _ => HttpTitlePorts = GlobalStaticConfiguration.IPScanner_HttpTitlePorts, + _ => HttpTitlePorts != GlobalStaticConfiguration.IPScanner_HttpTitlePorts); + /// /// Gets the command to add a custom command. /// diff --git a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs index 6879ec878b..0b02114916 100644 --- a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs @@ -589,12 +589,12 @@ private string GetSelectedHost() private void OpenWebConsoleAction() { - // The port of the web page whose title was read + // The port and scheme of the web page whose title was read if (Uri.TryCreate(SelectedResult.WebURL, UriKind.Absolute, out var webURL)) { NavigationManager.Open(new ApplicationRequest(ApplicationName.WebConsole) { - Target = WebConsoleUrlHelper.CreateUrl(GetSelectedHost(), webURL.Port) + Target = WebConsoleUrlHelper.CreateUrl(GetSelectedHost(), webURL.Port, webURL.Scheme) }); return; @@ -847,6 +847,7 @@ private async Task Start() // handled here - otherwise the scan would never end (IsRunning stays true). (List<(IPAddress ipAddress, string hostname)> hosts, List hostnamesNotResolved) hosts; int[] ports; + int[] httpTitlePorts; try { @@ -856,6 +857,9 @@ private async Task Start() ports = await PortRangeHelper.ConvertPortRangeToIntArrayAsync( SettingsManager.Current.IPScanner_PortScanPorts); + + httpTitlePorts = await PortRangeHelper.ConvertPortRangeToIntArrayAsync( + SettingsManager.Current.IPScanner_HttpTitlePorts); } catch (OperationCanceledException) { @@ -891,7 +895,7 @@ private async Task Start() ListHelper.Modify(SettingsManager.Current.IPScanner_HostHistory, Host, SettingsManager.Current.General_HistoryListEntries); - var scanOptions = CreateScanOptions(ports); + var scanOptions = CreateScanOptions(ports, httpTitlePorts); ScanOptions = new IPScannerResultOptions(scanOptions); @@ -982,8 +986,9 @@ private void StartScanner(IPScannerOptions scanOptions, List<(IPAddress ipAddres /// Creates the scan options from the current settings. /// /// Ports to scan. + /// Ports whose web page title is read (if open). /// Options for the . - private static IPScannerOptions CreateScanOptions(int[] ports) + private static IPScannerOptions CreateScanOptions(int[] ports, int[] httpTitlePorts) { return new IPScannerOptions { @@ -1006,6 +1011,7 @@ private static IPScannerOptions CreateScanOptions(int[] ports) MDNSEnabled = SettingsManager.Current.IPScanner_MDNSEnabled, MDNSTimeout = SettingsManager.Current.IPScanner_MDNSTimeout, HttpTitleEnabled = SettingsManager.Current.IPScanner_HttpTitleEnabled, + HttpTitlePorts = httpTitlePorts, HttpTitleTimeout = SettingsManager.Current.IPScanner_HttpTitleTimeout, SNMPEnabled = SettingsManager.Current.IPScanner_SNMPEnabled, SNMPVersion = SettingsManager.Current.IPScanner_SNMPVersion, diff --git a/Source/NETworkManager/Views/IPScannerSettingsView.xaml b/Source/NETworkManager/Views/IPScannerSettingsView.xaml index 464d24f2ad..26da56ad6a 100644 --- a/Source/NETworkManager/Views/IPScannerSettingsView.xaml +++ b/Source/NETworkManager/Views/IPScannerSettingsView.xaml @@ -162,10 +162,49 @@ - - + + + + + + + + + + + + + + + + + + Date: Fri, 30 Oct 2026 20:41:46 +0100 Subject: [PATCH 015/131] Feature: IP Scanner: ARP / NDP settings section with help (A3b) Find hosts in the local subnet and Resolve MAC address and vendor are in one ARP / NDP section of the settings (the MAC address section is removed), both with a help icon that explains the difference: finding hosts that don't answer any probe (state of the neighbor entry) vs. showing the MAC address / vendor of found hosts. Docs: comparison table. Co-Authored-By: Claude Opus 5.5 --- .../Resources/Strings.Designer.cs | 11 ++++++++++- .../Resources/Strings.resx | 5 ++++- .../Views/IPScannerSettingsView.xaml | 17 +++++++++++++---- Website/docs/application/ip-scanner.md | 19 +++++++++++++++++-- Website/docs/changelog/next-release.md | 2 +- 5 files changed, 45 insertions(+), 9 deletions(-) diff --git a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs index 5ea9db0390..0a2564235e 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs +++ b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs @@ -5976,7 +5976,7 @@ public static string HelpMessage_ExperimentalFeatures { } /// - /// Looks up a localized string similar to Find hosts in a local subnet that don't answer the ping, a port or NetBIOS (e.g. a firewall drops everything) by their entry in the neighbor table (ARP / NDP). To reach a host in a local subnet, Windows has to resolve its MAC address first, and a host answers this request even if its firewall drops everything else. No additional packets are sent, except to check an entry from earlier traffic again. Hosts behind a router can't be found this way.. + /// Looks up a localized string similar to Find hosts in a local subnet that don't answer the ping, a port or NetBIOS (e.g. a firewall drops everything) by their entry in the neighbor table (ARP / NDP). To reach a host in a local subnet, Windows has to resolve its MAC address first, and a host answers this request even if its firewall drops everything else. No additional packets are sent, except to check an entry from earlier traffic again. Hosts behind a router can't be found this way. Only decides if a host is up - the MAC address and vendor are s [rest of string was truncated]";. /// public static string HelpMessage_FindHostsInTheLocalSubnet { get { @@ -6194,6 +6194,15 @@ public static string HelpMessage_ReadTheTitleOfWebPages { } } + /// + /// Looks up a localized string similar to Show the MAC address and vendor of every found host: read from the neighbor table (ARP / NDP) for hosts in a local subnet, otherwise from the NetBIOS answer (if NetBIOS is enabled). Doesn't find hosts - a host that doesn't answer the ping, a port or NetBIOS is only found with "Find hosts in the local subnet".. + /// + public static string HelpMessage_ResolveMACAddressAndVendor { + get { + return ResourceManager.GetString("HelpMessage_ResolveMACAddressAndVendor", resourceCulture); + } + } + /// /// Looks up a localized string similar to Resolve the mDNS hostname (e.g. macbook.local) of every reachable host with a reverse lookup sent to UDP port 5353 of the host. Answered by Apple devices, Linux (Avahi), Android, printers and many IoT devices, usually only in the local subnet. Used as hostname if there is no DNS hostname.. /// diff --git a/Source/NETworkManager.Localization/Resources/Strings.resx b/Source/NETworkManager.Localization/Resources/Strings.resx index 85bacdbd8d..e35fc238f1 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.resx +++ b/Source/NETworkManager.Localization/Resources/Strings.resx @@ -5180,7 +5180,7 @@ Do you want to start the scan? Find hosts in the local subnet - Find hosts in a local subnet that don't answer the ping, a port or NetBIOS (e.g. a firewall drops everything) by their entry in the neighbor table (ARP / NDP). To reach a host in a local subnet, Windows has to resolve its MAC address first, and a host answers this request even if its firewall drops everything else. No additional packets are sent, except to check an entry from earlier traffic again. Hosts behind a router can't be found this way. + Find hosts in a local subnet that don't answer the ping, a port or NetBIOS (e.g. a firewall drops everything) by their entry in the neighbor table (ARP / NDP). To reach a host in a local subnet, Windows has to resolve its MAC address first, and a host answers this request even if its firewall drops everything else. No additional packets are sent, except to check an entry from earlier traffic again. Hosts behind a router can't be found this way. Only decides if a host is up - the MAC address and vendor are shown with "Resolve MAC address and vendor". Detect local subnet @@ -5254,4 +5254,7 @@ Do you want to start the scan? The community is saved as plain text in the settings file (not encrypted). Use a read-only community (e.g. public), not one with write access. + + Show the MAC address and vendor of every found host: read from the neighbor table (ARP / NDP) for hosts in a local subnet, otherwise from the NetBIOS answer (if NetBIOS is enabled). Doesn't find hosts - a host that doesn't answer the ping, a port or NetBIOS is only found with "Find hosts in the local subnet". + \ No newline at end of file diff --git a/Source/NETworkManager/Views/IPScannerSettingsView.xaml b/Source/NETworkManager/Views/IPScannerSettingsView.xaml index 26da56ad6a..eb19fcf2cc 100644 --- a/Source/NETworkManager/Views/IPScannerSettingsView.xaml +++ b/Source/NETworkManager/Views/IPScannerSettingsView.xaml @@ -120,8 +120,9 @@ Margin="0,0,0,10" /> + - + - - + + + + + - - - - - - - - - - - + + + @@ -1363,6 +1321,340 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Website/docs/application/ip-scanner.md b/Website/docs/application/ip-scanner.md index 410dc5e898..0760eea2ca 100644 --- a/Website/docs/application/ip-scanner.md +++ b/Website/docs/application/ip-scanner.md @@ -90,7 +90,15 @@ If multiple results are selected, **Rescan**, **Copy** > **IP address** / **Host ### Result -The number of reachable (up) and unreachable (down) IP addresses is shown next to the **Result** header. Expand a row to see the ping details, the scanned ports and the NetBIOS information (computer name, domain/workgroup, user and MAC address) of the host. +The number of reachable (up) and unreachable (down) IP addresses is shown next to the **Result** header. Expand a row to see the ping details, the scanned ports and the NetBIOS information (computer name, domain/workgroup, user and MAC address) of the host. The columns show a short summary of the optional requests, the row details all values: + +| Setting | Column | Row details | +| --- | --- | --- | +| [Resolve mDNS hostname](#resolve-mdns-hostname) | **Hostname** (DNS, mDNS or NetBIOS name) | **Hostnames**: DNS, mDNS and NetBIOS name | +| [Read the title of web pages](#read-the-title-of-web-pages) | **Web title** (title, `Server` header if the page has no title) | **Web**: URL, status code, title, `Server` and `X-Powered-By` header | +| [Read the SNMP system information](#read-the-snmp-system-information) | **SNMP name** | **SNMP**: name, description and location | + +The sections are only shown if the setting was enabled for the shown scan. A host is shown (and counted as up) as soon as it answers the first probe: ping reply, open or refused port or NetBIOS answer. Values of probes and lookups that are still running are shown as `...` (ping, ports, NetBIOS, hostname (DNS), MAC address / vendor, mDNS hostname, web title and SNMP), `-/-` means there is no result; the values are filled in when they arrive. The title of the web page is shown as `...` until the port scan is finished (it needs the open ports). Unreachable hosts are added when all probes are finished. A column you sorted by is not sorted again for values that arrive later, click the header again. The scan is finished when all hosts and all lookups are finished. @@ -352,7 +360,7 @@ The MAC address is resolved via ARP (IPv4) or NDP (IPv6) from the neighbor cache ### Resolve mDNS hostname -Resolve the mDNS hostname (e.g. `macbook.local`) of every reachable host: a reverse lookup (`PTR`) is sent to UDP port `5353` of the host. Answered by Apple devices, Linux (Avahi), Android, printers and many IoT devices that have no DNS (`PTR`) record. The name is shown in the **mDNS hostname** column and used as **Hostname** if there is no DNS hostname (order: DNS, mDNS, NetBIOS). Like a NetBIOS name, it is not passed to other tools (the IP address is used instead). +Resolve the mDNS hostname (e.g. `macbook.local`) of every reachable host: a reverse lookup (`PTR`) is sent to UDP port `5353` of the host. Answered by Apple devices, Linux (Avahi), Android, printers and many IoT devices that have no DNS (`PTR`) record. The name is shown in the row details (**Hostnames**) and used as **Hostname** if there is no DNS hostname (order: DNS, mDNS, NetBIOS). Like a NetBIOS name, it is not passed to other tools (the IP address is used instead). **Type:** `Boolean` @@ -406,7 +414,7 @@ Timeout in milliseconds of a web request (per port, scheme and redirect). ### Read the SNMP system information -Read the name, description and location (`sysName`, `sysDescr`, `sysLocation`) of every reachable host with an SNMP `GET` request to UDP port `161` and show them in the **SNMP name**, **SNMP description** (full text in the tooltip) and **SNMP location** columns, e.g. to identify switches, printers and access points. +Read the name, description and location (`sysName`, `sysDescr`, `sysLocation`) of every reachable host with an SNMP `GET` request to UDP port `161` and show them in the row details (**SNMP**), the name also in the **SNMP name** column, e.g. to identify switches, printers and access points. **Type:** `Boolean` diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index 4d696a0134..d202327a7f 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -92,10 +92,10 @@ Release date: **xx.xx.2026** - New **Find hosts in the local subnet** setting (default on): hosts in a local subnet that don't answer the ping, a port or NetBIOS (e.g. a Windows client in a public network, a phone or an IoT device with a firewall that drops everything) are found by their ARP / NDP entry, which Windows creates when the probes resolve the MAC address of the host. No additional packets are sent, except to check an entry from earlier traffic again. Answers with the MAC address of the default gateway (proxy ARP) are ignored. The setting and **Resolve MAC address and vendor** are in the new **ARP / NDP** section of the settings, both with a help icon that explains the difference (finding hosts vs. showing the MAC address of found hosts). [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) - New **OS (guess)** column with the operating system guessed from the TTL of the ping reply (`Linux / Unix`, `Windows` or `Network device`, the tooltip shows the number of hops), can be disabled with the new setting **Guess the operating system from the TTL** (default on). No additional packets are sent; IPv4 only. Also in the search, **Copy** and the export (`OSGuess`). [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) - An icon in the result marks this computer, the default gateway and the DNS and DHCP servers of the connected network interfaces (all roles in the tooltip), e.g. to find the router in a `/24`. Also in the search and the export (`Role`). [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) -- New **Resolve mDNS hostname** setting (default off, with **Timeout**): the mDNS hostname (e.g. `macbook.local`) of reachable hosts is resolved with a reverse lookup sent to UDP port `5353` of the host (Apple devices, Linux with Avahi, Android, printers, IoT devices). Shown in the new **mDNS hostname** column and used as **Hostname** if there is no DNS hostname (before the NetBIOS name). Also in the search and the export (`MDNSHostname`). [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) -- New **Read the title of web pages** setting (default off, with **Timeout**): the title of the web page (`GET /`) of reachable hosts with an open web port is shown in the new **Web title** column (URL in the tooltip, `Server` header if the page has no title), e.g. to identify routers, printers and NAS. The HTTP status code and the `Server` and `X-Powered-By` headers are read as well. The ports are configurable (**Ports**, default `443; 80; 8443; 8080`, tried in this order, with a button to restore the default); only ports that are open in the port scan are requested, http on `80` / `8080`, https and then http on other ports. Redirects on the same host are followed, certificate errors are accepted, no cookies or credentials are sent. **Web Console** in the context menu opens the port that answered. Also in the search and the export (`WebTitle`, `WebURL`, `WebStatusCode`, `WebServer`, `WebPoweredBy`). [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) -- New **Read the SNMP system information** setting (default off, with **Version** `v1` / `v2c`, **Community** and **Timeout**): name, description and location (`sysName`, `sysDescr`, `sysLocation`) of reachable hosts are shown in the new **SNMP name**, **SNMP description** and **SNMP location** columns, e.g. to identify switches and printers. The community is saved as plain text in the settings (warning icon next to the field, use a read-only community). Also in the search and the export (`SNMPName`, `SNMPDescription`, `SNMPLocation`). [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) -- The **Hostname** and **MAC address** / **Vendor** columns (and the new **mDNS hostname**, **Web title** and **SNMP** columns) show `...` while the lookup / request is running instead of `-/-`, like the ping, port and NetBIOS columns, so a missing value can be told apart from one that is still resolved. **Copy** > **Hostname** / **MAC address** / **Vendor** is disabled until the value arrived. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) +- New **Resolve mDNS hostname** setting (default off, with **Timeout**): the mDNS hostname (e.g. `macbook.local`) of reachable hosts is resolved with a reverse lookup sent to UDP port `5353` of the host (Apple devices, Linux with Avahi, Android, printers, IoT devices). Shown in the row details (new **Hostnames** section with the DNS, mDNS and NetBIOS name) and used as **Hostname** if there is no DNS hostname (before the NetBIOS name). Also in the search and the export (`MDNSHostname`). [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) +- New **Read the title of web pages** setting (default off, with **Timeout**): the title of the web page (`GET /`) of reachable hosts with an open web port is shown in the new **Web title** column (URL in the tooltip, `Server` header if the page has no title), e.g. to identify routers, printers and NAS. The row details (new **Web** section) show the URL, the HTTP status code, the title and the `Server` and `X-Powered-By` headers. The ports are configurable (**Ports**, default `443; 80; 8443; 8080`, tried in this order, with a button to restore the default); only ports that are open in the port scan are requested, http on `80` / `8080`, https and then http on other ports. Redirects on the same host are followed, certificate errors are accepted, no cookies or credentials are sent. **Web Console** in the context menu opens the port that answered. Also in the search and the export (`WebTitle`, `WebURL`, `WebStatusCode`, `WebServer`, `WebPoweredBy`). [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) +- New **Read the SNMP system information** setting (default off, with **Version** `v1` / `v2c`, **Community** and **Timeout**): name, description and location (`sysName`, `sysDescr`, `sysLocation`) of reachable hosts are shown in the row details (new **SNMP** section), the name also in the new **SNMP name** column, e.g. to identify switches and printers. The community is saved as plain text in the settings (warning icon next to the field, use a read-only community). Also in the search and the export (`SNMPName`, `SNMPDescription`, `SNMPLocation`). [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) +- The **Hostname** and **MAC address** / **Vendor** columns (and the new **Web title** and **SNMP name** columns and the new row details) show `...` while the lookup / request is running instead of `-/-`, like the ping, port and NetBIOS columns, so a missing value can be told apart from one that is still resolved. **Copy** > **Hostname** / **MAC address** / **Vendor** is disabled until the value arrived. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) - The **Detect local subnet** button opens a menu: **Detect local subnet** (as before), **Subnet of network interface** to select the IPv4 subnet of a specific network interface (e.g. LAN, Wi-Fi, VPN, Hyper-V) and **All local subnets** to scan the subnets of all network interfaces. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) - IP addresses can be excluded from the scan with `!` in the host input (also in profiles and the run command), e.g. `10.0.0.0/24; !10.0.0.1; !10.0.0.200-254`. Works with IP addresses, ranges, subnets and hostnames. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) From 27c3bf5ac15de7c99b2bc05b1aaf1965b623121f Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Fri, 30 Oct 2026 21:05:36 +0100 Subject: [PATCH 020/131] Feature: IP Scanner: SNMP answer marks a host as up (C3b) The SNMP request is sent with the other probes to every IP address (like NetBIOS) instead of only to found hosts: a host that answers is up (IPScannerDetectionMethod.SNMP), also if it blocks the ping and all ports. An answer with an error (e.g. noSuchName of v1) counts as answer (empty values). The SNMP timeout is part of the host duration of the start scheduler. Co-Authored-By: Claude Opus 5.5 --- ...ScannerDetectionMethodToStringConverter.cs | 5 +- .../Resources/Strings.Designer.cs | 2 +- .../Resources/Strings.resx | 2 +- .../Network/IPScanner.cs | 76 ++++++++++++------- .../Network/IPScannerDetectionMethod.cs | 7 +- .../Network/SNMPSystemInfoResolver.cs | 11 ++- .../Network/SNMPSystemInfoResolverTests.cs | 6 +- Website/docs/application/ip-scanner.md | 6 +- Website/docs/changelog/next-release.md | 2 +- 9 files changed, 77 insertions(+), 40 deletions(-) diff --git a/Source/NETworkManager.Converters/IPScannerDetectionMethodToStringConverter.cs b/Source/NETworkManager.Converters/IPScannerDetectionMethodToStringConverter.cs index 88a2bd83e7..23c6180e48 100644 --- a/Source/NETworkManager.Converters/IPScannerDetectionMethodToStringConverter.cs +++ b/Source/NETworkManager.Converters/IPScannerDetectionMethodToStringConverter.cs @@ -47,7 +47,7 @@ public object Convert(object value, Type targetType, object parameter, CultureIn /// Translated names, empty if the host didn't answer any probe. private static List GetNames(IPScannerDetectionMethod detectedBy) { - var names = new List(4); + var names = new List(5); if (detectedBy.HasFlag(IPScannerDetectionMethod.ICMP)) names.Add(Strings.Ping); @@ -58,6 +58,9 @@ private static List GetNames(IPScannerDetectionMethod detectedBy) if (detectedBy.HasFlag(IPScannerDetectionMethod.NetBIOS)) names.Add(Strings.NetBIOS); + if (detectedBy.HasFlag(IPScannerDetectionMethod.SNMP)) + names.Add(Strings.SNMP); + if (detectedBy.HasFlag(IPScannerDetectionMethod.ARP)) names.Add(Strings.ARPNDP); diff --git a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs index 4da7096069..8ba2f5a230 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs +++ b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs @@ -6177,7 +6177,7 @@ public static string HelpMessage_RDPAuthenticationLevel { } /// - /// Looks up a localized string similar to Read the name, description and location (sysName, sysDescr, sysLocation) of every reachable host with an SNMP v1 / v2c request to UDP port 161. A host with another community usually doesn't answer (timeout). Many intrusion detection systems report requests with common communities.. + /// Looks up a localized string similar to Read the name, description and location (sysName, sysDescr, sysLocation) of every IP address with an SNMP v1 / v2c request to UDP port 161, sent with the other probes. A host that answers is shown as up (detected by SNMP), also if it doesn't answer the ping, a port or NetBIOS. A host with another community usually doesn't answer (timeout). Many intrusion detection systems report requests with common communities.. /// public static string HelpMessage_ReadTheSNMPSystemInformation { get { diff --git a/Source/NETworkManager.Localization/Resources/Strings.resx b/Source/NETworkManager.Localization/Resources/Strings.resx index d525bc1860..b190ac750d 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.resx +++ b/Source/NETworkManager.Localization/Resources/Strings.resx @@ -5240,7 +5240,7 @@ Do you want to start the scan? Read the SNMP system information - Read the name, description and location (sysName, sysDescr, sysLocation) of every reachable host with an SNMP v1 / v2c request to UDP port 161. A host with another community usually doesn't answer (timeout). Many intrusion detection systems report requests with common communities. + Read the name, description and location (sysName, sysDescr, sysLocation) of every IP address with an SNMP v1 / v2c request to UDP port 161, sent with the other probes. A host that answers is shown as up (detected by SNMP), also if it doesn't answer the ping, a port or NetBIOS. A host with another community usually doesn't answer (timeout). Many intrusion detection systems report requests with common communities. The community is saved as plain text in the settings file (not encrypted). Use a read-only community (e.g. public), not one with write access. diff --git a/Source/NETworkManager.Models/Network/IPScanner.cs b/Source/NETworkManager.Models/Network/IPScanner.cs index bfbb3c80ae..b27881a2d3 100644 --- a/Source/NETworkManager.Models/Network/IPScanner.cs +++ b/Source/NETworkManager.Models/Network/IPScanner.cs @@ -177,7 +177,8 @@ public void ScanAsync(IEnumerable<(IPAddress ipAddress, string hostname)> hosts, ? (long)options.PortScanTimeout * options.PortScanAttempts * (long)Math.Ceiling(options.PortScanPorts.Length / (double)options.MaxPortThreads) : 0, - options.NetBIOSEnabled ? options.NetBIOSTimeout : 0)); + Math.Max(options.NetBIOSEnabled ? options.NetBIOSTimeout : 0, + options.SNMPEnabled ? options.SNMPTimeout : 0))); var hostConcurrency = hosts.TryGetNonEnumeratedCount(out var hostCount) ? Math.Min(hostCount, options.MaxHostThreads) @@ -252,6 +253,12 @@ private async Task ScanHostAsync(IPAddress ipAddress, ParallelOptions portScanPa ? NetBIOSResolver.ResolveAsync(ipAddress, options.NetBIOSTimeout, cancellationToken) : Task.FromResult(new NetBIOSInfo(ipAddress)); + // A host that answers the SNMP request is up as well (null if it didn't answer) + var snmpTask = options.SNMPEnabled + ? SNMPSystemInfoResolver.ResolveAsync(ipAddress, options.SNMPVersion, options.SNMPCommunity, + options.SNMPTimeout, cancellationToken) + : null; + var enrichTask = Task.CompletedTask; // Results of the finished probes, null while a probe is running @@ -259,6 +266,10 @@ private async Task ScanHostAsync(IPAddress ipAddress, ParallelOptions portScanPa List ports = null; NetBIOSInfo netBIOSInfo = null; + // Result of the SNMP request (null if the host didn't answer), see isSNMPFinished + SNMPSystemInfo snmpInfo = null; + var isSNMPFinished = snmpTask == null; + // Probes that answered so far var detectedBy = IPScannerDetectionMethod.None; @@ -274,6 +285,9 @@ private async Task ScanHostAsync(IPAddress ipAddress, ParallelOptions portScanPa if (pingTask != null) pending.Add(pingTask); + if (snmpTask != null) + pending.Add(snmpTask); + while (pending.Count > 0) { var completed = await Task.WhenAny(pending).ConfigureAwait(false); @@ -333,6 +347,21 @@ private async Task ScanHostAsync(IPAddress ipAddress, ParallelOptions portScanPa x.IsNetBIOSPending = false; }; } + else if (completed == snmpTask) + { + var value = snmpInfo = snmpTask.Result; + isSNMPFinished = true; + + isAnswer = value != null; // SNMP response + method = IPScannerDetectionMethod.SNMP; + update = x => + { + x.SNMPName = value?.Name ?? string.Empty; + x.SNMPDescription = value?.Description ?? string.Empty; + x.SNMPLocation = value?.Location ?? string.Empty; + x.IsSNMPPending = false; + }; + } else { // A port answered - its result follows with the port scan @@ -364,7 +393,8 @@ private async Task ScanHostAsync(IPAddress ipAddress, ParallelOptions portScanPa // First answer: the host is up. Before it could answer, the local system had to resolve its MAC // address, so it is in the neighbor table (ARP/NDP) from now on. - hostInfo = CreateHostInfo(ipAddress, detectedBy, pingInfo, ports, netBIOSInfo); + hostInfo = CreateHostInfo(ipAddress, detectedBy, pingInfo, ports, netBIOSInfo, snmpInfo, + isSNMPFinished); isHttpTitlePending = hostInfo.IsHttpTitlePending; _hostsUp.Increment(); @@ -381,7 +411,8 @@ private async Task ScanHostAsync(IPAddress ipAddress, ParallelOptions portScanPa await IsNeighborAsync(ipAddress, networkInterfaces, cancellationToken).ConfigureAwait(false)) { detectedBy = IPScannerDetectionMethod.ARP; - hostInfo = CreateHostInfo(ipAddress, detectedBy, pingInfo, ports, netBIOSInfo); + hostInfo = CreateHostInfo(ipAddress, detectedBy, pingInfo, ports, netBIOSInfo, snmpInfo, + isSNMPFinished); isHttpTitlePending = hostInfo.IsHttpTitlePending; _hostsUp.Increment(); @@ -413,7 +444,8 @@ await IsNeighborAsync(ipAddress, networkInterfaces, cancellationToken).Configure if (options.ShowAllResults || options.ReportAllHosts) { - hostInfo = CreateHostInfo(ipAddress, IPScannerDetectionMethod.None, pingInfo, ports, netBIOSInfo); + hostInfo = CreateHostInfo(ipAddress, IPScannerDetectionMethod.None, pingInfo, ports, + netBIOSInfo, snmpInfo, isSNMPFinished); OnHostFound(new IPScannerHostFoundArgs(hostInfo)); @@ -429,8 +461,8 @@ await IsNeighborAsync(ipAddress, networkInterfaces, cancellationToken).Configure finally { // Never return while a probe or lookup is still running (e.g. another probe was canceled or failed) - await WaitIgnoringErrorsAsync(pingTask ?? Task.CompletedTask, portScanTask, netBIOSTask, enrichTask) - .ConfigureAwait(false); + await WaitIgnoringErrorsAsync(pingTask ?? Task.CompletedTask, portScanTask, netBIOSTask, + snmpTask ?? Task.CompletedTask, enrichTask).ConfigureAwait(false); } } @@ -473,9 +505,12 @@ private List GetShownPorts(List ports) /// Result of the ping, null if it is still running or disabled. /// All probed ports, null if the port scan is still running. /// Result of the NetBIOS request, null if it is still running. + /// Result of the SNMP request, null if it is still running or the host didn't answer. + /// The SNMP request is finished (or disabled). /// Result of the host. private IPScannerHostInfo CreateHostInfo(IPAddress ipAddress, IPScannerDetectionMethod detectedBy, - PingInfo pingInfo, List ports, NetBIOSInfo netBIOSInfo) + PingInfo pingInfo, List ports, NetBIOSInfo netBIOSInfo, SNMPSystemInfo snmpInfo, + bool isSNMPFinished) { var isReachable = detectedBy != IPScannerDetectionMethod.None; @@ -495,7 +530,10 @@ private IPScannerHostInfo CreateHostInfo(IPAddress ipAddress, IPScannerDetection IsDNSPending = options.ResolveHostname, IsMACAddressPending = options.ResolveMACAddress, IsMDNSPending = isReachable && options.MDNSEnabled, - IsSNMPPending = isReachable && options.SNMPEnabled, + IsSNMPPending = !isSNMPFinished, + SNMPName = snmpInfo?.Name ?? string.Empty, + SNMPDescription = snmpInfo?.Description ?? string.Empty, + SNMPLocation = snmpInfo?.Location ?? string.Empty, IsHttpTitlePending = isReachable && options.HttpTitleEnabled && (ports == null ? options.PortScanEnabled : GetWebPorts(ports).Count > 0), IsPortScanPending = ports == null, @@ -504,8 +542,8 @@ private IPScannerHostInfo CreateHostInfo(IPAddress ipAddress, IPScannerDetection } /// - /// Enriches a found host with the DNS hostname (PTR), MAC address and vendor, the mDNS hostname and the SNMP - /// system information (only reachable hosts). Each result is raised as as soon as it arrives; the task + /// Enriches a found host with the DNS hostname (PTR), MAC address and vendor and the mDNS hostname (only + /// reachable hosts). Each result is raised as as soon as it arrives; the task /// completes only when all lookups are finished, so the host (and the scan) is never finished while a lookup is /// still running. /// @@ -543,21 +581,6 @@ async Task UpdateMDNSHostnameAsync() })); } - async Task UpdateSNMPSystemInfoAsync() - { - var snmpSystemInfo = await SNMPSystemInfoResolver.ResolveAsync(ipAddress, options.SNMPVersion, - options.SNMPCommunity, options.SNMPTimeout, cancellationToken).ConfigureAwait(false); - - // Also without an answer, so the placeholders are replaced - OnHostUpdated(new IPScannerHostUpdatedArgs(host, x => - { - x.SNMPName = snmpSystemInfo?.Name ?? string.Empty; - x.SNMPDescription = snmpSystemInfo?.Description ?? string.Empty; - x.SNMPLocation = snmpSystemInfo?.Location ?? string.Empty; - x.IsSNMPPending = false; - })); - } - async Task UpdateMACAddressAsync() { var (macAddress, vendor) = await ResolveMACAddressAsync(ipAddress, discoveredAt, networkInterfaces, @@ -580,8 +603,7 @@ async Task UpdateMACAddressAsync() await Task.WhenAll( options.ResolveHostname ? UpdateHostnameAsync() : Task.CompletedTask, options.ResolveMACAddress ? UpdateMACAddressAsync() : Task.CompletedTask, - options.MDNSEnabled && isReachable ? UpdateMDNSHostnameAsync() : Task.CompletedTask, - options.SNMPEnabled && isReachable ? UpdateSNMPSystemInfoAsync() : Task.CompletedTask).ConfigureAwait(false); + options.MDNSEnabled && isReachable ? UpdateMDNSHostnameAsync() : Task.CompletedTask).ConfigureAwait(false); } /// diff --git a/Source/NETworkManager.Models/Network/IPScannerDetectionMethod.cs b/Source/NETworkManager.Models/Network/IPScannerDetectionMethod.cs index 7d5657905e..49587a3178 100644 --- a/Source/NETworkManager.Models/Network/IPScannerDetectionMethod.cs +++ b/Source/NETworkManager.Models/Network/IPScannerDetectionMethod.cs @@ -32,5 +32,10 @@ public enum IPScannerDetectionMethod /// The host answered the ARP request (IPv4) / neighbor solicitation (IPv6) of the probes, see /// . /// - ARP = 8 + ARP = 8, + + /// + /// The host answered the SNMP request (UDP 161). + /// + SNMP = 16 } diff --git a/Source/NETworkManager.Models/Network/SNMPSystemInfoResolver.cs b/Source/NETworkManager.Models/Network/SNMPSystemInfoResolver.cs index 3322d83a9b..fbe401fb7a 100644 --- a/Source/NETworkManager.Models/Network/SNMPSystemInfoResolver.cs +++ b/Source/NETworkManager.Models/Network/SNMPSystemInfoResolver.cs @@ -87,7 +87,10 @@ public static GetRequestMessage CreateRequest(int requestId, SNMPVersion version /// /// SNMP message of the answer. /// ID of the request. - /// System information, null if the answer is invalid, for another request or an error. + /// + /// System information (empty values if the host answered with an error, e.g. noSuchName of v1 - it is reachable), + /// null if the answer is invalid or for another request. + /// public static SNMPSystemInfo ParseResponse(byte[] response, int requestId) { ISnmpMessage message; @@ -101,10 +104,12 @@ public static SNMPSystemInfo ParseResponse(byte[] response, int requestId) return null; } - if (message is not ResponseMessage || message.RequestId() != requestId || - message.Pdu().ErrorStatus.ToInt32() != 0) + if (message is not ResponseMessage || message.RequestId() != requestId) return null; + if (message.Pdu().ErrorStatus.ToInt32() != 0) + return new SNMPSystemInfo(string.Empty, string.Empty, string.Empty); + var values = message.Variables().ToDictionary(x => x.Id.ToString(), x => GetValue(x.Data)); return new SNMPSystemInfo(values.GetValueOrDefault(SysNameOID, string.Empty), diff --git a/Source/NETworkManager.Tests/Network/SNMPSystemInfoResolverTests.cs b/Source/NETworkManager.Tests/Network/SNMPSystemInfoResolverTests.cs index 159c6ea440..d268a6fc92 100644 --- a/Source/NETworkManager.Tests/Network/SNMPSystemInfoResolverTests.cs +++ b/Source/NETworkManager.Tests/Network/SNMPSystemInfoResolverTests.cs @@ -70,12 +70,14 @@ public void ParseResponse_OtherRequest_Null() } [Fact] - public void ParseResponse_Error_Null() + public void ParseResponse_Error_EmptyValues() { + // The host answered (it is reachable), but without values var response = CreateResponse(RequestId, ErrorCode.NoSuchName, (SNMPSystemInfoResolver.SysNameOID, new Null())); - Assert.Null(SNMPSystemInfoResolver.ParseResponse(response, RequestId)); + Assert.Equal(new SNMPSystemInfo(string.Empty, string.Empty, string.Empty), + SNMPSystemInfoResolver.ParseResponse(response, RequestId)); } [Theory] diff --git a/Website/docs/application/ip-scanner.md b/Website/docs/application/ip-scanner.md index 0760eea2ca..9941cdf7f5 100644 --- a/Website/docs/application/ip-scanner.md +++ b/Website/docs/application/ip-scanner.md @@ -104,7 +104,7 @@ A host is shown (and counted as up) as soon as it answers the first probe: ping An icon after the status icon marks the hosts that have a role for this computer: **This computer** (own IP address), **Default-Gateway**, **DNS server** and **DHCP server** of a connected network interface. The tooltip lists all roles of the host (e.g. a router that is default gateway, DNS and DHCP server). The search box also finds them by role, the export contains them as `Role` (e.g. `DefaultGateway, DNSServer, DHCPServer`). -The probes a host answered are shown in the tooltip of its status icon (e.g. `Up (detected by Ping, TCP)`) and as **Detected by** in the row details (one label per probe): **Ping** (ICMP echo reply), **TCP** (a [port](#ports) is open or refused the connection) and **NetBIOS** (answer to the [NetBIOS](#scan-for-netbios) request) and **ARP / NDP** (a host in a local subnet that didn't answer any of them, see [Find hosts in the local subnet](#find-hosts-in-the-local-subnet)). Probes that answer after the host is shown are added. The export contains them as `DetectedBy` (e.g. `ICMP, TCP` or `ARP`, empty for unreachable hosts). +The probes a host answered are shown in the tooltip of its status icon (e.g. `Up (detected by Ping, TCP)`) and as **Detected by** in the row details (one label per probe): **Ping** (ICMP echo reply), **TCP** (a [port](#ports) is open or refused the connection) and **NetBIOS** (answer to the [NetBIOS](#scan-for-netbios) request), **SNMP** (answer to the [SNMP](#read-the-snmp-system-information) request) and **ARP / NDP** (a host in a local subnet that didn't answer any of them, see [Find hosts in the local subnet](#find-hosts-in-the-local-subnet)). Probes that answer after the host is shown are added. The export contains them as `DetectedBy` (e.g. `ICMP, TCP` or `ARP`, empty for unreachable hosts). Only reachable hosts are shown, unless [Show unreachable IP addresses and ports](#show-unreachable-ip-addresses-and-ports) is enabled. A message below the input is shown if no reachable host was found, the scan was canceled, a hostname could not be resolved or an error occurred. @@ -414,7 +414,7 @@ Timeout in milliseconds of a web request (per port, scheme and redirect). ### Read the SNMP system information -Read the name, description and location (`sysName`, `sysDescr`, `sysLocation`) of every reachable host with an SNMP `GET` request to UDP port `161` and show them in the row details (**SNMP**), the name also in the **SNMP name** column, e.g. to identify switches, printers and access points. +Read the name, description and location (`sysName`, `sysDescr`, `sysLocation`) of every IP address with an SNMP `GET` request to UDP port `161`, sent together with the ping, the port scan and the NetBIOS request. A host that answers is shown as up (**Detected by** `SNMP`), also if it doesn't answer any other probe (e.g. a switch or printer that blocks ICMP). The values are shown in the row details (**SNMP**), the name also in the **SNMP name** column, e.g. to identify switches, printers and access points. **Type:** `Boolean` @@ -422,7 +422,7 @@ Read the name, description and location (`sysName`, `sysDescr`, `sysLocation`) o :::note -A host with another community usually doesn't answer at all (no value after the timeout). Many intrusion detection systems report requests with common communities like `public`. SNMP v3 is not supported (it needs credentials), use the [SNMP](./snmp) tool for it. +A host with another community usually doesn't answer at all (no value after the timeout); an answer with an error (e.g. `noSuchName` of SNMP v1) still counts as up. The request is sent to every IP address of the range, also unused ones. Many intrusion detection systems report requests with common communities like `public`. SNMP v3 is not supported (it needs credentials), use the [SNMP](./snmp) tool for it. ::: diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index d202327a7f..ff120f320d 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -94,7 +94,7 @@ Release date: **xx.xx.2026** - An icon in the result marks this computer, the default gateway and the DNS and DHCP servers of the connected network interfaces (all roles in the tooltip), e.g. to find the router in a `/24`. Also in the search and the export (`Role`). [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) - New **Resolve mDNS hostname** setting (default off, with **Timeout**): the mDNS hostname (e.g. `macbook.local`) of reachable hosts is resolved with a reverse lookup sent to UDP port `5353` of the host (Apple devices, Linux with Avahi, Android, printers, IoT devices). Shown in the row details (new **Hostnames** section with the DNS, mDNS and NetBIOS name) and used as **Hostname** if there is no DNS hostname (before the NetBIOS name). Also in the search and the export (`MDNSHostname`). [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) - New **Read the title of web pages** setting (default off, with **Timeout**): the title of the web page (`GET /`) of reachable hosts with an open web port is shown in the new **Web title** column (URL in the tooltip, `Server` header if the page has no title), e.g. to identify routers, printers and NAS. The row details (new **Web** section) show the URL, the HTTP status code, the title and the `Server` and `X-Powered-By` headers. The ports are configurable (**Ports**, default `443; 80; 8443; 8080`, tried in this order, with a button to restore the default); only ports that are open in the port scan are requested, http on `80` / `8080`, https and then http on other ports. Redirects on the same host are followed, certificate errors are accepted, no cookies or credentials are sent. **Web Console** in the context menu opens the port that answered. Also in the search and the export (`WebTitle`, `WebURL`, `WebStatusCode`, `WebServer`, `WebPoweredBy`). [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) -- New **Read the SNMP system information** setting (default off, with **Version** `v1` / `v2c`, **Community** and **Timeout**): name, description and location (`sysName`, `sysDescr`, `sysLocation`) of reachable hosts are shown in the row details (new **SNMP** section), the name also in the new **SNMP name** column, e.g. to identify switches and printers. The community is saved as plain text in the settings (warning icon next to the field, use a read-only community). Also in the search and the export (`SNMPName`, `SNMPDescription`, `SNMPLocation`). [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) +- New **Read the SNMP system information** setting (default off, with **Version** `v1` / `v2c`, **Community** and **Timeout**): name, description and location (`sysName`, `sysDescr`, `sysLocation`) are read with the other probes from every IP address - a host that answers is shown as up (**Detected by** `SNMP`), also if it blocks the ping and all ports - and shown in the row details (new **SNMP** section), the name also in the new **SNMP name** column, e.g. to identify switches and printers. The community is saved as plain text in the settings (warning icon next to the field, use a read-only community). Also in the search and the export (`SNMPName`, `SNMPDescription`, `SNMPLocation`). [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) - The **Hostname** and **MAC address** / **Vendor** columns (and the new **Web title** and **SNMP name** columns and the new row details) show `...` while the lookup / request is running instead of `-/-`, like the ping, port and NetBIOS columns, so a missing value can be told apart from one that is still resolved. **Copy** > **Hostname** / **MAC address** / **Vendor** is disabled until the value arrived. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) - The **Detect local subnet** button opens a menu: **Detect local subnet** (as before), **Subnet of network interface** to select the IPv4 subnet of a specific network interface (e.g. LAN, Wi-Fi, VPN, Hyper-V) and **All local subnets** to scan the subnets of all network interfaces. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) - IP addresses can be excluded from the scan with `!` in the host input (also in profiles and the run command), e.g. `10.0.0.0/24; !10.0.0.1; !10.0.0.200-254`. Works with IP addresses, ranges, subnets and hostnames. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) From 852a525ca7ab125709afdba25311b9cfb1fa957a Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Fri, 30 Oct 2026 21:08:22 +0100 Subject: [PATCH 021/131] Feature: IP Scanner: Row details in four columns with up / down labels (C5b) Row details: Ping details / Hostnames | Ports | Web | NetBIOS / SNMP (1* | 2* | 1* | 1*, ports and web span both rows). The "Detected by" row is replaced by an Up / Down label in the header of each probe that can find a host (ping, ports, NetBIOS, SNMP; IPScannerDetectionMethodToUpDown converter, hidden while running). Sections of disabled settings show a note like ports and NetBIOS (hostnames: DNS and mDNS disabled). The SNMP location uses its own string (with a comment: physical location, not a file location). Co-Authored-By: Claude Opus 5.5 --- ...ScannerDetectionMethodToStringConverter.cs | 17 +- ...ScannerDetectionMethodToUpDownConverter.cs | 44 +++ .../Resources/Strings.Designer.cs | 45 ++- .../Resources/Strings.resx | 16 +- .../NETworkManager/Views/IPScannerView.xaml | 269 +++++++++++------- Website/docs/application/ip-scanner.md | 4 +- Website/docs/changelog/next-release.md | 3 +- 7 files changed, 264 insertions(+), 134 deletions(-) create mode 100644 Source/NETworkManager.Converters/IPScannerDetectionMethodToUpDownConverter.cs diff --git a/Source/NETworkManager.Converters/IPScannerDetectionMethodToStringConverter.cs b/Source/NETworkManager.Converters/IPScannerDetectionMethodToStringConverter.cs index 23c6180e48..12ba34d9e0 100644 --- a/Source/NETworkManager.Converters/IPScannerDetectionMethodToStringConverter.cs +++ b/Source/NETworkManager.Converters/IPScannerDetectionMethodToStringConverter.cs @@ -18,10 +18,7 @@ public sealed class IPScannerDetectionMethodToStringConverter : IValueConverter /// /// Probes the host answered as . /// - /// - /// "Status" to get the status of the host ("Up (detected by Ping, TCP)" / "Down"), "List" to get the - /// translated probes as list (e.g. for labels). - /// + /// "Status" to get the status of the host ("Up (detected by Ping, TCP)" / "Down"). /// /// Translated, comma-separated probes, "-/-" if the host didn't answer any probe. public object Convert(object value, Type targetType, object parameter, CultureInfo culture) @@ -30,14 +27,12 @@ public object Convert(object value, Type targetType, object parameter, CultureIn var names = GetNames(detectedBy); - return parameter as string switch - { - "List" => names, - "Status" => detectedBy == IPScannerDetectionMethod.None + if (parameter as string == "Status") + return detectedBy == IPScannerDetectionMethod.None ? Strings.Down - : string.Format(Strings.UpDetectedByX, string.Join(", ", names)), - _ => detectedBy == IPScannerDetectionMethod.None ? "-/-" : string.Join(", ", names) - }; + : string.Format(Strings.UpDetectedByX, string.Join(", ", names)); + + return detectedBy == IPScannerDetectionMethod.None ? "-/-" : string.Join(", ", names); } /// diff --git a/Source/NETworkManager.Converters/IPScannerDetectionMethodToUpDownConverter.cs b/Source/NETworkManager.Converters/IPScannerDetectionMethodToUpDownConverter.cs new file mode 100644 index 0000000000..739e47056f --- /dev/null +++ b/Source/NETworkManager.Converters/IPScannerDetectionMethodToUpDownConverter.cs @@ -0,0 +1,44 @@ +using System; +using System.Globalization; +using System.Windows.Data; +using NETworkManager.Localization.Resources; +using NETworkManager.Models.Network; + +namespace NETworkManager.Converters; + +/// +/// Convert the of a host to "Up" / "Down" for one probe (e.g. the label in +/// the header of the ping details). +/// +public sealed class IPScannerDetectionMethodToUpDownConverter : IValueConverter +{ + /// + /// Convert the of a host to "Up" if it answered the probe, else "Down". + /// + /// Probes the host answered as . + /// + /// Probe as name of (e.g. "ICMP"). + /// + /// Translated "Up" or "Down". + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is not IPScannerDetectionMethod detectedBy || + !Enum.TryParse(parameter as string, out var method)) + return Strings.Down; + + return detectedBy.HasFlag(method) ? Strings.Up : Strings.Down; + } + + /// + /// !!! Method not implemented !!! + /// + /// + /// + /// + /// + /// + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} diff --git a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs index 8ba2f5a230..5399969a89 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs +++ b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs @@ -3415,15 +3415,6 @@ public static string Details { } } - /// - /// Looks up a localized string similar to Detected by. - /// - public static string DetectedBy { - get { - return ResourceManager.GetString("DetectedBy", resourceCulture); - } - } - /// /// Looks up a localized string similar to Detecting network.... /// @@ -6392,6 +6383,15 @@ public static string HostnameOrIPAddress { } } + /// + /// Looks up a localized string similar to Hostname resolution is disabled!. + /// + public static string HostnameResolutionIsDisabled { + get { + return ResourceManager.GetString("HostnameResolutionIsDisabled", resourceCulture); + } + } + /// /// Looks up a localized string similar to Hostnames. /// @@ -10293,6 +10293,15 @@ public static string RDAPXIsNotARegisteredDomainShowingY { } } + /// + /// Looks up a localized string similar to Reading web pages is disabled!. + /// + public static string ReadingWebPagesIsDisabled { + get { + return ResourceManager.GetString("ReadingWebPagesIsDisabled", resourceCulture); + } + } + /// /// Looks up a localized string similar to Read the SNMP system information. /// @@ -13320,6 +13329,24 @@ public static string SNMPErrorCode_WrongValue { } } + /// + /// Looks up a localized string similar to SNMP is disabled!. + /// + public static string SNMPIsDisabled { + get { + return ResourceManager.GetString("SNMPIsDisabled", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Location. + /// + public static string SNMPLocation { + get { + return ResourceManager.GetString("SNMPLocation", resourceCulture); + } + } + /// /// Looks up a localized string similar to SNMP name. /// diff --git a/Source/NETworkManager.Localization/Resources/Strings.resx b/Source/NETworkManager.Localization/Resources/Strings.resx index b190ac750d..b073932595 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.resx +++ b/Source/NETworkManager.Localization/Resources/Strings.resx @@ -5152,9 +5152,6 @@ Do you want to start the scan? PTR records can only be looked up for IP addresses. - - Detected by - Up (detected by {0}) @@ -5263,4 +5260,17 @@ Do you want to start the scan? X-Powered-By + + Location + Physical location of a device (SNMP sysLocation, e.g. "Server room"), not the location of a file or folder. + + + Hostname resolution is disabled! + + + Reading web pages is disabled! + + + SNMP is disabled! + \ No newline at end of file diff --git a/Source/NETworkManager/Views/IPScannerView.xaml b/Source/NETworkManager/Views/IPScannerView.xaml index 718d2a8f14..0c60208772 100644 --- a/Source/NETworkManager/Views/IPScannerView.xaml +++ b/Source/NETworkManager/Views/IPScannerView.xaml @@ -26,6 +26,7 @@ + @@ -39,6 +40,17 @@ + + @@ -996,11 +1008,10 @@ + - - - + @@ -1009,6 +1020,8 @@ + + - - - - - - - - - - - - - - - - - - - - - - - - + - + + + + + + + + + + + + + - + - + + + + + + + + + + + + + - + - + + + + + + + + + + + + + - - - - - - - - - - - - + + - - + + + + + + + + + + + + - + - + @@ -839,17 +829,24 @@ - - - - - - - - + + + + + + + + + + + - - - - - - - - + + + + + + + + + + + @@ -944,17 +948,24 @@ - - - - - - - - + + + + + + + + + + + @@ -1038,23 +1049,23 @@ - + - - - + + + - - + + - + - - - + + + - - + + - + - - - + + + - - + + - + - + - + - - - + + + - - + + **Hostname** / **MAC address** / **Vendor** is disabled until the value arrived. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) +- The **Ping status**, **Port status** and **NetBIOS reachable** columns show a status icon (green check / red cross, like the action of a rule in the **Firewall**) instead of the text (e.g. `Success` / `Timed out`, `Open` / `Closed`, `Yes` / `No`); the tooltip shows the details: ping status with time and TTL, the open ports with their service and the NetBIOS computer name and workgroup / domain. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) - The row details use four columns: **Ping details** / **Hostnames**, **Ports**, **Web** and **NetBIOS** / **SNMP**; a section of a disabled setting shows a note like the ports and NetBIOS. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) - The **Detect local subnet** button opens a menu: **Detect local subnet** (as before), **Subnet of network interface** to select the IPv4 subnet of a specific network interface (e.g. LAN, Wi-Fi, VPN, Hyper-V) and **All local subnets** to scan the subnets of all network interfaces. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) - IP addresses can be excluded from the scan with `!` in the host input (also in profiles and the run command), e.g. `10.0.0.0/24; !10.0.0.1; !10.0.0.200-254`. Works with IP addresses, ranges, subnets and hostnames. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) From 9bb40b95d1fb57a57e8c476d0428c1d28c2da686 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Fri, 30 Oct 2026 21:58:48 +0100 Subject: [PATCH 024/131] Feature: IP Scanner: Icon for the OS guess (B1b) The OS (guess) column shows an icon before the text (Windows logo, terminal for Linux / Unix, switch for a network device - not the router icon of the gateway role) and "..." while the ping is running. The hops stay in the tooltip; sorting, search, Copy and export are unchanged. Co-Authored-By: Claude Opus 5.5 --- .../IPScannerOSGuessToStringConverter.cs | 8 +- .../NETworkManager/Views/IPScannerView.xaml | 77 ++++++++++++++----- Website/docs/application/ip-scanner.md | 12 +-- Website/docs/changelog/next-release.md | 2 +- 4 files changed, 70 insertions(+), 29 deletions(-) diff --git a/Source/NETworkManager.Converters/IPScannerOSGuessToStringConverter.cs b/Source/NETworkManager.Converters/IPScannerOSGuessToStringConverter.cs index 31b7a36850..e2713d565f 100644 --- a/Source/NETworkManager.Converters/IPScannerOSGuessToStringConverter.cs +++ b/Source/NETworkManager.Converters/IPScannerOSGuessToStringConverter.cs @@ -14,7 +14,10 @@ public sealed class IPScannerOSGuessToStringConverter : IMultiValueConverter /// /// Convert the and the hops of a host to a translated . /// - /// and the hops (). + /// + /// , the hops () and optionally whether the ping is still + /// running (, "..." then). + /// /// /// "ToolTip" to get the tooltip ("Based on the TTL of the ping reply (2 hops)"). /// @@ -22,9 +25,10 @@ public sealed class IPScannerOSGuessToStringConverter : IMultiValueConverter public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture) { var osGuess = values.Length > 0 && values[0] is IPScannerOSGuess value ? value : IPScannerOSGuess.None; + var isPending = values.Length > 2 && values[2] is true; if (parameter as string != "ToolTip") - return osGuess == IPScannerOSGuess.None ? "-/-" : ToLocalizedString(osGuess); + return isPending ? "..." : osGuess == IPScannerOSGuess.None ? "-/-" : ToLocalizedString(osGuess); if (osGuess == IPScannerOSGuess.None) return null; diff --git a/Source/NETworkManager/Views/IPScannerView.xaml b/Source/NETworkManager/Views/IPScannerView.xaml index 380ca26f08..4927717185 100644 --- a/Source/NETworkManager/Views/IPScannerView.xaml +++ b/Source/NETworkManager/Views/IPScannerView.xaml @@ -859,37 +859,74 @@ - - + + + - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Date: Fri, 30 Oct 2026 22:02:31 +0100 Subject: [PATCH 025/131] Feature: IP Scanner: Rename "Web title" to "Web" (C2d) The feature reads the status code, the title and headers, not only the title: the column and the settings section are called "Web", the setting "Read web pages" (help text with all values). Code and settings (all unreleased, no migration): HttpTitle* -> WebPage* (IPScanner_WebPage Enabled / Ports / Timeout, WebPageResolver, WebPageInfo, IsWebPagePending). Co-Authored-By: Claude Opus 5.5 --- .../Resources/Strings.Designer.cs | 21 ++++-------- .../Resources/Strings.resx | 15 ++++---- .../Export/ExportManager.IPScannerHostInfo.cs | 26 +++++++------- .../Network/IPScanner.cs | 34 +++++++++---------- .../Network/IPScannerHostInfo.cs | 8 ++--- .../Network/IPScannerOptions.cs | 8 ++--- .../Network/IPScannerResultOptions.cs | 6 ++-- .../{HttpTitleInfo.cs => WebPageInfo.cs} | 4 +-- ...ttpTitleResolver.cs => WebPageResolver.cs} | 10 +++--- .../GlobalStaticConfiguration.cs | 6 ++-- .../NETworkManager.Settings/SettingsInfo.cs | 12 +++---- ...solverTests.cs => WebPageResolverTests.cs} | 18 +++++----- .../ViewModels/IPScannerSettingsViewModel.cs | 24 ++++++------- .../ViewModels/IPScannerViewModel.cs | 20 +++++------ .../Views/IPScannerSettingsView.xaml | 22 ++++++------ .../NETworkManager/Views/IPScannerView.xaml | 20 +++++------ Website/docs/application/ip-scanner.md | 8 ++--- Website/docs/changelog/next-release.md | 4 +-- 18 files changed, 127 insertions(+), 139 deletions(-) rename Source/NETworkManager.Models/Network/{HttpTitleInfo.cs => WebPageInfo.cs} (76%) rename Source/NETworkManager.Models/Network/{HttpTitleResolver.cs => WebPageResolver.cs} (95%) rename Source/NETworkManager.Tests/Network/{HttpTitleResolverTests.cs => WebPageResolverTests.cs} (73%) diff --git a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs index 0118cc7359..00a729380a 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs +++ b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs @@ -6186,11 +6186,11 @@ public static string HelpMessage_ReadTheSNMPSystemInformation { } /// - /// Looks up a localized string similar to Read the title of the web page (GET /) of every reachable host with an open port of the list below, in this order (the port scan must be enabled and the ports must also be in its list). The first port that answers is used (http on 80 and 8080, https and then http on other ports), redirects on the same host are followed. No cookies or credentials are sent, certificate errors are accepted. The request appears in the log of the web server. If the page has no title, the Server header is shown.. + /// Looks up a localized string similar to Read the web page (GET /) of every reachable host with an open port of the list below, in this order (the port scan must be enabled and the ports must also be in its list): status code, title and the Server and X-Powered-By headers. The first port that answers is used (http on 80 and 8080, https and then http on other ports), redirects on the same host are followed. No cookies or credentials are sent, certificate errors are accepted. The request appears in the log of the web server.. /// - public static string HelpMessage_ReadTheTitleOfWebPages { + public static string HelpMessage_ReadWebPages { get { - return ResourceManager.GetString("HelpMessage_ReadTheTitleOfWebPages", resourceCulture); + return ResourceManager.GetString("HelpMessage_ReadWebPages", resourceCulture); } } @@ -10330,11 +10330,11 @@ public static string ReadTheSNMPSystemInformation { } /// - /// Looks up a localized string similar to Read the title of web pages. + /// Looks up a localized string similar to Read web pages. /// - public static string ReadTheTitleOfWebPages { + public static string ReadWebPages { get { - return ResourceManager.GetString("ReadTheTitleOfWebPages", resourceCulture); + return ResourceManager.GetString("ReadWebPages", resourceCulture); } } @@ -14952,15 +14952,6 @@ public static string Website { } } - /// - /// Looks up a localized string similar to Web title. - /// - public static string WebTitle { - get { - return ResourceManager.GetString("WebTitle", resourceCulture); - } - } - /// /// Looks up a localized string similar to The WebView control (Microsoft Edge) cannot connect to websites with an invalid certificate!. /// diff --git a/Source/NETworkManager.Localization/Resources/Strings.resx b/Source/NETworkManager.Localization/Resources/Strings.resx index 133fb95b70..758906a322 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.resx +++ b/Source/NETworkManager.Localization/Resources/Strings.resx @@ -5221,15 +5221,6 @@ Do you want to start the scan? Resolve the mDNS hostname (e.g. macbook.local) of every reachable host with a reverse lookup sent to UDP port 5353 of the host. Answered by Apple devices, Linux (Avahi), Android, printers and many IoT devices, usually only in the local subnet. Used as hostname if there is no DNS hostname. - - Web title - - - Read the title of web pages - - - Read the title of the web page (GET /) of every reachable host with an open port of the list below, in this order (the port scan must be enabled and the ports must also be in its list). The first port that answers is used (http on 80 and 8080, https and then http on other ports), redirects on the same host are followed. No cookies or credentials are sent, certificate errors are accepted. The request appears in the log of the web server. If the page has no title, the Server header is shown. - SNMP name @@ -5279,4 +5270,10 @@ Do you want to start the scan? No answer + + Read web pages + + + Read the web page (GET /) of every reachable host with an open port of the list below, in this order (the port scan must be enabled and the ports must also be in its list): status code, title and the Server and X-Powered-By headers. The first port that answers is used (http on 80 and 8080, https and then http on other ports), redirects on the same host are followed. No cookies or credentials are sent, certificate errors are accepted. The request appears in the log of the web server. + \ No newline at end of file diff --git a/Source/NETworkManager.Models/Export/ExportManager.IPScannerHostInfo.cs b/Source/NETworkManager.Models/Export/ExportManager.IPScannerHostInfo.cs index aa0c725953..24b8c5f86a 100644 --- a/Source/NETworkManager.Models/Export/ExportManager.IPScannerHostInfo.cs +++ b/Source/NETworkManager.Models/Export/ExportManager.IPScannerHostInfo.cs @@ -134,14 +134,14 @@ private static void CreateCsv(IEnumerable collection, IPScann $"{GetOSGuess(info, options)}," + $"{CsvHelper.QuoteString(GetRole(info))}," + $"{CsvHelper.QuoteString(options.MDNSEnabled ? info.MDNSHostname : null)}," + - $"{CsvHelper.QuoteString(options.HttpTitleEnabled ? info.WebTitle : null)}," + - $"{CsvHelper.QuoteString(options.HttpTitleEnabled ? info.WebURL : null)}," + + $"{CsvHelper.QuoteString(options.WebPageEnabled ? info.WebTitle : null)}," + + $"{CsvHelper.QuoteString(options.WebPageEnabled ? info.WebURL : null)}," + $"{CsvHelper.QuoteString(options.SNMPEnabled ? info.SNMPName : null)}," + $"{CsvHelper.QuoteString(options.SNMPEnabled ? info.SNMPDescription : null)}," + $"{CsvHelper.QuoteString(options.SNMPEnabled ? info.SNMPLocation : null)}," + $"{GetWebStatusCode(info, options)}," + - $"{CsvHelper.QuoteString(options.HttpTitleEnabled ? info.WebServer : null)}," + - $"{CsvHelper.QuoteString(options.HttpTitleEnabled ? info.WebPoweredBy : null)}" + $"{CsvHelper.QuoteString(options.WebPageEnabled ? info.WebServer : null)}," + + $"{CsvHelper.QuoteString(options.WebPageEnabled ? info.WebPoweredBy : null)}" ); } @@ -204,8 +204,8 @@ from info in collection new XElement(nameof(IPScannerHostInfo.MDNSHostname), options.MDNSEnabled ? info.MDNSHostname : null), new XElement(nameof(IPScannerHostInfo.WebTitle), - options.HttpTitleEnabled ? info.WebTitle : null), - new XElement(nameof(IPScannerHostInfo.WebURL), options.HttpTitleEnabled ? info.WebURL : null), + options.WebPageEnabled ? info.WebTitle : null), + new XElement(nameof(IPScannerHostInfo.WebURL), options.WebPageEnabled ? info.WebURL : null), new XElement(nameof(IPScannerHostInfo.SNMPName), options.SNMPEnabled ? info.SNMPName : null), new XElement(nameof(IPScannerHostInfo.SNMPDescription), options.SNMPEnabled ? info.SNMPDescription : null), @@ -213,9 +213,9 @@ from info in collection options.SNMPEnabled ? info.SNMPLocation : null), new XElement(nameof(IPScannerHostInfo.WebStatusCode), GetWebStatusCode(info, options)), new XElement(nameof(IPScannerHostInfo.WebServer), - options.HttpTitleEnabled ? info.WebServer : null), + options.WebPageEnabled ? info.WebServer : null), new XElement(nameof(IPScannerHostInfo.WebPoweredBy), - options.HttpTitleEnabled ? info.WebPoweredBy : null) + options.WebPageEnabled ? info.WebPoweredBy : null) ) ) ) @@ -290,14 +290,14 @@ private static void CreateJson(IReadOnlyList collection, IPSc OSGuess = GetOSGuess(info, options), Role = GetRole(info), MDNSHostname = options.MDNSEnabled ? info.MDNSHostname : null, - WebTitle = options.HttpTitleEnabled ? info.WebTitle : null, - WebURL = options.HttpTitleEnabled ? info.WebURL : null, + WebTitle = options.WebPageEnabled ? info.WebTitle : null, + WebURL = options.WebPageEnabled ? info.WebURL : null, SNMPName = options.SNMPEnabled ? info.SNMPName : null, SNMPDescription = options.SNMPEnabled ? info.SNMPDescription : null, SNMPLocation = options.SNMPEnabled ? info.SNMPLocation : null, WebStatusCode = GetWebStatusCode(info, options), - WebServer = options.HttpTitleEnabled ? info.WebServer : null, - WebPoweredBy = options.HttpTitleEnabled ? info.WebPoweredBy : null + WebServer = options.WebPageEnabled ? info.WebServer : null, + WebPoweredBy = options.WebPageEnabled ? info.WebPoweredBy : null }; } @@ -322,7 +322,7 @@ private static string GetDetectedBy(IPScannerHostInfo info) /// Status code or null. private static int? GetWebStatusCode(IPScannerHostInfo info, IPScannerResultOptions options) { - return options.HttpTitleEnabled && info.WebStatusCode > 0 ? info.WebStatusCode : null; + return options.WebPageEnabled && info.WebStatusCode > 0 ? info.WebStatusCode : null; } /// diff --git a/Source/NETworkManager.Models/Network/IPScanner.cs b/Source/NETworkManager.Models/Network/IPScanner.cs index 6355e447dd..83366a3a93 100644 --- a/Source/NETworkManager.Models/Network/IPScanner.cs +++ b/Source/NETworkManager.Models/Network/IPScanner.cs @@ -274,7 +274,7 @@ private async Task ScanHostAsync(IPAddress ipAddress, ParallelOptions portScanPa var detectedBy = IPScannerDetectionMethod.None; // The title of the web page is read for the found host (when the port scan is finished) - var isHttpTitlePending = false; + var isWebPagePending = false; IPScannerHostInfo hostInfo = null; @@ -395,7 +395,7 @@ private async Task ScanHostAsync(IPAddress ipAddress, ParallelOptions portScanPa // address, so it is in the neighbor table (ARP/NDP) from now on. hostInfo = CreateHostInfo(ipAddress, detectedBy, pingInfo, ports, netBIOSInfo, snmpInfo, isSNMPFinished); - isHttpTitlePending = hostInfo.IsHttpTitlePending; + isWebPagePending = hostInfo.IsWebPagePending; _hostsUp.Increment(); @@ -413,7 +413,7 @@ await IsNeighborAsync(ipAddress, networkInterfaces, cancellationToken).Configure detectedBy = IPScannerDetectionMethod.ARP; hostInfo = CreateHostInfo(ipAddress, detectedBy, pingInfo, ports, netBIOSInfo, snmpInfo, isSNMPFinished); - isHttpTitlePending = hostInfo.IsHttpTitlePending; + isWebPagePending = hostInfo.IsWebPagePending; _hostsUp.Increment(); @@ -425,15 +425,15 @@ await IsNeighborAsync(ipAddress, networkInterfaces, cancellationToken).Configure // Title of the web page - needs the open ports, so after the port scan. Without an open web port, the // placeholder of a host found while the port scan was running is replaced. - if (isHttpTitlePending) + if (isWebPagePending) { var webPorts = GetWebPorts(ports); if (webPorts.Count > 0) enrichTask = Task.WhenAll(enrichTask, - UpdateHttpTitleAsync(ipAddress, hostInfo, webPorts, cancellationToken)); + UpdateWebPageAsync(ipAddress, hostInfo, webPorts, cancellationToken)); else - OnHostUpdated(new IPScannerHostUpdatedArgs(hostInfo, x => x.IsHttpTitlePending = false)); + OnHostUpdated(new IPScannerHostUpdatedArgs(hostInfo, x => x.IsWebPagePending = false)); } if (hostInfo == null) @@ -534,7 +534,7 @@ private IPScannerHostInfo CreateHostInfo(IPAddress ipAddress, IPScannerDetection SNMPName = snmpInfo?.Name ?? string.Empty, SNMPDescription = snmpInfo?.Description ?? string.Empty, SNMPLocation = snmpInfo?.Location ?? string.Empty, - IsHttpTitlePending = isReachable && options.HttpTitleEnabled && + IsWebPagePending = isReachable && options.WebPageEnabled && (ports == null ? options.PortScanEnabled : GetWebPorts(ports).Count > 0), IsPortScanPending = ports == null, IsNetBIOSPending = netBIOSInfo == null @@ -607,7 +607,7 @@ await Task.WhenAll( } /// - /// Gets the ports whose web page title is read (see ) that are + /// Gets the ports whose web page title is read (see ) that are /// open, in the configured order. /// /// All probed ports, null if the port scan is disabled. @@ -619,7 +619,7 @@ private List GetWebPorts(List ports) var openPorts = ports.Where(x => x.State == PortState.Open).Select(x => x.Port).ToHashSet(); - return [.. options.HttpTitlePorts.Where(openPorts.Contains)]; + return [.. options.WebPagePorts.Where(openPorts.Contains)]; } /// @@ -629,21 +629,21 @@ private List GetWebPorts(List ports) /// Result of the host, already raised in . /// Open ports to request, in the order to try them. /// Token to monitor for cancellation requests. - private async Task UpdateHttpTitleAsync(IPAddress ipAddress, IPScannerHostInfo host, List webPorts, + private async Task UpdateWebPageAsync(IPAddress ipAddress, IPScannerHostInfo host, List webPorts, CancellationToken cancellationToken) { - var httpTitleInfo = await HttpTitleResolver.ResolveAsync(ipAddress, webPorts, options.HttpTitleTimeout, + var webPageInfo = await WebPageResolver.ResolveAsync(ipAddress, webPorts, options.WebPageTimeout, cancellationToken).ConfigureAwait(false); // Also without an answer, so the placeholder is replaced OnHostUpdated(new IPScannerHostUpdatedArgs(host, x => { - x.WebTitle = httpTitleInfo?.Title ?? string.Empty; - x.WebURL = httpTitleInfo?.URL ?? string.Empty; - x.WebStatusCode = httpTitleInfo?.StatusCode ?? 0; - x.WebServer = httpTitleInfo?.Server ?? string.Empty; - x.WebPoweredBy = httpTitleInfo?.PoweredBy ?? string.Empty; - x.IsHttpTitlePending = false; + x.WebTitle = webPageInfo?.Title ?? string.Empty; + x.WebURL = webPageInfo?.URL ?? string.Empty; + x.WebStatusCode = webPageInfo?.StatusCode ?? 0; + x.WebServer = webPageInfo?.Server ?? string.Empty; + x.WebPoweredBy = webPageInfo?.PoweredBy ?? string.Empty; + x.IsWebPagePending = false; })); } diff --git a/Source/NETworkManager.Models/Network/IPScannerHostInfo.cs b/Source/NETworkManager.Models/Network/IPScannerHostInfo.cs index e85857dd9b..c0f2da1016 100644 --- a/Source/NETworkManager.Models/Network/IPScannerHostInfo.cs +++ b/Source/NETworkManager.Models/Network/IPScannerHostInfo.cs @@ -349,7 +349,7 @@ internal set (string.IsNullOrEmpty(NeighborMACAddress) && IsNetBIOSPending); /// - /// Title of the web page of the host, see . Empty if it has none or it was not + /// Title of the web page of the host, see . Empty if it has none or it was not /// read (yet). /// public string WebTitle @@ -540,7 +540,7 @@ internal set /// Indicates whether the title of the web page is still read, or the port scan it needs is still running /// ( is a placeholder). /// - public bool IsHttpTitlePending + public bool IsWebPagePending { get; internal set @@ -600,7 +600,7 @@ public void CopyFrom(IPScannerHostInfo source) IsDNSPending = source.IsDNSPending; IsNeighborMACAddressPending = source.IsNeighborMACAddressPending; IsMDNSPending = source.IsMDNSPending; - IsHttpTitlePending = source.IsHttpTitlePending; + IsWebPagePending = source.IsWebPagePending; IsSNMPPending = source.IsSNMPPending; NeighborMACAddress = source.NeighborMACAddress; NeighborVendor = source.NeighborVendor; @@ -633,7 +633,7 @@ public IPScannerHostInfo Clone() IsDNSPending = IsDNSPending, IsNeighborMACAddressPending = IsNeighborMACAddressPending, IsMDNSPending = IsMDNSPending, - IsHttpTitlePending = IsHttpTitlePending, + IsWebPagePending = IsWebPagePending, IsSNMPPending = IsSNMPPending, NeighborMACAddress = NeighborMACAddress, NeighborVendor = NeighborVendor diff --git a/Source/NETworkManager.Models/Network/IPScannerOptions.cs b/Source/NETworkManager.Models/Network/IPScannerOptions.cs index 9b2de4da53..3d8a6fa555 100644 --- a/Source/NETworkManager.Models/Network/IPScannerOptions.cs +++ b/Source/NETworkManager.Models/Network/IPScannerOptions.cs @@ -100,19 +100,19 @@ public record IPScannerOptions /// /// Read the title of the web page of reachable hosts with an open web port, see - /// . + /// . /// - public required bool HttpTitleEnabled { get; init; } + public required bool WebPageEnabled { get; init; } /// /// Ports whose web page title is read, in this order (only open ports, the first that answers is used). /// - public required int[] HttpTitlePorts { get; init; } + public required int[] WebPagePorts { get; init; } /// /// Timeout in milliseconds of a web request. /// - public required int HttpTitleTimeout { get; init; } + public required int WebPageTimeout { get; init; } /// /// Read the SNMP system information of reachable hosts, see . diff --git a/Source/NETworkManager.Models/Network/IPScannerResultOptions.cs b/Source/NETworkManager.Models/Network/IPScannerResultOptions.cs index 127f32db20..d838269e62 100644 --- a/Source/NETworkManager.Models/Network/IPScannerResultOptions.cs +++ b/Source/NETworkManager.Models/Network/IPScannerResultOptions.cs @@ -11,7 +11,7 @@ /// The NetBIOS information was requested. /// The MAC address and vendor were resolved. /// The mDNS hostname was resolved. -/// The title of the web pages was read. +/// The title of the web pages was read. /// The SNMP system information was read. public sealed record IPScannerResultOptions( bool ICMPEnabled, @@ -21,7 +21,7 @@ public sealed record IPScannerResultOptions( bool NetBIOSEnabled, bool ResolveMACAddress, bool MDNSEnabled, - bool HttpTitleEnabled, + bool WebPageEnabled, bool SNMPEnabled) { /// @@ -30,7 +30,7 @@ public sealed record IPScannerResultOptions( /// Options of the scan. public IPScannerResultOptions(IPScannerOptions options) : this(options.ICMPEnabled, options.OSGuessEnabled, options.ResolveHostname, options.PortScanEnabled, options.NetBIOSEnabled, options.ResolveMACAddress, - options.MDNSEnabled, options.HttpTitleEnabled, options.SNMPEnabled) + options.MDNSEnabled, options.WebPageEnabled, options.SNMPEnabled) { } } diff --git a/Source/NETworkManager.Models/Network/HttpTitleInfo.cs b/Source/NETworkManager.Models/Network/WebPageInfo.cs similarity index 76% rename from Source/NETworkManager.Models/Network/HttpTitleInfo.cs rename to Source/NETworkManager.Models/Network/WebPageInfo.cs index 085b62fa84..6a83ba1c52 100644 --- a/Source/NETworkManager.Models/Network/HttpTitleInfo.cs +++ b/Source/NETworkManager.Models/Network/WebPageInfo.cs @@ -1,11 +1,11 @@ namespace NETworkManager.Models.Network; /// -/// Web page of a host, see . +/// Web page of a host, see . /// /// URL that answered (e.g. "https://192.168.1.1/"). /// HTTP status code of the (last) response, e.g. 200. /// Title of the web page, empty if it has none (e.g. a redirect to another host). /// Value of the Server header (e.g. "nginx"), empty if there is none. /// Value of the X-Powered-By header (e.g. "PHP/8.2"), empty if there is none. -public sealed record HttpTitleInfo(string URL, int StatusCode, string Title, string Server, string PoweredBy); +public sealed record WebPageInfo(string URL, int StatusCode, string Title, string Server, string PoweredBy); diff --git a/Source/NETworkManager.Models/Network/HttpTitleResolver.cs b/Source/NETworkManager.Models/Network/WebPageResolver.cs similarity index 95% rename from Source/NETworkManager.Models/Network/HttpTitleResolver.cs rename to Source/NETworkManager.Models/Network/WebPageResolver.cs index 531949add0..edcd5f1a23 100644 --- a/Source/NETworkManager.Models/Network/HttpTitleResolver.cs +++ b/Source/NETworkManager.Models/Network/WebPageResolver.cs @@ -16,7 +16,7 @@ namespace NETworkManager.Models.Network; /// proxy; certificate errors are accepted (only the title is read, nothing is sent). The request appears in the /// log of the web server. /// -public static partial class HttpTitleResolver +public static partial class WebPageResolver { /// /// Maximum number of bytes read from the web page. @@ -70,7 +70,7 @@ public static partial class HttpTitleResolver /// Timeout in milliseconds per request. /// Token to monitor for cancellation requests. /// URL and title, null if no port answered. - public static async Task ResolveAsync(IPAddress ipAddress, IEnumerable ports, + public static async Task ResolveAsync(IPAddress ipAddress, IEnumerable ports, int timeout, CancellationToken cancellationToken) { foreach (var port in ports) @@ -107,7 +107,7 @@ public static string[] GetSchemes(int port) /// Timeout in milliseconds per request. /// Token to monitor for cancellation requests. /// Web page (title, status code, headers), null if the host didn't answer. - private static async Task GetWebPageAsync(string url, int timeout, + private static async Task GetWebPageAsync(string url, int timeout, CancellationToken cancellationToken) { var uri = new Uri(url); @@ -145,14 +145,14 @@ private static async Task GetWebPageAsync(string url, int timeout } // Redirect to another host - only the headers - return new HttpTitleInfo(url, statusCode, string.Empty, server, poweredBy); + return new WebPageInfo(url, statusCode, string.Empty, server, poweredBy); } var content = await ReadContentAsync(response, linkedCts.Token).ConfigureAwait(false); var title = ParseTitle(Decode(content, response.Content.Headers.ContentType?.CharSet)); - return new HttpTitleInfo(url, statusCode, title, server, poweredBy); + return new WebPageInfo(url, statusCode, title, server, poweredBy); } } catch (Exception) when (!cancellationToken.IsCancellationRequested) diff --git a/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs b/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs index 957e267514..a663707bb0 100644 --- a/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs +++ b/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs @@ -166,9 +166,9 @@ public static class GlobalStaticConfiguration public static bool IPScanner_ResolveMACAddress => true; public static bool IPScanner_MDNSEnabled => false; public static int IPScanner_MDNSTimeout => 2000; - public static bool IPScanner_HttpTitleEnabled => false; - public static string IPScanner_HttpTitlePorts => "443; 80; 8443; 8080"; - public static int IPScanner_HttpTitleTimeout => 2000; + public static bool IPScanner_WebPageEnabled => false; + public static string IPScanner_WebPagePorts => "443; 80; 8443; 8080"; + public static int IPScanner_WebPageTimeout => 2000; public static bool IPScanner_SNMPEnabled => false; public static SNMPVersion IPScanner_SNMPVersion => SNMPVersion.V2C; public static string IPScanner_SNMPCommunity => "public"; diff --git a/Source/NETworkManager.Settings/SettingsInfo.cs b/Source/NETworkManager.Settings/SettingsInfo.cs index 9022accef4..e087e97840 100644 --- a/Source/NETworkManager.Settings/SettingsInfo.cs +++ b/Source/NETworkManager.Settings/SettingsInfo.cs @@ -1442,7 +1442,7 @@ public int IPScanner_MDNSTimeout } } = GlobalStaticConfiguration.IPScanner_MDNSTimeout; - public bool IPScanner_HttpTitleEnabled + public bool IPScanner_WebPageEnabled { get; set @@ -1453,9 +1453,9 @@ public bool IPScanner_HttpTitleEnabled field = value; OnPropertyChanged(); } - } = GlobalStaticConfiguration.IPScanner_HttpTitleEnabled; + } = GlobalStaticConfiguration.IPScanner_WebPageEnabled; - public string IPScanner_HttpTitlePorts + public string IPScanner_WebPagePorts { get; set @@ -1466,9 +1466,9 @@ public string IPScanner_HttpTitlePorts field = value; OnPropertyChanged(); } - } = GlobalStaticConfiguration.IPScanner_HttpTitlePorts; + } = GlobalStaticConfiguration.IPScanner_WebPagePorts; - public int IPScanner_HttpTitleTimeout + public int IPScanner_WebPageTimeout { get; set @@ -1479,7 +1479,7 @@ public int IPScanner_HttpTitleTimeout field = value; OnPropertyChanged(); } - } = GlobalStaticConfiguration.IPScanner_HttpTitleTimeout; + } = GlobalStaticConfiguration.IPScanner_WebPageTimeout; public bool IPScanner_SNMPEnabled { diff --git a/Source/NETworkManager.Tests/Network/HttpTitleResolverTests.cs b/Source/NETworkManager.Tests/Network/WebPageResolverTests.cs similarity index 73% rename from Source/NETworkManager.Tests/Network/HttpTitleResolverTests.cs rename to Source/NETworkManager.Tests/Network/WebPageResolverTests.cs index 379384ea3f..1cc7f24ec9 100644 --- a/Source/NETworkManager.Tests/Network/HttpTitleResolverTests.cs +++ b/Source/NETworkManager.Tests/Network/WebPageResolverTests.cs @@ -4,7 +4,7 @@ namespace NETworkManager.Tests.Network; -public class HttpTitleResolverTests +public class WebPageResolverTests { [Theory] [InlineData("Router Login", "Router Login")] @@ -19,13 +19,13 @@ public class HttpTitleResolverTests [InlineData(null, "")] public void ParseTitle(string html, string expected) { - Assert.Equal(expected, HttpTitleResolver.ParseTitle(html)); + Assert.Equal(expected, WebPageResolver.ParseTitle(html)); } [Fact] public void ParseTitle_TooLong_Shortened() { - var title = HttpTitleResolver.ParseTitle($"{new string('a', 500)}"); + var title = WebPageResolver.ParseTitle($"{new string('a', 500)}"); Assert.Equal(new string('a', 200) + "...", title); } @@ -38,7 +38,7 @@ public void ParseTitle_TooLong_Shortened() [InlineData(null, "")] public void CleanHeaderValue(string value, string expected) { - Assert.Equal(expected, HttpTitleResolver.CleanHeaderValue(value)); + Assert.Equal(expected, WebPageResolver.CleanHeaderValue(value)); } [Theory] @@ -48,21 +48,21 @@ public void CleanHeaderValue(string value, string expected) [InlineData(9000, new[] { "https", "http" })] public void GetSchemes(int port, string[] expected) { - Assert.Equal(expected, HttpTitleResolver.GetSchemes(port)); + Assert.Equal(expected, WebPageResolver.GetSchemes(port)); } [Fact] public void Decode_DefaultUTF8() { Assert.Equal("Grüße", - HttpTitleResolver.Decode(Encoding.UTF8.GetBytes("Grüße"), null)); + WebPageResolver.Decode(Encoding.UTF8.GetBytes("Grüße"), null)); } [Fact] public void Decode_CharsetOfHeader() { Assert.Equal("Grüße", - HttpTitleResolver.Decode(Encoding.Latin1.GetBytes("Grüße"), "iso-8859-1")); + WebPageResolver.Decode(Encoding.Latin1.GetBytes("Grüße"), "iso-8859-1")); } [Fact] @@ -70,13 +70,13 @@ public void Decode_CharsetOfMetaTag() { const string html = "Grüße"; - Assert.Equal(html, HttpTitleResolver.Decode(Encoding.Latin1.GetBytes(html), null)); + Assert.Equal(html, WebPageResolver.Decode(Encoding.Latin1.GetBytes(html), null)); } [Fact] public void Decode_UnknownCharset_UTF8() { Assert.Equal("Test", - HttpTitleResolver.Decode(Encoding.UTF8.GetBytes("Test"), "unknown-charset")); + WebPageResolver.Decode(Encoding.UTF8.GetBytes("Test"), "unknown-charset")); } } diff --git a/Source/NETworkManager/ViewModels/IPScannerSettingsViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerSettingsViewModel.cs index 32cf6c654f..40b2537ede 100644 --- a/Source/NETworkManager/ViewModels/IPScannerSettingsViewModel.cs +++ b/Source/NETworkManager/ViewModels/IPScannerSettingsViewModel.cs @@ -372,7 +372,7 @@ public int MDNSTimeout /// /// Gets or sets a value indicating whether the title of web pages is read. /// - public bool HttpTitleEnabled + public bool WebPageEnabled { get; set @@ -381,7 +381,7 @@ public bool HttpTitleEnabled return; if (!_isLoading) - SettingsManager.Current.IPScanner_HttpTitleEnabled = value; + SettingsManager.Current.IPScanner_WebPageEnabled = value; field = value; OnPropertyChanged(); @@ -391,7 +391,7 @@ public bool HttpTitleEnabled /// /// Gets or sets the ports whose web page title is read (in this order, only open ports). /// - public string HttpTitlePorts + public string WebPagePorts { get; set @@ -400,7 +400,7 @@ public string HttpTitlePorts return; if (!_isLoading) - SettingsManager.Current.IPScanner_HttpTitlePorts = value; + SettingsManager.Current.IPScanner_WebPagePorts = value; field = value; OnPropertyChanged(); @@ -410,7 +410,7 @@ public string HttpTitlePorts /// /// Gets or sets the timeout of a web request in milliseconds. /// - public int HttpTitleTimeout + public int WebPageTimeout { get; set @@ -419,7 +419,7 @@ public int HttpTitleTimeout return; if (!_isLoading) - SettingsManager.Current.IPScanner_HttpTitleTimeout = value; + SettingsManager.Current.IPScanner_WebPageTimeout = value; field = value; OnPropertyChanged(); @@ -625,9 +625,9 @@ private void LoadSettings() ResolveMACAddress = SettingsManager.Current.IPScanner_ResolveMACAddress; MDNSEnabled = SettingsManager.Current.IPScanner_MDNSEnabled; MDNSTimeout = SettingsManager.Current.IPScanner_MDNSTimeout; - HttpTitleEnabled = SettingsManager.Current.IPScanner_HttpTitleEnabled; - HttpTitlePorts = SettingsManager.Current.IPScanner_HttpTitlePorts; - HttpTitleTimeout = SettingsManager.Current.IPScanner_HttpTitleTimeout; + WebPageEnabled = SettingsManager.Current.IPScanner_WebPageEnabled; + WebPagePorts = SettingsManager.Current.IPScanner_WebPagePorts; + WebPageTimeout = SettingsManager.Current.IPScanner_WebPageTimeout; SNMPEnabled = SettingsManager.Current.IPScanner_SNMPEnabled; SNMPVersion = SettingsManager.Current.IPScanner_SNMPVersion; SNMPCommunity = SettingsManager.Current.IPScanner_SNMPCommunity; @@ -652,9 +652,9 @@ private void LoadSettings() /// Gets the command to restore the default ports whose web page title is read. Disabled while the default ports are /// set. /// - public ICommand RestoreDefaultHttpTitlePortsCommand => new RelayCommand( - _ => HttpTitlePorts = GlobalStaticConfiguration.IPScanner_HttpTitlePorts, - _ => HttpTitlePorts != GlobalStaticConfiguration.IPScanner_HttpTitlePorts); + public ICommand RestoreDefaultWebPagePortsCommand => new RelayCommand( + _ => WebPagePorts = GlobalStaticConfiguration.IPScanner_WebPagePorts, + _ => WebPagePorts != GlobalStaticConfiguration.IPScanner_WebPagePorts); /// /// Gets the command to add a custom command. diff --git a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs index 6d943247c4..a5d168441e 100644 --- a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs @@ -851,7 +851,7 @@ private async Task Start() // handled here - otherwise the scan would never end (IsRunning stays true). (List<(IPAddress ipAddress, string hostname)> hosts, List hostnamesNotResolved) hosts; int[] ports; - int[] httpTitlePorts; + int[] webPagePorts; try { @@ -862,8 +862,8 @@ private async Task Start() ports = await PortRangeHelper.ConvertPortRangeToIntArrayAsync( SettingsManager.Current.IPScanner_PortScanPorts); - httpTitlePorts = await PortRangeHelper.ConvertPortRangeToIntArrayAsync( - SettingsManager.Current.IPScanner_HttpTitlePorts); + webPagePorts = await PortRangeHelper.ConvertPortRangeToIntArrayAsync( + SettingsManager.Current.IPScanner_WebPagePorts); } catch (OperationCanceledException) { @@ -899,7 +899,7 @@ private async Task Start() ListHelper.Modify(SettingsManager.Current.IPScanner_HostHistory, Host, SettingsManager.Current.General_HistoryListEntries); - var scanOptions = CreateScanOptions(ports, httpTitlePorts); + var scanOptions = CreateScanOptions(ports, webPagePorts); ScanOptions = new IPScannerResultOptions(scanOptions); @@ -990,9 +990,9 @@ private void StartScanner(IPScannerOptions scanOptions, List<(IPAddress ipAddres /// Creates the scan options from the current settings. /// /// Ports to scan. - /// Ports whose web page title is read (if open). + /// Ports whose web page title is read (if open). /// Options for the . - private static IPScannerOptions CreateScanOptions(int[] ports, int[] httpTitlePorts) + private static IPScannerOptions CreateScanOptions(int[] ports, int[] webPagePorts) { return new IPScannerOptions { @@ -1014,9 +1014,9 @@ private static IPScannerOptions CreateScanOptions(int[] ports, int[] httpTitlePo ResolveMACAddress = SettingsManager.Current.IPScanner_ResolveMACAddress, MDNSEnabled = SettingsManager.Current.IPScanner_MDNSEnabled, MDNSTimeout = SettingsManager.Current.IPScanner_MDNSTimeout, - HttpTitleEnabled = SettingsManager.Current.IPScanner_HttpTitleEnabled, - HttpTitlePorts = httpTitlePorts, - HttpTitleTimeout = SettingsManager.Current.IPScanner_HttpTitleTimeout, + WebPageEnabled = SettingsManager.Current.IPScanner_WebPageEnabled, + WebPagePorts = webPagePorts, + WebPageTimeout = SettingsManager.Current.IPScanner_WebPageTimeout, SNMPEnabled = SettingsManager.Current.IPScanner_SNMPEnabled, SNMPVersion = SettingsManager.Current.IPScanner_SNMPVersion, SNMPCommunity = SettingsManager.Current.IPScanner_SNMPCommunity, @@ -1051,7 +1051,7 @@ private static IPScannerResultOptions CreateResultOptions() SettingsManager.Current.IPScanner_NetBIOSEnabled, SettingsManager.Current.IPScanner_ResolveMACAddress, SettingsManager.Current.IPScanner_MDNSEnabled, - SettingsManager.Current.IPScanner_HttpTitleEnabled, + SettingsManager.Current.IPScanner_WebPageEnabled, SettingsManager.Current.IPScanner_SNMPEnabled); } diff --git a/Source/NETworkManager/Views/IPScannerSettingsView.xaml b/Source/NETworkManager/Views/IPScannerSettingsView.xaml index eb19fcf2cc..4e3ff3107f 100644 --- a/Source/NETworkManager/Views/IPScannerSettingsView.xaml +++ b/Source/NETworkManager/Views/IPScannerSettingsView.xaml @@ -159,27 +159,27 @@ Margin="0,0,0,10" /> - + - + @@ -211,7 +211,7 @@ - diff --git a/Source/NETworkManager/Views/IPScannerView.xaml b/Source/NETworkManager/Views/IPScannerView.xaml index 4927717185..c1480c8314 100644 --- a/Source/NETworkManager/Views/IPScannerView.xaml +++ b/Source/NETworkManager/Views/IPScannerView.xaml @@ -945,12 +945,12 @@ - - + @@ -959,7 +959,7 @@ + Path="Data.ScanOptions.WebPageEnabled" /> @@ -1507,7 +1507,7 @@ + Visibility="{Binding Path=Data.ScanOptions.WebPageEnabled, Source={StaticResource BindingProxy}, Converter={StaticResource ResourceKey=BooleanReverseToVisibilityCollapsedConverter}}"> + Visibility="{Binding Path=Data.ScanOptions.WebPageEnabled, Source={StaticResource BindingProxy}, Converter={StaticResource ResourceKey=BooleanToVisibilityCollapsedConverter}}"> + + + + + + + + + + + + + + @@ -1166,46 +1213,16 @@ - - - - - - - - - - - - - - - - - - + + + - - - - - - - - + + @@ -1213,33 +1230,17 @@ - + - - - - - - - - - + + + - - - - - - - - - + + + @@ -1281,25 +1282,10 @@ - - - - - - - - - @@ -1380,61 +1366,22 @@ - - - - - - - - - - - - - - - - - - + + + - - - - - - - - - + + + - - - - - - - - - + + + @@ -1476,35 +1423,12 @@ Style="{StaticResource InfoTextBlock}" Margin="10,0,0,0" /> - - - - - - - - - + - - - - - - - + - + @@ -1513,20 +1437,13 @@ - + - - - - - - - + - + @@ -1535,7 +1452,7 @@ - + @@ -1563,34 +1480,12 @@ Style="{StaticResource InfoTextBlock}" Margin="10,0,0,0" /> - - - - - - - - - - - - - - - - + - + @@ -1599,18 +1494,11 @@ - + - - - - - - - - + + @@ -1619,20 +1507,12 @@ - + - - - - - - - + - + @@ -1641,19 +1521,12 @@ - + - - - - - - - + - + @@ -1662,19 +1535,12 @@ - + - - - - - - - + - + @@ -1683,19 +1549,12 @@ - + - - - - - - - + - + @@ -1704,19 +1563,12 @@ - + - - - - - - - + - + @@ -1725,18 +1577,11 @@ - + - - - - - - - - + + @@ -1745,18 +1590,11 @@ - + - - - - - - - - + + @@ -1765,18 +1603,11 @@ - + - - - - - - - - + + @@ -1785,7 +1616,7 @@ - + @@ -1824,34 +1655,12 @@ Style="{StaticResource InfoTextBlock}" Margin="10,0,0,0" /> - - - - - - - - - - - - - - - - + - + @@ -1860,20 +1669,12 @@ - + - - - - - - - + - + @@ -1882,19 +1683,12 @@ - + - - - - - - - + - + @@ -1903,7 +1697,7 @@ - + From dff2fd3c62b380b8fcb7ae5d8205fadedd73c2cb Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Sat, 31 Oct 2026 16:46:12 +0100 Subject: [PATCH 059/131] Fix: IP Scanner / Port Scanner / Ping Monitor: Excluded host scanned if its hostname could not be resolved (F1) An exclusion whose hostname could not be resolved (e.g. a DNS timeout for "!printer.corp.local") was ignored - the hosts it should exclude were scanned anyway, only with the "could not be resolved" message. Nothing is scanned in this case now (the Ping Monitor keeps the input). Co-Authored-By: Claude Opus 5.5 --- .../Network/HostRangeHelper.cs | 19 ++++++++++++------- Website/docs/application/ip-scanner.md | 2 +- Website/docs/application/ping-monitor.md | 2 +- Website/docs/application/port-scanner.md | 2 +- Website/docs/changelog/next-release.md | 6 +++--- 5 files changed, 18 insertions(+), 13 deletions(-) diff --git a/Source/NETworkManager.Models/Network/HostRangeHelper.cs b/Source/NETworkManager.Models/Network/HostRangeHelper.cs index 49ac629f82..7c109b5b3f 100644 --- a/Source/NETworkManager.Models/Network/HostRangeHelper.cs +++ b/Source/NETworkManager.Models/Network/HostRangeHelper.cs @@ -292,6 +292,7 @@ private static long GetIPv4RangeCount(IPAddress start, IPAddress end) /// /// Sorted, distinct list of hosts, the hostnames that could not be resolved and the number of hosts removed by /// the exclusions (e.g. to show that every IP address is excluded, also if a hostname could not be resolved). + /// No hosts if the hostname of an exclusion could not be resolved (its IP addresses would be scanned). /// public static async Task<(List<(IPAddress ipAddress, string hostname)> hosts, List hostnamesNotResolved, int excludedCount)> @@ -385,24 +386,28 @@ await Parallel.ForEachAsync(Enumerable.Range(0, exclusionInputs.Count), exclusionHostnameNotResolved[index] = exclusionInputs[index].Split('/')[0]; }).ConfigureAwait(false); - var result = SortAndRemoveDuplicates(hostsPerInput, keepAllHostnames); + var hostnamesNotResolved = hostnameNotResolvedPerInput.Concat(exclusionHostnameNotResolved) + .Where(x => x != null).ToList(); + + // The IP addresses of an exclusion that could not be resolved are not known - nothing is scanned instead of + // scanning the hosts that should be excluded (e.g. a printer that must not get any packets) + if (exclusions.Any(x => x == null)) + return ([], hostnamesNotResolved, 0); - var activeExclusions = exclusions.Where(x => x != null).ToList(); + var result = SortAndRemoveDuplicates(hostsPerInput, keepAllHostnames); var excludedCount = 0; - if (activeExclusions.Count > 0) + if (exclusions.Length > 0) { var count = result.Count; - result = [.. result.Where(host => !activeExclusions.Any(isExcluded => isExcluded(host.ipAddress)))]; + result = [.. result.Where(host => !exclusions.Any(isExcluded => isExcluded(host.ipAddress)))]; excludedCount = count - result.Count; } - return (result, - [.. hostnameNotResolvedPerInput.Concat(exclusionHostnameNotResolved).Where(x => x != null)], - excludedCount); + return (result, hostnamesNotResolved, excludedCount); } /// diff --git a/Website/docs/application/ip-scanner.md b/Website/docs/application/ip-scanner.md index f01cd678d8..173d99b851 100644 --- a/Website/docs/application/ip-scanner.md +++ b/Website/docs/application/ip-scanner.md @@ -38,7 +38,7 @@ With the **IP Scanner** you can scan IP ranges, subnets and hostnames for active Multiple inputs can be combined with a semicolon (`;`). A column pasted from Excel (one entry per line) is converted to the semicolon-separated form automatically. -An input starting with `!` excludes its IP addresses from the other inputs, e.g. `10.0.0.0/24; !10.0.0.1; !10.0.0.200-254` (all formats of the table above; a hostname excludes all its IPv4 and IPv6 addresses, independent of the IPv4 preference). At least one input without `!` is required; if the exclusions remove every IP address, a message is shown instead. +An input starting with `!` excludes its IP addresses from the other inputs, e.g. `10.0.0.0/24; !10.0.0.1; !10.0.0.200-254` (all formats of the table above; a hostname excludes all its IPv4 and IPv6 addresses, independent of the IPv4 preference). At least one input without `!` is required; if the exclusions remove every IP address, a message is shown instead. If the hostname of an exclusion can't be resolved, nothing is scanned (otherwise its IP addresses would be scanned). Example: `10.0.0.0/24; 10.0.[10-20].1; 10.0.0.100-199` diff --git a/Website/docs/application/ping-monitor.md b/Website/docs/application/ping-monitor.md index 78e8b39661..146e087360 100644 --- a/Website/docs/application/ping-monitor.md +++ b/Website/docs/application/ping-monitor.md @@ -35,7 +35,7 @@ ICMP (Internet Control Message Protocol) is a network-layer protocol used to sen Multiple inputs can be combined with a semicolon (`;`). A column pasted from Excel (one entry per line) is converted to the semicolon-separated form automatically. -An input starting with `!` excludes its IP addresses from the other inputs, e.g. `10.0.0.0/24; !10.0.0.1; !10.0.0.200-254` (all formats of the table above; a hostname excludes all its IPv4 and IPv6 addresses, independent of the IPv4 preference). At least one input without `!` is required; if the exclusions remove every IP address, a message is shown instead. +An input starting with `!` excludes its IP addresses from the other inputs, e.g. `10.0.0.0/24; !10.0.0.1; !10.0.0.200-254` (all formats of the table above; a hostname excludes all its IPv4 and IPv6 addresses, independent of the IPv4 preference). At least one input without `!` is required; if the exclusions remove every IP address, a message is shown instead. If the hostname of an exclusion can't be resolved, nothing is scanned (otherwise its IP addresses would be scanned). Example: `10.0.0.0/24; 10.0.[10-20].1; 10.0.0.100-199` diff --git a/Website/docs/application/port-scanner.md b/Website/docs/application/port-scanner.md index 6db53ef54a..eb5580bcd8 100644 --- a/Website/docs/application/port-scanner.md +++ b/Website/docs/application/port-scanner.md @@ -50,7 +50,7 @@ TCP (Transmission Control Protocol) is a connection-oriented transport-layer pro Multiple inputs can be combined with a semicolon (`;`). A column pasted from Excel (one entry per line) is converted to the semicolon-separated form automatically. -An input starting with `!` excludes its IP addresses from the other inputs, e.g. `10.0.0.0/24; !10.0.0.1; !10.0.0.200-254` (all formats of the table above; a hostname excludes all its IPv4 and IPv6 addresses, independent of the IPv4 preference). At least one input without `!` is required; if the exclusions remove every IP address, a message is shown instead. +An input starting with `!` excludes its IP addresses from the other inputs, e.g. `10.0.0.0/24; !10.0.0.1; !10.0.0.200-254` (all formats of the table above; a hostname excludes all its IPv4 and IPv6 addresses, independent of the IPv4 preference). At least one input without `!` is required; if the exclusions remove every IP address, a message is shown instead. If the hostname of an exclusion can't be resolved, nothing is scanned (otherwise its IP addresses would be scanned). Example: `10.0.0.0/24; 10.0.[10-20].1; 10.0.0.100-199` or `1-1024; 8080; 8443` diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index 25d89d83e8..9c8a2093a7 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -99,7 +99,7 @@ Release date: **xx.xx.2026** - The **Ping status**, **Port status** and **NetBIOS reachable** columns show a status icon (green check / red cross, like the action of a rule in the **Firewall**) instead of the text (e.g. `Success` / `Timed out`, `Open` / `Closed`, `Yes` / `No`); the tooltip shows the details: ping status with time and TTL, the open ports with their service and the NetBIOS computer name and workgroup / domain. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) - The row details use four columns: **Ping details** / **Hostnames**, **Ports**, **Web** and **NetBIOS** / **SNMP**; a section of a disabled setting shows a note like the ports and NetBIOS. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) - The **Detect local subnet** button opens a menu: **Detect local subnet** (as before), **Subnet of network interface** to select the IPv4 subnet of a specific network interface (e.g. LAN, Wi-Fi, VPN, Hyper-V) and **All local subnets** to scan the subnets of all network interfaces. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) -- IP addresses can be excluded from the scan with `!` in the host input (also in profiles and the run command), e.g. `10.0.0.0/24; !10.0.0.1; !10.0.0.200-254`. Works with IP addresses, ranges, subnets and hostnames (all IPv4 and IPv6 addresses of the hostname); a message is shown if every IP address is excluded. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) +- IP addresses can be excluded from the scan with `!` in the host input (also in profiles and the run command), e.g. `10.0.0.0/24; !10.0.0.1; !10.0.0.200-254`. Works with IP addresses, ranges, subnets and hostnames (all IPv4 and IPv6 addresses of the hostname, nothing is scanned if the hostname can't be resolved); a message is shown if every IP address is excluded. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) **Port Scanner** @@ -129,7 +129,7 @@ Release date: **xx.xx.2026** - Groups: new **Ports** setting (ports, port ranges and port profiles) for the Port Scanner, used by the profiles of the group that don't override them (new **Ports** checkbox in the profile, like the other settings that can be set in the group). Existing profiles with ports keep them (the checkbox is enabled when the profile file is loaded). [#3667](https://github.com/BornToBeRoot/NETworkManager/pull/3667) - The export runs in the background, so the window stays responsive while a large result is exported, and uses the order shown in the result (sorting, grouping) instead of the order in which the ports were scanned. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) - Ports are started one after another (every 1 ms, shorter with short timeouts and many threads) instead of all at the same moment. This avoids bursts of hundreds of connection requests within a few milliseconds and makes the progress smoother. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) -- IP addresses can be excluded from the scan with `!` in the host input (also in profiles and the run command), e.g. `10.0.0.0/24; !10.0.0.1; !10.0.0.200-254`. Works with IP addresses, ranges, subnets and hostnames (all IPv4 and IPv6 addresses of the hostname); a message is shown if every IP address is excluded. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) +- IP addresses can be excluded from the scan with `!` in the host input (also in profiles and the run command), e.g. `10.0.0.0/24; !10.0.0.1; !10.0.0.200-254`. Works with IP addresses, ranges, subnets and hostnames (all IPv4 and IPv6 addresses of the hostname, nothing is scanned if the hostname can't be resolved); a message is shown if every IP address is excluded. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) **Ping Monitor** @@ -139,7 +139,7 @@ Release date: **xx.xx.2026** - Reworked each monitored host's card: the collapsed quick info now shows labeled values (`Received: X · Lost: Y · Packet loss: Z%`) instead of unlabeled numbers, the expanded view shows only the latency chart (with more room, since Hostname/IP address are already visible in the header) and the last status change time moved to a tooltip on the connectivity icon. The **Status change** field was also renamed to **Last status change**. [#3572](https://github.com/BornToBeRoot/NETworkManager/pull/3572) - Host input now accepts newline-separated hosts (one per line, e.g. a column pasted from Excel), converted automatically to the semicolon-separated form. [#3568](https://github.com/BornToBeRoot/NETworkManager/pull/3568) - Host input now supports shorthand IPv4 ranges like `192.168.0.1-100`, in addition to the existing `192.168.0.0-192.168.0.100` and `192.168.[0-100].1` range formats. [#3568](https://github.com/BornToBeRoot/NETworkManager/pull/3568) -- IP addresses can be excluded with `!` in the host input (also in profiles and the run command), e.g. `10.0.0.0/24; !10.0.0.1; !10.0.0.200-254`. Works with IP addresses, ranges, subnets and hostnames (all IPv4 and IPv6 addresses of the hostname); a message is shown if every IP address is excluded. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) +- IP addresses can be excluded with `!` in the host input (also in profiles and the run command), e.g. `10.0.0.0/24; !10.0.0.1; !10.0.0.200-254`. Works with IP addresses, ranges, subnets and hostnames (all IPv4 and IPv6 addresses of the hostname, nothing is scanned if the hostname can't be resolved); a message is shown if every IP address is excluded. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) **Traceroute** From 20d574a0ddd3b7eb07f8be9b5e717cf795bae26b Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Sat, 31 Oct 2026 16:47:14 +0100 Subject: [PATCH 060/131] Fix: IP Scanner: Control characters in the mDNS hostname (F2) The labels of an mDNS answer were taken as they are - a device answering with a control character (e.g. 0x00) put it into the hostname, the search and the export, and the XML export failed (invalid character). Control characters are removed, like in the web page title and the SNMP values. Co-Authored-By: Claude Opus 5.5 --- Source/NETworkManager.Models/Network/MDNSResolver.cs | 10 ++++++++-- .../NETworkManager.Tests/Network/MDNSResolverTests.cs | 7 +++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/Source/NETworkManager.Models/Network/MDNSResolver.cs b/Source/NETworkManager.Models/Network/MDNSResolver.cs index f7d98acdd5..f5d8bb7103 100644 --- a/Source/NETworkManager.Models/Network/MDNSResolver.cs +++ b/Source/NETworkManager.Models/Network/MDNSResolver.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Net; using System.Net.Sockets; using System.Text; @@ -101,7 +102,8 @@ public static byte[] CreateReverseQuery(IPAddress ipAddress, ushort id) /// DNS message of the answer. /// ID of the query. /// - /// Hostname without trailing dot, empty if the answer is invalid or contains no PTR record. + /// Hostname without trailing dot and control characters, empty if the answer is invalid or contains no + /// PTR record. /// public static string ParsePTRResponse(byte[] response, ushort id) { @@ -146,7 +148,11 @@ public static string ParsePTRResponse(byte[] response, ushort id) { var dataOffset = offset; - return ReadName(response, ref dataOffset) ?? string.Empty; + // Labels can contain any byte - control characters (e.g. 0x00) are not shown and would make the XML + // export fail + var name = ReadName(response, ref dataOffset) ?? string.Empty; + + return new string([.. name.Where(x => !char.IsControl(x))]); } offset += dataLength; diff --git a/Source/NETworkManager.Tests/Network/MDNSResolverTests.cs b/Source/NETworkManager.Tests/Network/MDNSResolverTests.cs index 7b8d23db76..fe78a7a5f5 100644 --- a/Source/NETworkManager.Tests/Network/MDNSResolverTests.cs +++ b/Source/NETworkManager.Tests/Network/MDNSResolverTests.cs @@ -83,6 +83,13 @@ public void ParsePTRResponse(bool compressed) MDNSResolver.ParsePTRResponse(CreateResponse(Id, "macbook.local", compressed: compressed), Id)); } + [Fact] + public void ParsePTRResponse_ControlCharacters_Removed() + { + Assert.Equal("macbook.local", + MDNSResolver.ParsePTRResponse(CreateResponse(Id, "mac\u0000book\u0001.local\n"), Id)); + } + [Fact] public void ParsePTRResponse_IdZero_Accepted() { From 30771d254f53b7f1b360d23a81b7cf45d4c054e7 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Sat, 31 Oct 2026 16:47:57 +0100 Subject: [PATCH 061/131] Fix: IP Scanner: Export of web values without the port scan (F3) Web pages are only read with the port scan (only open ports are requested), the Web column and the Copy menu already check both settings. The export only checked "Read web pages": with the port scan disabled it wrote empty values ("requested, no page") instead of the empty values of a disabled probe (null in JSON). Co-Authored-By: Claude Opus 5.5 --- .../Export/ExportManager.IPScannerHostInfo.cs | 26 +++++++++++++------ .../Network/ExportManagerIPScannerTests.cs | 20 +++++++++++++- 2 files changed, 37 insertions(+), 9 deletions(-) diff --git a/Source/NETworkManager.Models/Export/ExportManager.IPScannerHostInfo.cs b/Source/NETworkManager.Models/Export/ExportManager.IPScannerHostInfo.cs index fea20cf22b..b0c6ca1020 100644 --- a/Source/NETworkManager.Models/Export/ExportManager.IPScannerHostInfo.cs +++ b/Source/NETworkManager.Models/Export/ExportManager.IPScannerHostInfo.cs @@ -97,16 +97,16 @@ private static List GetFields(IPScannerHostInfo info, IPScannerResu new(nameof(IPScannerHostInfo.OSGuess), GetOSGuess(info, options)), new(nameof(IPScannerHostInfo.Role), GetRole(info), true), new(nameof(IPScannerHostInfo.MDNSHostname), IfEnabled(options.MDNSEnabled, info.MDNSHostname), true), - new(nameof(IPScannerHostInfo.WebTitle), IfEnabled(options.WebPageEnabled, info.WebTitle), true), - new(nameof(IPScannerHostInfo.WebURL), IfEnabled(options.WebPageEnabled, info.WebURL), true), + new(nameof(IPScannerHostInfo.WebTitle), IfEnabled(IsWebPageRead(options), info.WebTitle), true), + new(nameof(IPScannerHostInfo.WebURL), IfEnabled(IsWebPageRead(options), info.WebURL), true), new(nameof(IPScannerHostInfo.SNMPName), IfEnabled(options.SNMPEnabled, info.SNMPName), true), new(nameof(IPScannerHostInfo.SNMPDescription), IfEnabled(options.SNMPEnabled, info.SNMPDescription), true), new(nameof(IPScannerHostInfo.SNMPLocation), IfEnabled(options.SNMPEnabled, info.SNMPLocation), true), new(nameof(IPScannerHostInfo.WebStatusCode), GetWebStatusCode(info, options)), - new(nameof(IPScannerHostInfo.WebServer), IfEnabled(options.WebPageEnabled, info.WebServer), true), - new(nameof(IPScannerHostInfo.WebPoweredBy), IfEnabled(options.WebPageEnabled, info.WebPoweredBy), true), - new(nameof(IPScannerHostInfo.WebRealm), IfEnabled(options.WebPageEnabled, info.WebRealm), true), - new(nameof(IPScannerHostInfo.WebRedirectURL), IfEnabled(options.WebPageEnabled, info.WebRedirectURL), true), + new(nameof(IPScannerHostInfo.WebServer), IfEnabled(IsWebPageRead(options), info.WebServer), true), + new(nameof(IPScannerHostInfo.WebPoweredBy), IfEnabled(IsWebPageRead(options), info.WebPoweredBy), true), + new(nameof(IPScannerHostInfo.WebRealm), IfEnabled(IsWebPageRead(options), info.WebRealm), true), + new(nameof(IPScannerHostInfo.WebRedirectURL), IfEnabled(IsWebPageRead(options), info.WebRedirectURL), true), new("WebCertificateSubject", certificate?.Subject, true), new("WebCertificateIssuer", certificate?.Issuer, true), new("WebCertificateNotAfter", certificate == null @@ -232,6 +232,16 @@ private static string GetDetectedBy(IPScannerHostInfo info) return info.DetectedBy == IPScannerDetectionMethod.None ? null : info.DetectedBy.ToString(); } + /// + /// Checks if the web pages were read in the scan: only with the port scan (only open ports are requested). + /// + /// Options of the scan the hosts are from. + /// True if the web pages were read. + private static bool IsWebPageRead(IPScannerResultOptions options) + { + return options.PortScanEnabled && options.WebPageEnabled; + } + /// /// Gets the HTTP status code of the web page of a host, null if none answered or the web page was not read. /// @@ -240,7 +250,7 @@ private static string GetDetectedBy(IPScannerHostInfo info) /// Status code or null. private static int? GetWebStatusCode(IPScannerHostInfo info, IPScannerResultOptions options) { - return options.WebPageEnabled && info.WebStatusCode > 0 ? info.WebStatusCode : null; + return IsWebPageRead(options) && info.WebStatusCode > 0 ? info.WebStatusCode : null; } /// @@ -252,7 +262,7 @@ private static string GetDetectedBy(IPScannerHostInfo info) /// Certificate or null. private static WebCertificateInfo GetWebCertificate(IPScannerHostInfo info, IPScannerResultOptions options) { - return options.WebPageEnabled ? info.WebCertificate : null; + return IsWebPageRead(options) ? info.WebCertificate : null; } /// diff --git a/Source/NETworkManager.Tests/Network/ExportManagerIPScannerTests.cs b/Source/NETworkManager.Tests/Network/ExportManagerIPScannerTests.cs index 8c87045316..ab68c21577 100644 --- a/Source/NETworkManager.Tests/Network/ExportManagerIPScannerTests.cs +++ b/Source/NETworkManager.Tests/Network/ExportManagerIPScannerTests.cs @@ -27,12 +27,18 @@ public class ExportManagerIPScannerTests private static readonly IPScannerResultOptions AllEnabled = new(true, true, true, true, true, true, true, true, true); private static string Export(ExportFileType fileType, params IPScannerHostInfo[] hosts) + { + return Export(fileType, AllEnabled, hosts); + } + + private static string Export(ExportFileType fileType, IPScannerResultOptions options, + params IPScannerHostInfo[] hosts) { var filePath = Path.GetTempFileName(); try { - ExportManager.Export(filePath, fileType, hosts, AllEnabled); + ExportManager.Export(filePath, fileType, hosts, options); return File.ReadAllText(filePath); } @@ -75,6 +81,18 @@ public void Xml_SameElementsAsCsv() Assert.Equal(CsvHeader.Split(',').Where(x => x != "Ports"), host.Elements().Select(x => x.Name.LocalName)); } + [Fact] + public void Json_WebPageWithoutPortScan_Empty() + { + // Web pages are only read with the port scan - the values are empty (null), like the ones of a disabled probe + var options = AllEnabled with { PortScanEnabled = false }; + + var host = (JObject)JArray.Parse(Export(ExportFileType.Json, options, CreateHost())).Single(); + + Assert.Equal(JTokenType.Null, host["WebTitle"]!.Type); + Assert.Equal(JTokenType.Null, host["WebURL"]!.Type); + } + [Fact] public void Json_SameKeysAsCsv() { From 17ad7af00980e945ae37c68fccb7f4b66fea8453 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Sat, 31 Oct 2026 16:48:55 +0100 Subject: [PATCH 062/131] Fix: IP Scanner: Local subnet of an adapter that is down (F4) The check whether a host is in a local subnet (neighbor check, MAC address from the neighbor table) used every network interface. An adapter that is down but still has its static IPv4 address made hosts of that subnet (e.g. reached via VPN) count as local: they got neighbor table reads and an ARP / NDP check that can't find them. Only operational network interfaces are used, like for the roles and the local subnets. Co-Authored-By: Claude Opus 5.5 --- Source/NETworkManager.Models/Network/IPScanner.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/Source/NETworkManager.Models/Network/IPScanner.cs b/Source/NETworkManager.Models/Network/IPScanner.cs index d2872665f7..7b6c560ac3 100644 --- a/Source/NETworkManager.Models/Network/IPScanner.cs +++ b/Source/NETworkManager.Models/Network/IPScanner.cs @@ -688,18 +688,21 @@ private async Task UpdateWebPageAsync(IPAddress ipAddress, IPScannerHostInfo hos /// hosts are in the neighbor table - for a host behind a router, its MAC address can't be resolved via ARP/NDP /// (the table contains the router), so there is no need to look it up (and to read the table again). /// IPv6: link-local addresses, other addresses if they are in the same /64 as a local address (the prefix - /// length is not known, local IPv6 subnets are /64). + /// length is not known, local IPv6 subnets are /64). Only operational network interfaces (an adapter that is + /// down may still have its static IPv4 address, e.g. of a subnet that is reached via VPN). /// /// IP address of the host. /// Local network interfaces. /// True if the IP address is in a local subnet. private static bool IsInLocalSubnet(IPAddress ipAddress, IEnumerable networkInterfaces) { + var operationalInterfaces = networkInterfaces.Where(x => x.IsOperational); + if (ipAddress.AddressFamily == AddressFamily.InterNetwork) { var address = IPv4Address.ToUInt32(ipAddress); - return networkInterfaces.Any(networkInterface => networkInterface.IPv4Address.Any(x => + return operationalInterfaces.Any(networkInterface => networkInterface.IPv4Address.Any(x => { if (x.Item2 == null) return false; @@ -715,7 +718,7 @@ private static bool IsInLocalSubnet(IPAddress ipAddress, IEnumerable networkInterface.IPv6Address.Any(x => + return operationalInterfaces.Any(networkInterface => networkInterface.IPv6Address.Any(x => x.GetAddressBytes().AsSpan(0, 8).SequenceEqual(prefix))); } From 4db301c160af05be41132795134d37662b377891 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Sat, 31 Oct 2026 16:49:19 +0100 Subject: [PATCH 063/131] Fix: IP Scanner: TTL in the tooltip of the ping status from the strings (F5) The tooltip of the ping status ("Success (2 ms, TTL 64)") had "TTL" in the code instead of the existing string, so it couldn't be translated. Co-Authored-By: Claude Opus 5.5 --- .../IPScannerStatusToolTipConverter.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Source/NETworkManager.Converters/IPScannerStatusToolTipConverter.cs b/Source/NETworkManager.Converters/IPScannerStatusToolTipConverter.cs index 208800e95a..e47017aa42 100644 --- a/Source/NETworkManager.Converters/IPScannerStatusToolTipConverter.cs +++ b/Source/NETworkManager.Converters/IPScannerStatusToolTipConverter.cs @@ -46,7 +46,7 @@ public object Convert(object value, Type targetType, object parameter, CultureIn var time = Models.Network.Ping.TimeToString(pingInfo.Status, pingInfo.Time); // No TTL for IPv6 - return pingInfo.TTL > 0 ? $"{status} ({time}, TTL {pingInfo.TTL})" : $"{status} ({time})"; + return pingInfo.TTL > 0 ? $"{status} ({time}, {Strings.TTL} {pingInfo.TTL})" : $"{status} ({time})"; // Open: 22 (ssh), 80 (http) / Closed case List ports: From 40a5fc631840fe4f8b180d83f8d21b3984a00a9f Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Sun, 1 Nov 2026 00:39:40 +0100 Subject: [PATCH 064/131] Fix: IP Scanner: Web section of the row details empty without the port scan The web pages are only read with the port scan. The column and the Copy menu already checked both options, the row details showed an empty list instead of the "Reading web pages is disabled" note. Co-Authored-By: Claude Opus 5.5 --- .../NETworkManager/Views/IPScannerView.xaml | 28 ++++++++++++++++--- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/Source/NETworkManager/Views/IPScannerView.xaml b/Source/NETworkManager/Views/IPScannerView.xaml index 1b3b9f22cc..31c46f2a62 100644 --- a/Source/NETworkManager/Views/IPScannerView.xaml +++ b/Source/NETworkManager/Views/IPScannerView.xaml @@ -1470,18 +1470,38 @@ Foreground="{DynamicResource MahApps.Brushes.Gray5}" VerticalAlignment="Center" /> + + Orientation="Horizontal"> + + + - + + + + + + + From 827f8a047e9af342e24afaf873dfc4cf825b3a3a Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Sun, 1 Nov 2026 00:40:25 +0100 Subject: [PATCH 065/131] IP Scanner: Web and port scan settings disabled while the port scan is disabled The web pages are only read from open ports of the port scan - Read web pages and its settings are disabled without it (the help tooltip already explained it). The ports, timeout and attempts of the port scan are disabled while the port scan is disabled, like the settings of the other probes. Co-Authored-By: Claude Opus 5.5 --- .../Views/IPScannerSettingsView.xaml | 177 +++++++++--------- Website/docs/application/ip-scanner.md | 4 +- Website/docs/changelog/next-release.md | 3 +- 3 files changed, 95 insertions(+), 89 deletions(-) diff --git a/Source/NETworkManager/Views/IPScannerSettingsView.xaml b/Source/NETworkManager/Views/IPScannerSettingsView.xaml index 4e3ff3107f..3d15e34fa7 100644 --- a/Source/NETworkManager/Views/IPScannerSettingsView.xaml +++ b/Source/NETworkManager/Views/IPScannerSettingsView.xaml @@ -60,51 +60,53 @@ - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + - - - - @@ -161,8 +163,9 @@ IsEnabled="{Binding MDNSEnabled}" Margin="0,0,0,20" /> + + IsOn="{Binding WebPageEnabled}" IsEnabled="{Binding PortScanEnabled}" /> @@ -171,48 +174,50 @@ - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + - - diff --git a/Website/docs/application/ip-scanner.md b/Website/docs/application/ip-scanner.md index 173d99b851..b37ed89f06 100644 --- a/Website/docs/application/ip-scanner.md +++ b/Website/docs/application/ip-scanner.md @@ -283,7 +283,7 @@ Resolve the hostname (PTR) for each IP address. ### Scan ports -Scan each IP address for open TCP ports. +Scan each IP address for open TCP ports. The [web pages](#read-web-pages) are only read with the port scan; its settings are disabled while the port scan is disabled. **Type:** `Boolean` @@ -428,7 +428,7 @@ Read the web page (`GET /`) of every reachable host with an open port of the [Po :::note -Only ports that are open in the [port scan](#scan-ports) are requested - the ports must also be in its [Ports](#ports) list. No cookies or credentials are sent, no proxy is used and certificate errors are accepted (the certificate is only read). Every request uses a new connection (`Connection: close`), so the certificate is also read on a rescan. The request appears in the log of the web server. At most 64 KB of the page are read. +Only ports that are open in the [port scan](#scan-ports) are requested - the ports must also be in its [Ports](#ports) list. Without the port scan, no web page is read and the setting is disabled. No cookies or credentials are sent, no proxy is used and certificate errors are accepted (the certificate is only read). Every request uses a new connection (`Connection: close`), so the certificate is also read on a rescan. The request appears in the log of the web server. At most 64 KB of the page are read. ::: diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index 9c8a2093a7..6c12187201 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -93,11 +93,12 @@ Release date: **xx.xx.2026** - New **OS (guess)** column with the operating system guessed from the TTL of the ping reply (icon and text: `Linux / Unix`, `Windows` or `Network device`, the tooltip shows the number of hops), can be disabled with the new setting **Guess the operating system from the TTL** (default on). No additional packets are sent; IPv4 only. Also in the search, **Copy** and the export (`OSGuess`). [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) - An icon in the result marks this computer, the default gateway and the DNS and DHCP servers of the connected network interfaces (all roles in the tooltip), e.g. to find the router in a `/24`. Also in the search and the export (`Role`). [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) - New **Resolve mDNS hostname** setting (default off, with **Timeout**): the mDNS hostname (e.g. `macbook.local`) of reachable hosts is resolved with a reverse lookup sent to UDP port `5353` of the host (Apple devices, Linux with Avahi, Android, printers, IoT devices). Shown in the row details (new **Hostnames** section with the DNS, mDNS and NetBIOS name) and used as **Hostname** if there is no DNS hostname (before the NetBIOS name). Also in the search, **Copy** and the export (`MDNSHostname`). [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) -- New **Read web pages** setting (default off, with **Timeout**): the web page (`GET /`) of reachable hosts with an open web port is read - the title is shown in the new **Web** column (URL in the tooltip, `Server` header if the page has no title), e.g. to identify routers, printers and NAS. The row details (new **Web** section) show the URL, the HTTP status code, the title, the `Server` and `X-Powered-By` headers, the realm of a login prompt (`WWW-Authenticate`, often the model of a router or camera) the target of a redirect to another host and, for https, the TLS certificate (subject, issuer or `Self-signed`, valid until, `(expired)`). The ports are configurable (**Ports**, default `443; 80; 8443; 8080`, tried in this order, with a button to restore the default); only ports that are open in the port scan are requested, https on `443` / `8443`, http on `80` / `8080`, https and then http on other ports. Redirects on the same host are followed, certificate errors are accepted, no cookies or credentials are sent. **Web Console** in the context menu opens the port that answered. Also in the search, **Copy** > **Web** (title, URL, server) and the export (`WebTitle`, `WebURL`, `WebStatusCode`, `WebServer`, `WebPoweredBy`, `WebRealm`, `WebRedirectURL`, `WebCertificateSubject`, `WebCertificateIssuer`, `WebCertificateNotAfter`, `WebCertificateSelfSigned`). [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) +- New **Read web pages** setting (default off, with **Timeout**): the web page (`GET /`) of reachable hosts with an open web port is read - the title is shown in the new **Web** column (URL in the tooltip, `Server` header if the page has no title), e.g. to identify routers, printers and NAS. The row details (new **Web** section) show the URL, the HTTP status code, the title, the `Server` and `X-Powered-By` headers, the realm of a login prompt (`WWW-Authenticate`, often the model of a router or camera) the target of a redirect to another host and, for https, the TLS certificate (subject, issuer or `Self-signed`, valid until, `(expired)`). The ports are configurable (**Ports**, default `443; 80; 8443; 8080`, tried in this order, with a button to restore the default); only ports that are open in the port scan are requested (the setting is disabled while **Scan ports** is disabled), https on `443` / `8443`, http on `80` / `8080`, https and then http on other ports. Redirects on the same host are followed, certificate errors are accepted, no cookies or credentials are sent. **Web Console** in the context menu opens the port that answered. Also in the search, **Copy** > **Web** (title, URL, server) and the export (`WebTitle`, `WebURL`, `WebStatusCode`, `WebServer`, `WebPoweredBy`, `WebRealm`, `WebRedirectURL`, `WebCertificateSubject`, `WebCertificateIssuer`, `WebCertificateNotAfter`, `WebCertificateSelfSigned`). [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) - New **Read the SNMP system information** setting (default off, with **Version** `v1` / `v2c`, **Community** and **Timeout**): name, description and location (`sysName`, `sysDescr`, `sysLocation`) are read with the other probes from every IP address - a host that answers is shown as up (**Detected by** `SNMP`), also if it blocks the ping and all ports - and shown in the row details (new **SNMP** section), the name also in the new **SNMP name** column, e.g. to identify switches and printers. The community is saved as plain text in the settings (warning icon next to the field, use a read-only community). Also in the search, **Copy** > **SNMP** and the export (`SNMPName`, `SNMPDescription`, `SNMPLocation`). [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) - The **Hostname** and **MAC address** / **Vendor** columns (and the new **Web** and **SNMP name** columns and the new row details) show `...` while the lookup / request is running instead of `-/-`, like the ping, port and NetBIOS columns, so a missing value can be told apart from one that is still resolved. **Hostname** and **MAC address** / **Vendor** show `...` until all their sources are finished (DNS, mDNS and NetBIOS name / neighbor table and NetBIOS). **Copy** > **Hostname** / **MAC address** / **Vendor** is disabled until the value arrived. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) - The **Ping status**, **Port status** and **NetBIOS reachable** columns show a status icon (green check / red cross, like the action of a rule in the **Firewall**) instead of the text (e.g. `Success` / `Timed out`, `Open` / `Closed`, `Yes` / `No`); the tooltip shows the details: ping status with time and TTL, the open ports with their service and the NetBIOS computer name and workgroup / domain. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) - The row details use four columns: **Ping details** / **Hostnames**, **Ports**, **Web** and **NetBIOS** / **SNMP**; a section of a disabled setting shows a note like the ports and NetBIOS. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) +- The settings of the port scan (**Ports**, **Timeout**, **Attempts**) are disabled while **Scan ports** is disabled, like the settings of the other probes. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) - The **Detect local subnet** button opens a menu: **Detect local subnet** (as before), **Subnet of network interface** to select the IPv4 subnet of a specific network interface (e.g. LAN, Wi-Fi, VPN, Hyper-V) and **All local subnets** to scan the subnets of all network interfaces. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) - IP addresses can be excluded from the scan with `!` in the host input (also in profiles and the run command), e.g. `10.0.0.0/24; !10.0.0.1; !10.0.0.200-254`. Works with IP addresses, ranges, subnets and hostnames (all IPv4 and IPv6 addresses of the hostname, nothing is scanned if the hostname can't be resolved); a message is shown if every IP address is excluded. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) From 78ad76720c23d19f7ca1fcd4a19b2308f8e642b2 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Sun, 1 Nov 2026 00:43:21 +0100 Subject: [PATCH 066/131] IP Scanner: Options button to turn the scan options on or off (F1) A menu next to Scan with the probes and lookups (ping, hostname, port scan, NetBIOS, ARP / NDP, MAC address, mDNS, web pages, SNMP), Show unreachable, Expand host view and Open settings... The items are bound to the global settings, so all tabs use the same values. The settings page (created once and kept) follows changes made outside of it. Read web pages is only enabled with the port scan, like in the settings. Disabled while a scan is running. Co-Authored-By: Claude Opus 5.5 --- .../ViewModels/IPScannerSettingsViewModel.cs | 14 ++++ .../ViewModels/IPScannerViewModel.cs | 5 ++ .../NETworkManager/Views/IPScannerView.xaml | 69 ++++++++++++++++++- Website/docs/application/ip-scanner.md | 4 ++ Website/docs/changelog/next-release.md | 1 + 5 files changed, 92 insertions(+), 1 deletion(-) diff --git a/Source/NETworkManager/ViewModels/IPScannerSettingsViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerSettingsViewModel.cs index 40b2537ede..dd17068208 100644 --- a/Source/NETworkManager/ViewModels/IPScannerSettingsViewModel.cs +++ b/Source/NETworkManager/ViewModels/IPScannerSettingsViewModel.cs @@ -600,6 +600,20 @@ public IPScannerSettingsViewModel() LoadSettings(); _isLoading = false; + + // The scan options can also be changed in the tool (options menu next to the scan button). The view (and + // this view model) is created once and kept, so it follows the settings. + SettingsManager.Current.PropertyChanged += SettingsManager_PropertyChanged; + } + + /// + /// Shows a setting of the IP Scanner that was changed outside of the settings page. Setting a property to its + /// current value does nothing, so a change made on this page isn't written again. + /// + private void SettingsManager_PropertyChanged(object sender, PropertyChangedEventArgs e) + { + if (e.PropertyName?.StartsWith("IPScanner_", StringComparison.Ordinal) == true) + LoadSettings(); } /// diff --git a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs index 6e96096e05..4f79378772 100644 --- a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs @@ -568,6 +568,11 @@ private void UseAllLocalSubnetsAction() SetHostFromSubnetDetection(string.Join("; ", subnets)); } + /// + /// Gets the command to open the IP Scanner settings (from the scan options menu). + /// + public ICommand OpenSettingsCommand => new RelayCommand(_ => NavigationManager.OpenSettings()); + /// /// Gets the command to redirect the selected host to another application. /// diff --git a/Source/NETworkManager/Views/IPScannerView.xaml b/Source/NETworkManager/Views/IPScannerView.xaml index 31c46f2a62..217f982852 100644 --- a/Source/NETworkManager/Views/IPScannerView.xaml +++ b/Source/NETworkManager/Views/IPScannerView.xaml @@ -14,6 +14,7 @@ xmlns:localization="clr-namespace:NETworkManager.Localization.Resources;assembly=NETworkManager.Localization" xmlns:controls="clr-namespace:NETworkManager.Controls;assembly=NETworkManager.Controls" xmlns:models="clr-namespace:NETworkManager.Models;assembly=NETworkManager.Models" + xmlns:settings="clr-namespace:NETworkManager.Settings;assembly=NETworkManager.Settings" Loaded="UserControl_Loaded" mc:Ignorable="d" d:DataContext="{d:DesignInstance viewModels:IPScannerViewModel}"> @@ -67,6 +68,8 @@ + + - private async Task RefreshAction() { - IsStatusMessageDisplayed = false; - await Refresh(); } @@ -376,8 +363,6 @@ private async Task Refresh(bool init = false) { IsRefreshing = true; - StatusMessage = Strings.RefreshingDots; - IsStatusMessageDisplayed = true; // The first load right away; a refresh takes at least the refresh interval (the refresh button can't be // spammed), loading runs at the same time @@ -390,8 +375,7 @@ private async Task Refresh(bool init = false) results.ForEach(Results.Add); - StatusMessage = string.Format(Strings.ReloadedAtX, DateTime.Now.ToShortTimeString()); - IsStatusMessageDisplayed = true; + LastRefreshTime = DateTime.Now; IsRefreshing = false; } diff --git a/Source/NETworkManager/Views/ConnectionsView.xaml b/Source/NETworkManager/Views/ConnectionsView.xaml index 9aa40e15a6..c58937708a 100644 --- a/Source/NETworkManager/Views/ConnectionsView.xaml +++ b/Source/NETworkManager/Views/ConnectionsView.xaml @@ -5,7 +5,6 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:converters="clr-namespace:NETworkManager.Converters;assembly=NETworkManager.Converters" xmlns:iconPacks="http://metro.mahapps.com/winfx/xaml/iconpacks" - xmlns:networkManager="clr-namespace:NETworkManager" xmlns:viewModels="clr-namespace:NETworkManager.ViewModels" xmlns:network="clr-namespace:NETworkManager.Models.Network;assembly=NETworkManager.Models" xmlns:localization="clr-namespace:NETworkManager.Localization.Resources;assembly=NETworkManager.Localization" @@ -28,11 +27,10 @@ - - + @@ -40,7 +38,13 @@ Style="{StaticResource HeaderTextBlock}" Text="{x:Static localization:Strings.Connections}" VerticalAlignment="Center" /> - + @@ -201,19 +205,6 @@ SortMemberPath="ProcessPath" MinWidth="150" /> - - - - diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index 90b1c38aed..81fe67c6e6 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -189,6 +189,7 @@ Release date: **xx.xx.2026** - The search filters after a short delay while typing instead of on every key press (smoother with many connections), `Enter` filters right away. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) - A manual refresh is faster: the wait of 2.5 seconds (so the refresh button can't be spammed) now runs while loading instead of before it, and the refresh icon spins until loading is finished. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) +- The time of the last refresh (`Reloaded at 14:32`) is shown in the header instead of a status line below the list; with **Automatically update every**, a countdown to the next refresh (`Next refresh in 00:04`). The tooltip shows the date and time. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) **Listeners** From 5c88edca41152c961785a88770e12a72aa98cb4a Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Sun, 1 Nov 2026 01:48:28 +0100 Subject: [PATCH 086/131] Listeners: Refresh status in the header, countdown to the next refresh Co-Authored-By: Claude Opus 5.5 --- .../ViewModels/ListenersViewModel.cs | 28 ++++-------------- .../NETworkManager/Views/ListenersView.xaml | 29 +++++-------------- Website/docs/changelog/next-release.md | 1 + 3 files changed, 15 insertions(+), 43 deletions(-) diff --git a/Source/NETworkManager/ViewModels/ListenersViewModel.cs b/Source/NETworkManager/ViewModels/ListenersViewModel.cs index af3ee2db48..33814dfbe0 100644 --- a/Source/NETworkManager/ViewModels/ListenersViewModel.cs +++ b/Source/NETworkManager/ViewModels/ListenersViewModel.cs @@ -236,12 +236,12 @@ public bool IsRefreshing } /// - /// Gets or sets a value indicating whether the status message is displayed. + /// Gets the time of the last refresh, null before the first one (shown in the header). /// - public bool IsStatusMessageDisplayed + public DateTime? LastRefreshTime { get; - set + private set { if (value == field) return; @@ -252,20 +252,9 @@ public bool IsStatusMessageDisplayed } /// - /// Gets or sets the status message. + /// Gets the auto refresh, for the countdown in the header (see ). /// - public string StatusMessage - { - get; - set - { - if (value == field) - return; - - field = value; - OnPropertyChanged(); - } - } + public AutoRefreshTimer AutoRefreshTimer => _autoRefreshTimer; #endregion @@ -295,8 +284,6 @@ private bool Refresh_CanExecute(object parameter) /// private async Task RefreshAction() { - IsStatusMessageDisplayed = false; - await Refresh(); } @@ -364,8 +351,6 @@ private async Task Refresh(bool init = false) { IsRefreshing = true; - StatusMessage = Strings.RefreshingDots; - IsStatusMessageDisplayed = true; // The first load right away; a refresh takes at least the refresh interval (the refresh button can't be // spammed), loading runs at the same time @@ -378,8 +363,7 @@ private async Task Refresh(bool init = false) results.ForEach(Results.Add); - StatusMessage = string.Format(Strings.ReloadedAtX, DateTime.Now.ToShortTimeString()); - IsStatusMessageDisplayed = true; + LastRefreshTime = DateTime.Now; IsRefreshing = false; } diff --git a/Source/NETworkManager/Views/ListenersView.xaml b/Source/NETworkManager/Views/ListenersView.xaml index a34f6c7e87..8c41627d75 100644 --- a/Source/NETworkManager/Views/ListenersView.xaml +++ b/Source/NETworkManager/Views/ListenersView.xaml @@ -11,7 +11,6 @@ xmlns:controls="clr-namespace:NETworkManager.Controls;assembly=NETworkManager.Controls" xmlns:internalControls="clr-namespace:NETworkManager.Controls" xmlns:wpfHelpers="clr-namespace:NETworkManager.Utilities.WPF;assembly=NETworkManager.Utilities.WPF" - xmlns:networkManager="clr-namespace:NETworkManager" mc:Ignorable="d" d:DataContext="{d:DesignInstance viewModels:ListenersViewModel}"> @@ -27,11 +26,10 @@ - - + @@ -39,7 +37,13 @@ Style="{StaticResource HeaderTextBlock}" Text="{x:Static localization:Strings.Listeners}" VerticalAlignment="Center" /> - + @@ -154,23 +158,6 @@ MinWidth="100" /> - - - - - - - - diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index 81fe67c6e6..8d49176ff2 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -195,6 +195,7 @@ Release date: **xx.xx.2026** - The search filters after a short delay while typing instead of on every key press (smoother with many listeners), `Enter` filters right away. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) - A manual refresh is faster: the wait of 2.5 seconds (so the refresh button can't be spammed) now runs while loading instead of before it, and the refresh icon spins until loading is finished. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) +- The time of the last refresh (`Reloaded at 14:32`) is shown in the header instead of a status line below the list; with **Automatically update every**, a countdown to the next refresh (`Next refresh in 00:04`). The tooltip shows the date and time. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) **Neighbor Table** From e99f7878970b89514ad47314586fa33fd70783d5 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Sun, 1 Nov 2026 01:49:47 +0100 Subject: [PATCH 087/131] Neighbor Table: Refresh status in the header, countdown to the next refresh The status line below the list only shows errors (add / delete), without an empty gap otherwise. Co-Authored-By: Claude Opus 5.5 --- .../ViewModels/NeighborTableViewModel.cs | 28 ++++++++++++++++--- .../Views/NeighborTableView.xaml | 18 ++++++------ Website/docs/changelog/next-release.md | 1 + 3 files changed, 35 insertions(+), 12 deletions(-) diff --git a/Source/NETworkManager/ViewModels/NeighborTableViewModel.cs b/Source/NETworkManager/ViewModels/NeighborTableViewModel.cs index 89bfd29003..6613a2c2bb 100644 --- a/Source/NETworkManager/ViewModels/NeighborTableViewModel.cs +++ b/Source/NETworkManager/ViewModels/NeighborTableViewModel.cs @@ -229,6 +229,27 @@ public bool IsModifying } } + /// + /// Gets the time of the last refresh, null before the first one (shown in the header). + /// + public DateTime? LastRefreshTime + { + get; + private set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } + + /// + /// Gets the auto refresh, for the countdown in the header (see ). + /// + public AutoRefreshTimer AutoRefreshTimer => _autoRefreshTimer; + public bool IsStatusMessageDisplayed { get; @@ -457,8 +478,8 @@ private async Task Refresh(bool init = false) { IsRefreshing = true; - StatusMessage = Strings.RefreshingDots; - IsStatusMessageDisplayed = true; + // An error of the last refresh / action is hidden (the refresh icon spins while refreshing) + IsStatusMessageDisplayed = false; // The first load right away; a refresh takes at least the refresh interval (the refresh button can't be // spammed), loading runs at the same time @@ -471,8 +492,7 @@ private async Task Refresh(bool init = false) results.ForEach(Results.Add); - StatusMessage = string.Format(Strings.ReloadedAtX, DateTime.Now.ToShortTimeString()); - IsStatusMessageDisplayed = true; + LastRefreshTime = DateTime.Now; IsRefreshing = false; } diff --git a/Source/NETworkManager/Views/NeighborTableView.xaml b/Source/NETworkManager/Views/NeighborTableView.xaml index 1ec41cf778..14ac56990a 100644 --- a/Source/NETworkManager/Views/NeighborTableView.xaml +++ b/Source/NETworkManager/Views/NeighborTableView.xaml @@ -7,7 +7,6 @@ xmlns:iconPacks="http://metro.mahapps.com/winfx/xaml/iconpacks" xmlns:viewModels="clr-namespace:NETworkManager.ViewModels" xmlns:network="clr-namespace:NETworkManager.Models.Network;assembly=NETworkManager.Models" - xmlns:networkManager="clr-namespace:NETworkManager" xmlns:localization="clr-namespace:NETworkManager.Localization.Resources;assembly=NETworkManager.Localization" xmlns:controls="clr-namespace:NETworkManager.Controls;assembly=NETworkManager.Controls" xmlns:internalControls="clr-namespace:NETworkManager.Controls" @@ -34,7 +33,7 @@ - + @@ -42,6 +41,7 @@ + @@ -49,7 +49,13 @@ Style="{StaticResource HeaderTextBlock}" Text="{x:Static localization:Strings.NeighborTable}" VerticalAlignment="Center" /> - + @@ -199,15 +205,11 @@ - diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index 8d49176ff2..0e6c747457 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -201,6 +201,7 @@ Release date: **xx.xx.2026** - The search filters after a short delay while typing instead of on every key press (smoother with many entries), `Enter` filters right away. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) - A manual refresh is faster: the wait of 2.5 seconds (so the refresh button can't be spammed) now runs while loading instead of before it, and the refresh icon spins until loading is finished. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) +- The time of the last refresh (`Reloaded at 14:32`) is shown in the header instead of a status line below the list; with **Automatically update every**, a countdown to the next refresh (`Next refresh in 00:04`). The tooltip shows the date and time. The line below the list only shows errors. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) **WiFi** From 2dc51a7bbb00a18df41303b66f8bc468546b7ed0 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Sun, 1 Nov 2026 01:50:50 +0100 Subject: [PATCH 088/131] Hosts File Editor: Refresh status in the header The status line below the list only shows errors (e.g. a locked hosts file, hidden again when a retry succeeds), without an empty gap otherwise. Co-Authored-By: Claude Opus 5.5 --- .../ViewModels/HostsFileEditorViewModel.cs | 27 ++++++++++++++++--- .../Views/HostsFileEditorView.xaml | 19 ++++++------- Website/docs/changelog/next-release.md | 1 + 3 files changed, 34 insertions(+), 13 deletions(-) diff --git a/Source/NETworkManager/ViewModels/HostsFileEditorViewModel.cs b/Source/NETworkManager/ViewModels/HostsFileEditorViewModel.cs index 5e879fa7e1..0a95474a86 100644 --- a/Source/NETworkManager/ViewModels/HostsFileEditorViewModel.cs +++ b/Source/NETworkManager/ViewModels/HostsFileEditorViewModel.cs @@ -158,6 +158,23 @@ public bool IsRefreshing } } + /// + /// Gets the time of the last refresh, null before the first one (shown in the header). + /// + public DateTime? LastRefreshTime + { + get; + private set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } + + /// /// Gets or sets a value indicating whether the status message is displayed. /// @@ -702,8 +719,8 @@ private async Task Refresh(bool init = false) IsRefreshing = true; - StatusMessage = Strings.RefreshingDots; - IsStatusMessageDisplayed = true; + // An error of the last refresh / action is hidden (the refresh icon spins while refreshing) + IsStatusMessageDisplayed = false; // Try 3 times if the hosts file is locked (e.g. by an antivirus or another editor) const int attempts = 3; @@ -727,8 +744,10 @@ private async Task Refresh(bool init = false) entries.ToList().ForEach(Results.Add); - StatusMessage = string.Format(Strings.ReloadedAtX, DateTime.Now.ToShortTimeString()); - IsStatusMessageDisplayed = true; + LastRefreshTime = DateTime.Now; + + // The error of a previous attempt + IsStatusMessageDisplayed = false; break; } diff --git a/Source/NETworkManager/Views/HostsFileEditorView.xaml b/Source/NETworkManager/Views/HostsFileEditorView.xaml index 433eb1e62b..16cbaa2f2a 100644 --- a/Source/NETworkManager/Views/HostsFileEditorView.xaml +++ b/Source/NETworkManager/Views/HostsFileEditorView.xaml @@ -4,11 +4,11 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:iconPacks="http://metro.mahapps.com/winfx/xaml/iconpacks" - xmlns:networkManager="clr-namespace:NETworkManager" xmlns:converters="clr-namespace:NETworkManager.Converters;assembly=NETworkManager.Converters" xmlns:viewModels="clr-namespace:NETworkManager.ViewModels" xmlns:hostsFileEditor="clr-namespace:NETworkManager.Models.HostsFileEditor;assembly=NETworkManager.Models" xmlns:localization="clr-namespace:NETworkManager.Localization.Resources;assembly=NETworkManager.Localization" + xmlns:internalControls="clr-namespace:NETworkManager.Controls" xmlns:settings="clr-namespace:NETworkManager.Settings;assembly=NETworkManager.Settings" xmlns:mahAppsControls="clr-namespace:MahApps.Metro.Controls;assembly=MahApps.Metro" xmlns:wpfHelpers="clr-namespace:NETworkManager.Utilities.WPF;assembly=NETworkManager.Utilities.WPF" @@ -30,7 +30,7 @@ - + @@ -39,6 +39,7 @@ + @@ -46,7 +47,12 @@ Style="{StaticResource HeaderTextBlock}" Text="{x:Static localization:Strings.HostsFileEditor}" VerticalAlignment="Center" /> - + @@ -302,16 +308,11 @@ - diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index 0e6c747457..120e15c0a4 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -160,6 +160,7 @@ Release date: **xx.xx.2026** - Entries that don't change (e.g. enabling an already enabled entry, or confirming the edit dialog without changes) are no longer rewritten in the `hosts` file, so custom formatting such as tabs or alignment is kept. [#3624](https://github.com/BornToBeRoot/NETworkManager/pull/3624) - The search filters after a short delay while typing instead of on every key press (smoother with many entries), `Enter` filters right away. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) - A manual refresh is faster: the wait of 2.5 seconds (so the refresh button can't be spammed) now runs while loading instead of before it, and the refresh icon spins until loading is finished. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) +- The time of the last refresh (`Reloaded at 14:32`) is shown in the header instead of a status line below the list (the tooltip shows the date and time). The line below the list only shows errors. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) **Firewall** From ed5caf204b8dee142097024ab18d98cefd71ca99 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Sun, 1 Nov 2026 01:51:44 +0100 Subject: [PATCH 089/131] Firewall: Refresh status in the header The status line below the list only shows errors (failed load, rules loaded with errors, a failed action), without an empty gap otherwise. The loading indicator of the first load stays. Co-Authored-By: Claude Opus 5.5 --- .../ViewModels/FirewallViewModel.cs | 27 ++++++++++++++----- Source/NETworkManager/Views/FirewallView.xaml | 17 +++++++----- Website/docs/changelog/next-release.md | 1 + 3 files changed, 32 insertions(+), 13 deletions(-) diff --git a/Source/NETworkManager/ViewModels/FirewallViewModel.cs b/Source/NETworkManager/ViewModels/FirewallViewModel.cs index 760a6947dd..2df7ca0155 100644 --- a/Source/NETworkManager/ViewModels/FirewallViewModel.cs +++ b/Source/NETworkManager/ViewModels/FirewallViewModel.cs @@ -198,6 +198,22 @@ public bool IsModifying } } + /// + /// Gets the time of the last refresh, null before the first one (shown in the header). + /// + public DateTime? LastRefreshTime + { + get; + private set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } + /// /// Gets or sets whether the status message bar is shown. /// @@ -771,8 +787,8 @@ private async Task Refresh(bool init = false) // Show the loading indicator instead of the (empty) list on the initial load, see IsLoading IsLoading = init; - StatusMessage = Strings.RefreshingDots; - IsStatusMessageDisplayed = true; + // An error of the last refresh / action is hidden (the refresh icon spins while refreshing) + IsStatusMessageDisplayed = false; try { @@ -816,19 +832,18 @@ private async Task Refresh(bool init = false) } }); - IsStatusMessageDisplayed = true; + LastRefreshTime = DateTime.Now; // Only parts of the query failed (e.g. a single rule or a filter), show that the list may be incomplete if (errors.Count > 0) { StatusMessage = string.Format(Strings.FirewallRulesLoadedWithErrorsMessage, - DateTime.Now.ToShortTimeString(), errors[0]); + LastRefreshTime.Value.ToShortTimeString(), errors[0]); + IsStatusMessageDisplayed = true; return false; } - StatusMessage = string.Format(Strings.ReloadedAtX, DateTime.Now.ToShortTimeString()); - return true; } catch (Exception ex) diff --git a/Source/NETworkManager/Views/FirewallView.xaml b/Source/NETworkManager/Views/FirewallView.xaml index 61a2622874..266cada653 100644 --- a/Source/NETworkManager/Views/FirewallView.xaml +++ b/Source/NETworkManager/Views/FirewallView.xaml @@ -8,6 +8,7 @@ xmlns:iconPacks="http://metro.mahapps.com/winfx/xaml/iconpacks" xmlns:viewModels="clr-namespace:NETworkManager.ViewModels" xmlns:localization="clr-namespace:NETworkManager.Localization.Resources;assembly=NETworkManager.Localization" + xmlns:internalControls="clr-namespace:NETworkManager.Controls" xmlns:settings="clr-namespace:NETworkManager.Settings;assembly=NETworkManager.Settings" xmlns:controls="clr-namespace:NETworkManager.Controls;assembly=NETworkManager.Controls" xmlns:firewall="clr-namespace:NETworkManager.Models.Firewall;assembly=NETworkManager.Models" @@ -39,7 +40,7 @@ - + @@ -49,6 +50,7 @@ + @@ -56,7 +58,12 @@ Style="{StaticResource HeaderTextBlock}" Text="{x:Static localization:Strings.FirewallRules}" VerticalAlignment="Center" /> - + @@ -752,15 +759,11 @@ - diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index 120e15c0a4..87900deb2a 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -181,6 +181,7 @@ Release date: **xx.xx.2026** - Faster loading of the rules managed by NETworkManager if there are many of them (the ports, addresses, programs etc. of all rules are queried at once instead of per rule). [#3637](https://github.com/BornToBeRoot/NETworkManager/pull/3637) - `Enter` in the search box filters the rules right away instead of after the delay while typing. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) - A manual refresh is faster: the wait of 2.5 seconds (so the refresh button can't be spammed) now runs while loading instead of before it, and the refresh icon spins until the rules are loaded (also if this takes longer, e.g. with **Show all rules**). [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) +- The time of the last refresh (`Reloaded at 14:32`) is shown in the header instead of a status line below the list (the tooltip shows the date and time). The line below the list only shows errors (e.g. rules loaded with errors, a failed action). [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) **Profiles** From 96d0f69d1f10b3dfdf993a9a7fab00777c344566 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Sun, 1 Nov 2026 01:53:04 +0100 Subject: [PATCH 090/131] WiFi: Scan status in the header of the networks, countdown to the next scan The status line below only shows errors and the connection status. No "Searching for networks..." text and no pulse indicator, the search icon spins. Co-Authored-By: Claude Opus 5.5 --- .../ViewModels/WiFiViewModel.cs | 36 ++++++++++++++----- Source/NETworkManager/Views/WiFiView.xaml | 25 ++++++++----- Website/docs/changelog/next-release.md | 1 + 3 files changed, 44 insertions(+), 18 deletions(-) diff --git a/Source/NETworkManager/ViewModels/WiFiViewModel.cs b/Source/NETworkManager/ViewModels/WiFiViewModel.cs index 351470f136..fd90245358 100644 --- a/Source/NETworkManager/ViewModels/WiFiViewModel.cs +++ b/Source/NETworkManager/ViewModels/WiFiViewModel.cs @@ -133,6 +133,27 @@ public bool IsNetworksLoading } } + /// + /// Gets the time of the last scan of the networks, null before the first one (shown in the header). + /// + public DateTime? LastRefreshTime + { + get; + private set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } + + /// + /// Gets the auto refresh, for the countdown in the header (see ). + /// + public AutoRefreshTimer AutoRefreshTimer => _autoRefreshTimer; + public bool AutoRefreshEnabled { get; @@ -711,16 +732,13 @@ private async Task ScanAsync(WiFiAdapterInfo adapterInfo, bool refreshing = fals { Log.Debug($"ScanAsync - Scanning WiFi adapter \"{adapterInfo.NetworkInterfaceInfo.Name}\" with delay of {delayInMs} ms..."); + // An error of the last scan is hidden (the search icon spins while searching) + IsStatusMessageDisplayed = false; + if (refreshing) - { - StatusMessage = Strings.SearchingForNetworksDots; IsBackgroundSearchRunning = true; - } else - { - IsStatusMessageDisplayed = false; IsNetworksLoading = true; - } if (delayInMs != 0) await Task.Delay(delayInMs); @@ -799,8 +817,7 @@ private async Task ScanAsync(WiFiAdapterInfo adapterInfo, bool refreshing = fals Radio6GHzLowerLegend = [.. legend6GHzLower]; Radio6GHzUpperLegend = [.. legend6GHzUpper]; - statusMessage = string.Format(Strings.LastScanAtX, - wiFiNetworkScanInfo.Timestamp.ToLongTimeString()); + LastRefreshTime = wiFiNetworkScanInfo.Timestamp; } catch (Exception ex) { @@ -822,8 +839,9 @@ private async Task ScanAsync(WiFiAdapterInfo adapterInfo, bool refreshing = fals } finally { - IsStatusMessageDisplayed = true; + // Only an error, the time of the scan is shown in the header StatusMessage = statusMessage; + IsStatusMessageDisplayed = !string.IsNullOrEmpty(statusMessage); IsBackgroundSearchRunning = false; IsNetworksLoading = false; diff --git a/Source/NETworkManager/Views/WiFiView.xaml b/Source/NETworkManager/Views/WiFiView.xaml index 8e970dc8a5..fa28c9c15f 100644 --- a/Source/NETworkManager/Views/WiFiView.xaml +++ b/Source/NETworkManager/Views/WiFiView.xaml @@ -23,6 +23,7 @@ + @@ -205,15 +206,27 @@ - + - - + + + + + + + + + - Date: Sun, 1 Nov 2026 01:59:00 +0100 Subject: [PATCH 091/131] IP Scanner: Changes since the last scan (E1) A completed scan of the same input as the last completed scan in the tab is compared with it: New (up now, not before), Gone (up before, not now - a down row with the previous hostname / MAC is added) and MAC changed (other MAC, previous one in the tooltip). Change column and counters only with a comparison, "Last scan at" in the header. Canceled scans, a rescan of selected hosts and another input don't compare / don't become the baseline. In the search and the export (Change, PreviousMACAddress). IPScannerScanComparer with tests. Co-Authored-By: Claude Opus 5.5 --- .../IPScannerHostChangeToStringConverter.cs | 56 +++++++ .../Resources/Strings.Designer.cs | 63 ++++++++ .../Resources/Strings.resx | 21 +++ .../Export/ExportManager.IPScannerHostInfo.cs | 6 +- .../Network/IPScannerHostChange.cs | 29 ++++ .../Network/IPScannerHostInfo.cs | 61 +++++++ .../Network/IPScannerScanComparer.cs | 89 +++++++++++ .../Network/ExportManagerIPScannerTests.cs | 2 +- .../Network/IPScannerScanComparerTests.cs | 116 ++++++++++++++ .../ViewModels/IPScannerViewModel.cs | 150 ++++++++++++++++++ .../NETworkManager/Views/IPScannerView.xaml | 114 +++++++++++++ Website/docs/application/ip-scanner.md | 13 ++ Website/docs/changelog/next-release.md | 1 + 13 files changed, 719 insertions(+), 2 deletions(-) create mode 100644 Source/NETworkManager.Converters/IPScannerHostChangeToStringConverter.cs create mode 100644 Source/NETworkManager.Models/Network/IPScannerHostChange.cs create mode 100644 Source/NETworkManager.Models/Network/IPScannerScanComparer.cs create mode 100644 Source/NETworkManager.Tests/Network/IPScannerScanComparerTests.cs diff --git a/Source/NETworkManager.Converters/IPScannerHostChangeToStringConverter.cs b/Source/NETworkManager.Converters/IPScannerHostChangeToStringConverter.cs new file mode 100644 index 0000000000..c7538054c1 --- /dev/null +++ b/Source/NETworkManager.Converters/IPScannerHostChangeToStringConverter.cs @@ -0,0 +1,56 @@ +using System; +using System.Globalization; +using System.Windows.Data; +using NETworkManager.Localization.Resources; +using NETworkManager.Models.Network; + +namespace NETworkManager.Converters; + +/// +/// Convert the of a host (since the previous scan) to a translated +/// (e.g. "New"). +/// +public sealed class IPScannerHostChangeToStringConverter : IValueConverter +{ + /// + /// Convert the of a host to a translated . + /// + /// Change of the host as . + /// + /// + /// + /// Translated change, empty if there is none. + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + return value is IPScannerHostChange change ? ToLocalizedString(change) : string.Empty; + } + + /// + /// Gets the translated change of a host. + /// + /// Change of the host. + /// Translated change, empty for . + public static string ToLocalizedString(IPScannerHostChange change) + { + return change switch + { + IPScannerHostChange.New => Strings.HostChange_New, + IPScannerHostChange.Gone => Strings.HostChange_Gone, + IPScannerHostChange.MACChanged => Strings.HostChange_MACChanged, + _ => string.Empty + }; + } + + /// + /// !!! Method not implemented !!! + /// + /// + /// + /// + /// + /// + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} diff --git a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs index f53d351a80..f5b43d39a3 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs +++ b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs @@ -6356,6 +6356,33 @@ public static string Host { } } + /// + /// Looks up a localized string similar to Gone. + /// + public static string HostChange_Gone { + get { + return ResourceManager.GetString("HostChange_Gone", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to MAC changed. + /// + public static string HostChange_MACChanged { + get { + return ResourceManager.GetString("HostChange_MACChanged", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to New. + /// + public static string HostChange_New { + get { + return ResourceManager.GetString("HostChange_New", resourceCulture); + } + } + /// /// Looks up a localized string similar to Hosting. /// @@ -9652,6 +9679,15 @@ public static string Preview { } } + /// + /// Looks up a localized string similar to Previous MAC address: {0}. + /// + public static string PreviousMACAddressX { + get { + return ResourceManager.GetString("PreviousMACAddressX", resourceCulture); + } + } + /// /// Looks up a localized string similar to Primary DNS server. /// @@ -15325,6 +15361,15 @@ public static string XExpired { } } + /// + /// Looks up a localized string similar to {0} Gone. + /// + public static string XGone { + get { + return ResourceManager.GetString("XGone", resourceCulture); + } + } + /// /// Looks up a localized string similar to {0} host(s) did not answer the ping and were not scanned.. /// @@ -15334,6 +15379,24 @@ public static string XHostsDidNotAnswerThePingAndWereNotScanned { } } + /// + /// Looks up a localized string similar to {0} MAC changed. + /// + public static string XMACChanged { + get { + return ResourceManager.GetString("XMACChanged", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0} New. + /// + public static string XNew { + get { + return ResourceManager.GetString("XNew", resourceCulture); + } + } + /// /// Looks up a localized string similar to {0} Open. /// diff --git a/Source/NETworkManager.Localization/Resources/Strings.resx b/Source/NETworkManager.Localization/Resources/Strings.resx index 2fc9baa1ec..afab4a725e 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.resx +++ b/Source/NETworkManager.Localization/Resources/Strings.resx @@ -1321,6 +1321,27 @@ Profile files are not affected! Change + + New + + + Gone + + + MAC changed + + + {0} New + + + {0} Gone + + + {0} MAC changed + + + Previous MAC address: {0} + Change Master Password... diff --git a/Source/NETworkManager.Models/Export/ExportManager.IPScannerHostInfo.cs b/Source/NETworkManager.Models/Export/ExportManager.IPScannerHostInfo.cs index b0c6ca1020..785f8f1262 100644 --- a/Source/NETworkManager.Models/Export/ExportManager.IPScannerHostInfo.cs +++ b/Source/NETworkManager.Models/Export/ExportManager.IPScannerHostInfo.cs @@ -112,7 +112,11 @@ private static List GetFields(IPScannerHostInfo info, IPScannerResu new("WebCertificateNotAfter", certificate == null ? null : DateTimeHelper.DateTimeToFullDateTimeString(certificate.NotAfter.ToLocalTime())), - new("WebCertificateSelfSigned", certificate?.IsSelfSigned) + new("WebCertificateSelfSigned", certificate?.IsSelfSigned), + // Change since the previous scan of the same input (empty without a previous scan / change) + new(nameof(IPScannerHostInfo.Change), + info.Change == IPScannerHostChange.None ? null : info.Change.ToString()), + new(nameof(IPScannerHostInfo.PreviousMACAddress), info.PreviousMACAddress) ]; } diff --git a/Source/NETworkManager.Models/Network/IPScannerHostChange.cs b/Source/NETworkManager.Models/Network/IPScannerHostChange.cs new file mode 100644 index 0000000000..b33bf8012c --- /dev/null +++ b/Source/NETworkManager.Models/Network/IPScannerHostChange.cs @@ -0,0 +1,29 @@ +namespace NETworkManager.Models.Network; + +/// +/// Change of a host since the previous scan of the same input in the same tab (see +/// ). +/// +public enum IPScannerHostChange +{ + /// + /// No change, or there is no previous scan to compare with. + /// + None, + + /// + /// The host is up now, it was not up in the previous scan. + /// + New, + + /// + /// The host was up in the previous scan, it is not up now. + /// + Gone, + + /// + /// The host is up with another MAC address than in the previous scan (both known), e.g. a replaced device, an + /// IP address conflict or ARP spoofing. + /// + MACChanged +} diff --git a/Source/NETworkManager.Models/Network/IPScannerHostInfo.cs b/Source/NETworkManager.Models/Network/IPScannerHostInfo.cs index d772ae39df..1860e571d4 100644 --- a/Source/NETworkManager.Models/Network/IPScannerHostInfo.cs +++ b/Source/NETworkManager.Models/Network/IPScannerHostInfo.cs @@ -648,6 +648,65 @@ internal set } } + /// + /// Change since the previous scan of the same input (set by the view model after the scan, see + /// ). Not taken over by : a rescan of the host keeps + /// the change of the scan. + /// + public IPScannerHostChange Change + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } + + /// + /// MAC address of the previous scan if it changed (), otherwise + /// empty. + /// + public string PreviousMACAddress + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } = string.Empty; + + /// + /// Creates the row of a host that was up in the previous scan and is not up now + /// (), with the hostname and MAC address of the previous scan. + /// + /// IP address of the host. + /// The host in the previous scan. + /// Unreachable host, without pending values. + public static IPScannerHostInfo CreateGone(IPAddress ipAddress, IPScannerBaselineHost previous) + { + var host = new IPScannerHostInfo(ipAddress, false, + new PingInfo(System.DateTime.Now, ipAddress, IPStatus.Unknown), false, [], new NetBIOSInfo(ipAddress)) + { + // The shown hostname of the previous scan (DNS, mDNS or NetBIOS) - the host is not asked again + DNSHostname = previous.Hostname, + NeighborMACAddress = previous.MACAddress, + NeighborVendor = previous.Vendor, + Change = IPScannerHostChange.Gone + }; + + host.EndPendingLookups(); + + return host; + } + /// /// Takes over all values of another result of the same host (e.g. a rescan), so the shown row keeps its /// identity (selection, expanded details). Call it on the thread that owns the host (UI thread). @@ -703,6 +762,8 @@ public IPScannerHostInfo Clone() // One list of the values (see CopyFrom), the new instance has no event subscribers yet clone.CopyFrom(this); + clone.Change = Change; + clone.PreviousMACAddress = PreviousMACAddress; return clone; } diff --git a/Source/NETworkManager.Models/Network/IPScannerScanComparer.cs b/Source/NETworkManager.Models/Network/IPScannerScanComparer.cs new file mode 100644 index 0000000000..b2fc23259c --- /dev/null +++ b/Source/NETworkManager.Models/Network/IPScannerScanComparer.cs @@ -0,0 +1,89 @@ +using System; +using System.Collections.Generic; +using System.Net; + +namespace NETworkManager.Models.Network; + +/// +/// A host of a completed scan, the baseline of the next scan of the same input (see +/// ). +/// +/// The host was up (reachable). +/// Shown hostname (DNS, mDNS or NetBIOS), empty if unknown. +/// MAC address, empty if unknown. +/// Vendor of the MAC address, empty if unknown. +public sealed record IPScannerBaselineHost(bool IsUp, string Hostname, string MACAddress, string Vendor); + +/// +/// Result of . +/// +/// Change of each host of the current scan (only hosts with a change). +/// Hosts that were up in the previous scan and are not up (or not in the result) now. +public sealed record IPScannerComparison( + IReadOnlyDictionary Changes, + IReadOnlyList<(IPAddress IPAddress, IPScannerBaselineHost Previous)> Gone); + +/// +/// Compares a scan with the previous scan of the same input: which hosts are new, gone or have another MAC address. +/// +public static class IPScannerScanComparer +{ + /// + /// Creates the baseline of a completed scan, to compare the next scan with. + /// + /// Hosts of the result (up, and down ones if they are shown). + /// Baseline by IP address. + public static Dictionary CreateBaseline(IEnumerable hosts) + { + var baseline = new Dictionary(); + + foreach (var host in hosts) + { + // A gone host of the last comparison is a placeholder, not a result of the scan + if (host.Change == IPScannerHostChange.Gone) + continue; + + baseline[host.IPAddress] = new IPScannerBaselineHost(host.IsReachable, host.Hostname ?? string.Empty, + host.MACAddress ?? string.Empty, host.Vendor ?? string.Empty); + } + + return baseline; + } + + /// + /// Compares the current scan with the baseline of the previous one. + /// + /// Baseline of the previous scan (see ). + /// Current scan as baseline (see ). + /// Changes of the current hosts and the gone hosts (in the order of the previous scan). + public static IPScannerComparison Compare(IReadOnlyDictionary baseline, + IReadOnlyDictionary current) + { + var changes = new Dictionary(); + var gone = new List<(IPAddress, IPScannerBaselineHost)>(); + + foreach (var (ipAddress, host) in current) + { + if (!host.IsUp) + continue; + + if (!baseline.TryGetValue(ipAddress, out var previous) || !previous.IsUp) + { + changes[ipAddress] = (IPScannerHostChange.New, string.Empty); + continue; + } + + if (!string.IsNullOrEmpty(host.MACAddress) && !string.IsNullOrEmpty(previous.MACAddress) && + !string.Equals(host.MACAddress, previous.MACAddress, StringComparison.OrdinalIgnoreCase)) + changes[ipAddress] = (IPScannerHostChange.MACChanged, previous.MACAddress); + } + + foreach (var (ipAddress, previous) in baseline) + { + if (previous.IsUp && (!current.TryGetValue(ipAddress, out var host) || !host.IsUp)) + gone.Add((ipAddress, previous)); + } + + return new IPScannerComparison(changes, gone); + } +} diff --git a/Source/NETworkManager.Tests/Network/ExportManagerIPScannerTests.cs b/Source/NETworkManager.Tests/Network/ExportManagerIPScannerTests.cs index ab68c21577..84aa86ead9 100644 --- a/Source/NETworkManager.Tests/Network/ExportManagerIPScannerTests.cs +++ b/Source/NETworkManager.Tests/Network/ExportManagerIPScannerTests.cs @@ -22,7 +22,7 @@ public class ExportManagerIPScannerTests "NetBIOSIPAddress,NetBIOSComputerName,NetBIOSUserName,NetBIOSGroupName,NetBIOSMACAddress,NetBIOSVendor," + "MACAddress,Vendor,DNSHostname,DetectedBy,OSGuess,Role,MDNSHostname,WebTitle,WebURL,SNMPName,SNMPDescription," + "SNMPLocation,WebStatusCode,WebServer,WebPoweredBy,WebRealm,WebRedirectURL,WebCertificateSubject," + - "WebCertificateIssuer,WebCertificateNotAfter,WebCertificateSelfSigned"; + "WebCertificateIssuer,WebCertificateNotAfter,WebCertificateSelfSigned,Change,PreviousMACAddress"; private static readonly IPScannerResultOptions AllEnabled = new(true, true, true, true, true, true, true, true, true); diff --git a/Source/NETworkManager.Tests/Network/IPScannerScanComparerTests.cs b/Source/NETworkManager.Tests/Network/IPScannerScanComparerTests.cs new file mode 100644 index 0000000000..26c2bb63ef --- /dev/null +++ b/Source/NETworkManager.Tests/Network/IPScannerScanComparerTests.cs @@ -0,0 +1,116 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net; +using NETworkManager.Models.Network; +using Xunit; + +namespace NETworkManager.Tests.Network; + +public class IPScannerScanComparerTests +{ + private static readonly IPAddress A = IPAddress.Parse("10.0.0.1"); + private static readonly IPAddress B = IPAddress.Parse("10.0.0.2"); + private static readonly IPAddress C = IPAddress.Parse("10.0.0.3"); + private static readonly IPAddress V6 = IPAddress.Parse("fe80::1"); + + private static IPScannerBaselineHost Up(string mac = "") => new(true, "host", mac, "vendor"); + private static IPScannerBaselineHost Down() => new(false, string.Empty, string.Empty, string.Empty); + + private static Dictionary Scan(params (IPAddress, IPScannerBaselineHost)[] hosts) => + hosts.ToDictionary(x => x.Item1, x => x.Item2); + + [Fact] + public void SameHostsHaveNoChange() + { + var result = IPScannerScanComparer.Compare(Scan((A, Up("00:11")), (B, Up())), + Scan((A, Up("00:11")), (B, Up()))); + + Assert.Empty(result.Changes); + Assert.Empty(result.Gone); + } + + [Fact] + public void HostUpNowIsNew() + { + var result = IPScannerScanComparer.Compare(Scan((A, Up()), (B, Down())), Scan((A, Up()), (B, Up()), (C, Up()))); + + Assert.Equal(IPScannerHostChange.New, result.Changes[B].Change); + Assert.Equal(IPScannerHostChange.New, result.Changes[C].Change); + Assert.False(result.Changes.ContainsKey(A)); + } + + [Fact] + public void HostNotUpNowIsGone() + { + var previous = Up("00:11"); + var result = IPScannerScanComparer.Compare(Scan((A, previous), (B, Up()), (V6, Up())), + Scan((B, Down()))); + + Assert.Equal([A, B, V6], result.Gone.Select(x => x.IPAddress)); + Assert.Same(previous, result.Gone[0].Previous); + } + + [Fact] + public void HostDownBeforeAndNowIsNoChange() + { + var result = IPScannerScanComparer.Compare(Scan((A, Down())), Scan((A, Down()))); + + Assert.Empty(result.Changes); + Assert.Empty(result.Gone); + } + + [Fact] + public void OtherMACAddressIsMACChanged() + { + var result = IPScannerScanComparer.Compare(Scan((A, Up("00:11:22:33:44:55"))), + Scan((A, Up("66:77:88:99:AA:BB")))); + + Assert.Equal(IPScannerHostChange.MACChanged, result.Changes[A].Change); + Assert.Equal("00:11:22:33:44:55", result.Changes[A].PreviousMACAddress); + } + + [Theory] + [InlineData("", "00:11:22:33:44:55")] + [InlineData("00:11:22:33:44:55", "")] + [InlineData("00:11:22:33:44:aa", "00:11:22:33:44:AA")] + public void UnknownOrSameMACAddressIsNoChange(string previousMAC, string currentMAC) + { + var result = IPScannerScanComparer.Compare(Scan((A, Up(previousMAC))), Scan((A, Up(currentMAC)))); + + Assert.Empty(result.Changes); + } + + [Fact] + public void EmptyBaselineMarksAllUpHostsAsNew() + { + var result = IPScannerScanComparer.Compare(Scan(), Scan((A, Up()), (B, Down()))); + + Assert.Single(result.Changes); + Assert.Equal(IPScannerHostChange.New, result.Changes[A].Change); + } + + [Fact] + public void BaselineSkipsGoneRowsOfTheLastComparison() + { + var gone = IPScannerHostInfo.CreateGone(A, Up("00:11")); + var up = new IPScannerHostInfo(B, true, new PingInfo(), false, [], new NetBIOSInfo(B)); + + var baseline = IPScannerScanComparer.CreateBaseline([gone, up]); + + Assert.Equal([B], baseline.Keys); + Assert.True(baseline[B].IsUp); + } + + [Fact] + public void GoneRowHasThePreviousValues() + { + var gone = IPScannerHostInfo.CreateGone(A, new IPScannerBaselineHost(true, "nas.local", "00:11", "Synology")); + + Assert.False(gone.IsReachable); + Assert.Equal(IPScannerHostChange.Gone, gone.Change); + Assert.Equal("nas.local", gone.Hostname); + Assert.Equal("00:11", gone.MACAddress); + Assert.False(gone.IsHostnamePending); + Assert.False(gone.IsMACAddressPending); + } +} diff --git a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs index 4f79378772..bcd7e42d30 100644 --- a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs @@ -60,6 +60,14 @@ public class IPScannerViewModel : ViewModelBase, IProfileManagerMinimal // Options of the last scan, to rescan hosts with the same options (the columns follow them, see ScanOptions) private IPScannerOptions _lastScanOptions; + // Baseline of the last completed scan of this tab and its input: the next scan of the same input shows what + // changed (see CompareWithPreviousScan). Only in memory, gone when the tab is closed. + private Dictionary _baseline; + private string _baselineInput; + + // Input of the running / last scan (not of a rescan of selected hosts) + private string _scanInput; + // Rescan of shown hosts (see RescanSelectedHosts), null if no rescan is running: row per rescanned IP address // (every IP address is scanned once, see HostRangeHelper.ResolveAsync). The scanner creates new results - their // values are copied into the existing rows (selection and expanded details stay), the updates of a new result are @@ -326,6 +334,87 @@ public int HostsDown } } + /// + /// Gets or sets the number of hosts that are up now and were not up in the previous scan (see ). + /// + public int HostsNew + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } + + /// + /// Gets or sets the number of hosts that are not up now and were up in the previous scan (see ). + /// + public int HostsGone + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } + + /// + /// Gets or sets the number of hosts that are up with another MAC address than in the previous scan (see ). + /// + public int HostsMACChanged + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } + + /// + /// Gets or sets a value indicating whether the shown result is compared with the previous scan of the same input + /// (change column and counters). + /// + public bool HasComparison + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } + + /// + /// Gets or sets the time the last scan was completed, null before (or while a scan is running). + /// + public DateTime? LastScanTime + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } + /// /// Gets or sets a value indicating whether the scan is being prepared. /// @@ -468,6 +557,7 @@ public IPScannerViewModel(Guid tabId, string hostOrIPRange, bool start = true) (ScanOptions.OSGuessEnabled && Matches(IPScannerOSGuessToStringConverter.ToLocalizedString(info.OSGuess))) || Matches(IPScannerHostRoleToStringConverter.ToLocalizedString(info.Role)) || + Matches(IPScannerHostChangeToStringConverter.ToLocalizedString(info.Change)) || info.Ports.Any(x => Matches(x.Port.ToString()) || Matches(x.LookupInfo.Service)); }; @@ -478,6 +568,7 @@ public IPScannerViewModel(Guid tabId, string hostOrIPRange, bool start = true) liveShaping.LiveFilteringProperties.Add(nameof(IPScannerHostInfo.Hostname)); liveShaping.LiveFilteringProperties.Add(nameof(IPScannerHostInfo.MDNSHostname)); liveShaping.LiveFilteringProperties.Add(nameof(IPScannerHostInfo.WebTitle)); + liveShaping.LiveFilteringProperties.Add(nameof(IPScannerHostInfo.Change)); liveShaping.LiveFilteringProperties.Add(nameof(IPScannerHostInfo.WebServer)); liveShaping.LiveFilteringProperties.Add(nameof(IPScannerHostInfo.WebPoweredBy)); liveShaping.LiveFilteringProperties.Add(nameof(IPScannerHostInfo.WebRealm)); @@ -841,6 +932,14 @@ private async Task Start() Results.Clear(); + // Compared with the previous scan of the same input when this scan is completed (see ScanComplete) + _scanInput = Host?.Trim() ?? string.Empty; + HasComparison = false; + HostsNew = 0; + HostsGone = 0; + HostsMACChanged = 0; + LastScanTime = null; + // No results are shown until the scan's options are known - follow the current settings ScanOptions = CreateResultOptions(); @@ -1547,6 +1646,9 @@ private void ScanComplete(object sender, ScanCompleteArgs e) { Application.Current.Dispatcher.Invoke(() => { + // A rescan of selected hosts updates rows, it is not a scan of the input (see CompareWithPreviousScan) + var isRescan = _rescanRows != null; + // All hosts and lookups are finished when the scanner raises ScanComplete - add the remaining // buffered hosts and apply the remaining updates before the scan ends, with the final counters _resultsFlushTimer?.Stop(); @@ -1612,6 +1714,10 @@ private void ScanComplete(object sender, ScanCompleteArgs e) AddStatusMessage(Strings.NoReachableHostsFound); } + // Only a completed scan of the input is compared and becomes the baseline of the next one + if (!isRescan && !e.IsCanceled && e.Exception == null && HostsToScan > 0) + CompareWithPreviousScan(); + PreparingScan = false; IsCanceling = false; IsRunning = false; @@ -1621,5 +1727,49 @@ private void ScanComplete(object sender, ScanCompleteArgs e) }); } + /// + /// Compares the completed scan with the previous completed scan of the same input in this tab: marks new hosts + /// and hosts with another MAC address, adds the hosts that are gone (shown as down, also without "Show + /// unreachable"). The scan becomes the baseline of the next one. Called on the UI thread. + /// + private void CompareWithPreviousScan() + { + var current = IPScannerScanComparer.CreateBaseline(Results); + + if (_baseline != null && string.Equals(_baselineInput, _scanInput, StringComparison.OrdinalIgnoreCase)) + { + var comparison = IPScannerScanComparer.Compare(_baseline, current); + + foreach (var host in Results) + { + if (comparison.Changes.TryGetValue(host.IPAddress, out var change)) + { + host.Change = change.Change; + host.PreviousMACAddress = change.PreviousMACAddress; + } + } + + foreach (var (ipAddress, previous) in comparison.Gone) + { + // A shown down host (Show unreachable) is marked, otherwise a row is added + var row = Results.FirstOrDefault(x => x.IPAddress.Equals(ipAddress)); + + if (row != null) + row.Change = IPScannerHostChange.Gone; + else + Results.Add(IPScannerHostInfo.CreateGone(ipAddress, previous)); + } + + HostsNew = comparison.Changes.Values.Count(x => x.Change == IPScannerHostChange.New); + HostsMACChanged = comparison.Changes.Values.Count(x => x.Change == IPScannerHostChange.MACChanged); + HostsGone = comparison.Gone.Count; + HasComparison = true; + } + + _baseline = current; + _baselineInput = _scanInput; + LastScanTime = DateTime.Now; + } + #endregion } \ No newline at end of file diff --git a/Source/NETworkManager/Views/IPScannerView.xaml b/Source/NETworkManager/Views/IPScannerView.xaml index 217f982852..399552a296 100644 --- a/Source/NETworkManager/Views/IPScannerView.xaml +++ b/Source/NETworkManager/Views/IPScannerView.xaml @@ -12,6 +12,7 @@ xmlns:viewModels="clr-namespace:NETworkManager.ViewModels" xmlns:network="clr-namespace:NETworkManager.Models.Network;assembly=NETworkManager.Models" xmlns:localization="clr-namespace:NETworkManager.Localization.Resources;assembly=NETworkManager.Localization" + xmlns:internalControls="clr-namespace:NETworkManager.Controls" xmlns:controls="clr-namespace:NETworkManager.Controls;assembly=NETworkManager.Controls" xmlns:models="clr-namespace:NETworkManager.Models;assembly=NETworkManager.Models" xmlns:settings="clr-namespace:NETworkManager.Settings;assembly=NETworkManager.Settings" @@ -31,6 +32,7 @@ + @@ -347,7 +349,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + all exports the filtered results; the up / down counters always count all hosts. Results can also be exported while the scan is running, values that are still `...` are exported as empty values. +### Changes since the last scan + +A scan of the same input as the last completed scan in the tab shows what changed since then: + +- **New**: the host is up now, it was not up in the last scan. +- **Gone**: the host was up in the last scan, it is not up now. The row is added at the end of the scan (as down, with the hostname and MAC address of the last scan), also if [Show unreachable IP addresses and ports](#show-unreachable-ip-addresses-and-ports) is disabled. +- **MAC changed**: the host is up with another MAC address than in the last scan (both known), e.g. a replaced device, an IP address conflict or ARP spoofing. The tooltip shows the previous MAC address. + +The **Change** column and the counters next to the **Result** header (e.g. `2 New · 1 Gone`) are only shown if the result is compared with a previous scan. **Last scan at** next to the counters shows when the last scan was completed (the tooltip shows the date). + +Only completed scans are compared: a canceled scan or a scan with another input starts over (the next scan of this input is compared with it). **Rescan** of selected hosts updates their rows and keeps the changes, but is not a scan of the input. The last scan is only kept in memory, as long as the tab is open. The change is also in the search and the export (`Change`, `PreviousMACAddress`). + ### Row details Expand a row with the button in front of it to see all values of the host, or enable [Expand host view](#expand-host-view) to expand all hosts: diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index 3e125c6990..8770405dfd 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -102,6 +102,7 @@ Release date: **xx.xx.2026** - The settings of the port scan (**Ports**, **Timeout**, **Attempts**) are disabled while **Scan ports** is disabled, like the settings of the other probes. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) - The **Detect local subnet** button opens a menu: **Detect local subnet** (as before), **Subnet of network interface** to select the IPv4 subnet of a specific network interface (e.g. LAN, Wi-Fi, VPN, Hyper-V) and **All local subnets** to scan the subnets of all network interfaces. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) - IP addresses can be excluded from the scan with `!` in the host input (also in profiles and the run command), e.g. `10.0.0.0/24; !10.0.0.1; !10.0.0.200-254`. Works with IP addresses, ranges, subnets and hostnames (all IPv4 and IPv6 addresses of the hostname, nothing is scanned if the hostname can't be resolved); a message is shown if every IP address is excluded. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) +- A scan of the same input as the last completed scan in the tab shows what changed: hosts that are **New** (up now, not before), **Gone** (up before, not now - added as down, also without **Show unreachable**) and **MAC changed** (another MAC address, tooltip with the previous one) in the new **Change** column, counters next to the **Result** header (e.g. `2 New · 1 Gone`) and **Last scan at** with the time. Kept in memory while the tab is open; also in the search and the export (`Change`, `PreviousMACAddress`). [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) **Port Scanner** From e9d0f567aeba2033d200b18c0f077ca918959815 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Sun, 1 Nov 2026 02:04:14 +0100 Subject: [PATCH 092/131] Port Scanner: Changes since the last scan (E1) A completed scan of the same hosts and (resolved) ports as the last completed scan in the tab is compared with its open ports: Now open / No longer open in the Change column, counters and "Last scan at" in the header. The open ports of the last scan are always reported by the scanner (ReportPorts), so a port that is no longer open has a row with its state also without "Show closed ports"; a port of a host that was not scanned gets a timed out row. Search and export (Change). PortScannerScanComparer with tests. Co-Authored-By: Claude Opus 5.5 --- .../PortScannerPortChangeToStringConverter.cs | 55 +++++++ .../Resources/Strings.Designer.cs | 36 +++++ .../Resources/Strings.resx | 12 ++ .../Export/ExportManager.PortScannerInfo.cs | 18 ++- .../Network/PortScanner.cs | 3 +- .../Network/PortScannerOptions.cs | 7 + .../Network/PortScannerPortChange.cs | 23 +++ .../Network/PortScannerPortInfo.cs | 17 ++ .../Network/PortScannerScanComparer.cs | 72 +++++++++ .../Network/PortScannerScanComparerTests.cs | 74 +++++++++ .../ViewModels/PortScannerViewModel.cs | 151 +++++++++++++++++- .../NETworkManager/Views/PortScannerView.xaml | 88 ++++++++++ Website/docs/application/port-scanner.md | 6 + Website/docs/changelog/next-release.md | 1 + 14 files changed, 556 insertions(+), 7 deletions(-) create mode 100644 Source/NETworkManager.Converters/PortScannerPortChangeToStringConverter.cs create mode 100644 Source/NETworkManager.Models/Network/PortScannerPortChange.cs create mode 100644 Source/NETworkManager.Models/Network/PortScannerScanComparer.cs create mode 100644 Source/NETworkManager.Tests/Network/PortScannerScanComparerTests.cs diff --git a/Source/NETworkManager.Converters/PortScannerPortChangeToStringConverter.cs b/Source/NETworkManager.Converters/PortScannerPortChangeToStringConverter.cs new file mode 100644 index 0000000000..ebebccafc6 --- /dev/null +++ b/Source/NETworkManager.Converters/PortScannerPortChangeToStringConverter.cs @@ -0,0 +1,55 @@ +using System; +using System.Globalization; +using System.Windows.Data; +using NETworkManager.Localization.Resources; +using NETworkManager.Models.Network; + +namespace NETworkManager.Converters; + +/// +/// Convert the of a port (since the previous scan) to a translated +/// (e.g. "Now open"). +/// +public sealed class PortScannerPortChangeToStringConverter : IValueConverter +{ + /// + /// Convert the of a port to a translated . + /// + /// Change of the port as . + /// + /// + /// + /// Translated change, empty if there is none. + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + return value is PortScannerPortChange change ? ToLocalizedString(change) : string.Empty; + } + + /// + /// Gets the translated change of a port. + /// + /// Change of the port. + /// Translated change, empty for . + public static string ToLocalizedString(PortScannerPortChange change) + { + return change switch + { + PortScannerPortChange.Opened => Strings.PortChange_Opened, + PortScannerPortChange.Closed => Strings.PortChange_Closed, + _ => string.Empty + }; + } + + /// + /// !!! Method not implemented !!! + /// + /// + /// + /// + /// + /// + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} diff --git a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs index f5b43d39a3..9e30c19adb 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs +++ b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs @@ -9517,6 +9517,24 @@ public static string Portable { } } + /// + /// Looks up a localized string similar to No longer open. + /// + public static string PortChange_Closed { + get { + return ResourceManager.GetString("PortChange_Closed", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Now open. + /// + public static string PortChange_Opened { + get { + return ResourceManager.GetString("PortChange_Opened", resourceCulture); + } + } + /// /// Looks up a localized string similar to Port description. /// @@ -15397,6 +15415,24 @@ public static string XNew { } } + /// + /// Looks up a localized string similar to {0} No longer open. + /// + public static string XNoLongerOpen { + get { + return ResourceManager.GetString("XNoLongerOpen", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0} Now open. + /// + public static string XNowOpen { + get { + return ResourceManager.GetString("XNowOpen", resourceCulture); + } + } + /// /// Looks up a localized string similar to {0} Open. /// diff --git a/Source/NETworkManager.Localization/Resources/Strings.resx b/Source/NETworkManager.Localization/Resources/Strings.resx index afab4a725e..a684883a48 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.resx +++ b/Source/NETworkManager.Localization/Resources/Strings.resx @@ -1342,6 +1342,18 @@ Profile files are not affected! Previous MAC address: {0} + + Now open + + + No longer open + + + {0} Now open + + + {0} No longer open + Change Master Password... diff --git a/Source/NETworkManager.Models/Export/ExportManager.PortScannerInfo.cs b/Source/NETworkManager.Models/Export/ExportManager.PortScannerInfo.cs index 3306d06d56..2f34044de8 100644 --- a/Source/NETworkManager.Models/Export/ExportManager.PortScannerInfo.cs +++ b/Source/NETworkManager.Models/Export/ExportManager.PortScannerInfo.cs @@ -49,11 +49,11 @@ private static void CreateCsv(IEnumerable collection, strin var stringBuilder = new StringBuilder(); stringBuilder.AppendLine( - $"{nameof(PortScannerPortInfo.IPAddress)},{nameof(PortScannerPortInfo.Hostname)},{nameof(PortScannerPortInfo.Port)},{nameof(PortLookupInfo.Protocol)},{nameof(PortLookupInfo.Service)},{nameof(PortLookupInfo.Description)},{nameof(PortScannerPortInfo.State)}"); + $"{nameof(PortScannerPortInfo.IPAddress)},{nameof(PortScannerPortInfo.Hostname)},{nameof(PortScannerPortInfo.Port)},{nameof(PortLookupInfo.Protocol)},{nameof(PortLookupInfo.Service)},{nameof(PortLookupInfo.Description)},{nameof(PortScannerPortInfo.State)},{nameof(PortScannerPortInfo.Change)}"); foreach (var info in collection) stringBuilder.AppendLine( - $"{info.IPAddress},{CsvHelper.QuoteString(info.Hostname)},{info.Port},{info.LookupInfo.Protocol},{CsvHelper.QuoteString(info.LookupInfo.Service)},{CsvHelper.QuoteString(info.LookupInfo.Description)},{info.State}"); + $"{info.IPAddress},{CsvHelper.QuoteString(info.Hostname)},{info.Port},{info.LookupInfo.Protocol},{CsvHelper.QuoteString(info.LookupInfo.Service)},{CsvHelper.QuoteString(info.LookupInfo.Description)},{info.State},{GetChange(info)}"); File.WriteAllText(filePath, stringBuilder.ToString()); } @@ -77,7 +77,8 @@ from info in collection new XElement(nameof(PortLookupInfo.Protocol), info.LookupInfo.Protocol), new XElement(nameof(PortLookupInfo.Service), info.LookupInfo.Service), new XElement(nameof(PortLookupInfo.Description), info.LookupInfo.Description), - new XElement(nameof(PortScannerPortInfo.State), info.State))))); + new XElement(nameof(PortScannerPortInfo.State), info.State), + new XElement(nameof(PortScannerPortInfo.Change), GetChange(info)))))); document.Save(filePath); } @@ -100,9 +101,18 @@ private static void CreateJson(IReadOnlyList collection, st Protocol = collection[i].LookupInfo.Protocol.ToString(), collection[i].LookupInfo.Service, collection[i].LookupInfo.Description, - State = collection[i].State.ToString() + State = collection[i].State.ToString(), + Change = GetChange(collection[i]) }; File.WriteAllText(filePath, JsonConvert.SerializeObject(jsonData, Formatting.Indented)); } + + /// + /// Gets the change of a port since the previous scan, empty without a previous scan / change. + /// + private static string GetChange(PortScannerPortInfo info) + { + return info.Change == PortScannerPortChange.None ? string.Empty : info.Change.ToString(); + } } \ No newline at end of file diff --git a/Source/NETworkManager.Models/Network/PortScanner.cs b/Source/NETworkManager.Models/Network/PortScanner.cs index 370ac31b5e..dc37b928ac 100644 --- a/Source/NETworkManager.Models/Network/PortScanner.cs +++ b/Source/NETworkManager.Models/Network/PortScanner.cs @@ -291,7 +291,8 @@ await Parallel.ForEachAsync(ports, portParallelOptions, async (port, portCt) => } } - if (_options.ShowAllResults || portState == PortState.Open) + if (_options.ShowAllResults || portState == PortState.Open || + _options.ReportPorts?.Contains((host.ipAddress, port)) == true) OnPortScanned(new PortScannerPortScannedArgs( new PortScannerPortInfo(host.ipAddress, hostname, port, PortLookup.LookupByPortAndProtocol(port), portState))); diff --git a/Source/NETworkManager.Models/Network/PortScannerOptions.cs b/Source/NETworkManager.Models/Network/PortScannerOptions.cs index ce447114d9..599c710263 100644 --- a/Source/NETworkManager.Models/Network/PortScannerOptions.cs +++ b/Source/NETworkManager.Models/Network/PortScannerOptions.cs @@ -63,4 +63,11 @@ public record PortScannerOptions /// Include closed ports in the result. /// public required bool ShowAllResults { get; init; } + + /// + /// Ports that are reported in any state, also if is disabled: the open ports of + /// the previous scan, so a port that is no longer open has a row with its state (see + /// ). Null for none. + /// + public System.Collections.Generic.IReadOnlySet<(System.Net.IPAddress IPAddress, int Port)> ReportPorts { get; init; } } diff --git a/Source/NETworkManager.Models/Network/PortScannerPortChange.cs b/Source/NETworkManager.Models/Network/PortScannerPortChange.cs new file mode 100644 index 0000000000..45758db1a9 --- /dev/null +++ b/Source/NETworkManager.Models/Network/PortScannerPortChange.cs @@ -0,0 +1,23 @@ +namespace NETworkManager.Models.Network; + +/// +/// Change of a port since the previous scan of the same hosts and ports in the same tab (see +/// ). +/// +public enum PortScannerPortChange +{ + /// + /// No change, or there is no previous scan to compare with. + /// + None, + + /// + /// The port is open now, it was not open in the previous scan. + /// + Opened, + + /// + /// The port was open in the previous scan, it is not open now (closed, timed out or the host was not scanned). + /// + Closed +} diff --git a/Source/NETworkManager.Models/Network/PortScannerPortInfo.cs b/Source/NETworkManager.Models/Network/PortScannerPortInfo.cs index c6eb3b7454..9ebf5fae44 100644 --- a/Source/NETworkManager.Models/Network/PortScannerPortInfo.cs +++ b/Source/NETworkManager.Models/Network/PortScannerPortInfo.cs @@ -39,4 +39,21 @@ public PortScannerPortInfo(IPAddress ipAddress, string hostname, int port, PortL /// hostname (e.g. a generic PTR record of a provider). /// public string HostAsString => string.IsNullOrEmpty(Hostname) ? IPAddress.ToString() : $"{Hostname} ({IPAddress})"; + + /// + /// Change since the previous scan of the same hosts and ports (set by the view model after the scan, see + /// ). + /// + public PortScannerPortChange Change + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } } \ No newline at end of file diff --git a/Source/NETworkManager.Models/Network/PortScannerScanComparer.cs b/Source/NETworkManager.Models/Network/PortScannerScanComparer.cs new file mode 100644 index 0000000000..0739677562 --- /dev/null +++ b/Source/NETworkManager.Models/Network/PortScannerScanComparer.cs @@ -0,0 +1,72 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net; + +namespace NETworkManager.Models.Network; + +/// +/// Result of . +/// +/// Ports that are open now and were not open in the previous scan. +/// Ports of the result that were open in the previous scan and are not open now. +/// +/// Ports that were open in the previous scan and are not in the result (e.g. the host didn't answer the ping +/// before the scan), with the hostname of the previous scan. +/// +public sealed record PortScannerComparison( + IReadOnlySet<(IPAddress IPAddress, int Port)> Opened, + IReadOnlySet<(IPAddress IPAddress, int Port)> Closed, + IReadOnlyList<(IPAddress IPAddress, int Port, string Hostname)> Missing); + +/// +/// Compares a port scan with the previous scan of the same hosts and ports: which ports are open now or no longer +/// open. Only the open ports of a scan are kept (closed ports may not be in the result). +/// +public static class PortScannerScanComparer +{ + /// + /// Creates the baseline of a completed scan: its open ports with the hostname. + /// + /// Ports of the result. + /// Open ports and their hostname. + public static Dictionary<(IPAddress IPAddress, int Port), string> CreateBaseline( + IEnumerable ports) + { + return ports.Where(x => x.State == PortState.Open) + .GroupBy(x => (x.IPAddress, x.Port)) + .ToDictionary(x => x.Key, x => x.First().Hostname ?? string.Empty); + } + + /// + /// Compares the result of a scan with the baseline of the previous one. + /// + /// Open ports of the previous scan (see ). + /// Ports of the current result. + /// Opened, closed and missing ports. + public static PortScannerComparison Compare(IReadOnlyDictionary<(IPAddress IPAddress, int Port), string> baseline, + IEnumerable ports) + { + var opened = new HashSet<(IPAddress, int)>(); + var closed = new HashSet<(IPAddress, int)>(); + var seen = new HashSet<(IPAddress, int)>(); + + foreach (var port in ports) + { + var key = (port.IPAddress, port.Port); + seen.Add(key); + + var wasOpen = baseline.ContainsKey(key); + + if (port.State == PortState.Open && !wasOpen) + opened.Add(key); + else if (port.State != PortState.Open && wasOpen) + closed.Add(key); + } + + var missing = baseline.Where(x => !seen.Contains(x.Key)) + .Select(x => (x.Key.IPAddress, x.Key.Port, x.Value)) + .ToList(); + + return new PortScannerComparison(opened, closed, missing); + } +} diff --git a/Source/NETworkManager.Tests/Network/PortScannerScanComparerTests.cs b/Source/NETworkManager.Tests/Network/PortScannerScanComparerTests.cs new file mode 100644 index 0000000000..1244abedd7 --- /dev/null +++ b/Source/NETworkManager.Tests/Network/PortScannerScanComparerTests.cs @@ -0,0 +1,74 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net; +using NETworkManager.Models.Lookup; +using NETworkManager.Models.Network; +using Xunit; + +namespace NETworkManager.Tests.Network; + +public class PortScannerScanComparerTests +{ + private static readonly IPAddress A = IPAddress.Parse("10.0.0.1"); + private static readonly IPAddress B = IPAddress.Parse("10.0.0.2"); + + private static PortScannerPortInfo Port(IPAddress ipAddress, int port, PortState state, string hostname = "host") => + new(ipAddress, hostname, port, new PortLookupInfo(port, TransportProtocol.Tcp), state); + + [Fact] + public void BaselineHasOnlyOpenPorts() + { + var baseline = PortScannerScanComparer.CreateBaseline([ + Port(A, 22, PortState.Open, "server"), Port(A, 80, PortState.Closed), Port(B, 443, PortState.TimedOut) + ]); + + Assert.Equal([(A, 22)], baseline.Keys); + Assert.Equal("server", baseline[(A, 22)]); + } + + [Fact] + public void SameOpenPortsHaveNoChange() + { + var baseline = PortScannerScanComparer.CreateBaseline([Port(A, 22, PortState.Open)]); + + var result = PortScannerScanComparer.Compare(baseline, [Port(A, 22, PortState.Open), Port(A, 80, PortState.Closed)]); + + Assert.Empty(result.Opened); + Assert.Empty(result.Closed); + Assert.Empty(result.Missing); + } + + [Fact] + public void PortOpenNowIsOpened() + { + var baseline = PortScannerScanComparer.CreateBaseline([Port(A, 22, PortState.Open)]); + + var result = PortScannerScanComparer.Compare(baseline, [Port(A, 22, PortState.Open), Port(A, 8000, PortState.Open)]); + + Assert.Equal([(A, 8000)], result.Opened); + } + + [Theory] + [InlineData(PortState.Closed)] + [InlineData(PortState.TimedOut)] + public void PortNotOpenNowIsClosed(PortState state) + { + var baseline = PortScannerScanComparer.CreateBaseline([Port(A, 3389, PortState.Open)]); + + var result = PortScannerScanComparer.Compare(baseline, [Port(A, 3389, state)]); + + Assert.Equal([(A, 3389)], result.Closed); + Assert.Empty(result.Missing); + } + + [Fact] + public void PortNotInTheResultIsMissing() + { + var baseline = PortScannerScanComparer.CreateBaseline([Port(B, 443, PortState.Open, "web")]); + + var result = PortScannerScanComparer.Compare(baseline, [Port(A, 22, PortState.Open)]); + + Assert.Equal([(B, 443, "web")], result.Missing); + Assert.Equal([(A, 22)], result.Opened); + } +} diff --git a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs index 92be2feaf4..f2ca4df16b 100644 --- a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs @@ -2,10 +2,12 @@ using MahApps.Metro.Controls; using MahApps.Metro.SimpleChildWindow; using NETworkManager.Controls; +using NETworkManager.Converters; using NETworkManager.Localization; using NETworkManager.Localization.Resources; using NETworkManager.Models; using NETworkManager.Models.Export; +using NETworkManager.Models.Lookup; using NETworkManager.Models.Network; using NETworkManager.Profiles; using NETworkManager.Settings; @@ -76,6 +78,14 @@ public class PortScannerViewModel : ViewModelBase, IProfileManagerMinimal // Ports of the last scan as entered (e.g. "22; 80; 443"), for a profile created from a result private string _lastPortsInput; + // Open ports of the last completed scan of this tab and its input (hosts + resolved ports): the next scan of the + // same input shows what changed (see CompareWithPreviousScan). Only in memory, gone when the tab is closed. + private Dictionary<(IPAddress IPAddress, int Port), string> _baseline; + private string _baselineInput; + + // Input of the running / last scan (not of a rescan), null until the ports are resolved + private string _scanInput; + // Message of the tab, shown until the first scan has started or the ports are changed (see constructor) private string _message; @@ -365,6 +375,71 @@ public int PortsTimedOut } } + /// + /// Gets or sets the number of ports that are open now and were not open in the previous scan (see ). + /// + public int PortsNowOpen + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } + + /// + /// Gets or sets the number of ports that are not open now and were open in the previous scan (see ). + /// + public int PortsNoLongerOpen + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } + + /// + /// Gets or sets a value indicating whether the shown result is compared with the previous scan of the same hosts + /// and ports (change column and counters). + /// + public bool HasComparison + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } + + /// + /// Gets or sets the time the last scan was completed, null before (or while a scan is running). + /// + public DateTime? LastScanTime + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } + /// /// Gets or sets a value indicating whether the scan is being prepared. /// @@ -529,7 +604,8 @@ public PortScannerViewModel(Guid tabId, string host, string port, bool start = t Matches(info.LookupInfo.Service) || Matches(info.LookupInfo.Description) || Matches(info.State.ToString()) || - Matches(ResourceTranslator.Translate(ResourceIdentifier.PortState, info.State)); + Matches(ResourceTranslator.Translate(ResourceIdentifier.PortState, info.State)) || + Matches(PortScannerPortChangeToStringConverter.ToLocalizedString(info.Change)); }; _searchDelay = new SearchDelay(GlobalStaticConfiguration.SearchDispatcherTimerTimeSpan, ApplySearch); @@ -897,6 +973,13 @@ private async Task Start() Results.Clear(); _portsNotOpenPerHost.Clear(); + // Compared with the previous scan of the same input when this scan is completed (see ScanComplete) + _scanInput = null; + HasComparison = false; + PortsNowOpen = 0; + PortsNoLongerOpen = 0; + LastScanTime = null; + // No results are shown until the scan's options are known - follow the current settings ScanOptions = CreateScanOptions(); @@ -975,7 +1058,16 @@ private async Task Start() _lastPorts = ports; _lastPortsInput = Ports; - StartScanner(ScanOptions, (portScanner, ct) => portScanner.ScanAsync(hosts.hosts, ports, ct)); + // The resolved ports: a changed port profile is another input + _scanInput = $"{Host?.Trim()}|{string.Join(";", ports)}"; + + // The open ports of the previous scan of this input are also reported if they are closed now, so they get a + // row with their state (not in ScanOptions: not for a rescan, not for the columns) + var options = IsComparedWithBaseline() + ? ScanOptions with { ReportPorts = _baseline.Keys.ToHashSet() } + : ScanOptions; + + StartScanner(options, (portScanner, ct) => portScanner.ScanAsync(hosts.hosts, ports, ct)); } /// @@ -1513,6 +1605,9 @@ private void ScanComplete(object sender, ScanCompleteArgs e) { Application.Current.Dispatcher.Invoke(() => { + // A rescan updates rows, it is not a scan of the input (see CompareWithPreviousScan) + var isRescan = _isRescan; + if (sender is PortScanner portScanner) { portScanner.PortScanned -= PortScanned; @@ -1619,6 +1714,10 @@ private void ScanComplete(object sender, ScanCompleteArgs e) AddStatusMessage(string.Format(Strings.XHostsDidNotAnswerThePingAndWereNotScanned, pingScanner.HostsSkipped)); + // Only a completed scan of the input is compared and becomes the baseline of the next one + if (!isRescan && !e.IsCanceled && e.Exception == null && _scanInput != null && PortsToScan > 0) + CompareWithPreviousScan(); + PreparingScan = false; IsCanceling = false; IsRunning = false; @@ -1628,5 +1727,53 @@ private void ScanComplete(object sender, ScanCompleteArgs e) }); } + /// + /// Checks if the running scan is compared with the previous scan (same input). + /// + private bool IsComparedWithBaseline() + { + return _baseline != null && string.Equals(_baselineInput, _scanInput, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Compares the completed scan with the previous completed scan of the same hosts and ports in this tab: marks + /// the ports that are open now / no longer open, adds a row for a port that was open and is not in the result + /// (e.g. the host didn't answer the ping before the scan). The scan becomes the baseline of the next one. Called on + /// the UI thread. + /// + private void CompareWithPreviousScan() + { + if (IsComparedWithBaseline()) + { + var comparison = PortScannerScanComparer.Compare(_baseline, Results); + + foreach (var row in Results) + { + var key = (row.IPAddress, row.Port); + + if (comparison.Opened.Contains(key)) + row.Change = PortScannerPortChange.Opened; + else if (comparison.Closed.Contains(key)) + row.Change = PortScannerPortChange.Closed; + } + + foreach (var (ipAddress, port, hostname) in comparison.Missing) + Results.Add(new PortScannerPortInfo(ipAddress, hostname, port, PortLookup.LookupByPortAndProtocol(port), + PortState.TimedOut) { Change = PortScannerPortChange.Closed }); + + PortsNowOpen = comparison.Opened.Count; + PortsNoLongerOpen = comparison.Closed.Count + comparison.Missing.Count; + HasComparison = true; + + // The view isn't filtered live (see the constructor) + if (!string.IsNullOrEmpty(Search)) + ResultsView.Refresh(); + } + + _baseline = PortScannerScanComparer.CreateBaseline(Results); + _baselineInput = _scanInput; + LastScanTime = DateTime.Now; + } + #endregion } \ No newline at end of file diff --git a/Source/NETworkManager/Views/PortScannerView.xaml b/Source/NETworkManager/Views/PortScannerView.xaml index 9eaab48e48..6c458919c7 100644 --- a/Source/NETworkManager/Views/PortScannerView.xaml +++ b/Source/NETworkManager/Views/PortScannerView.xaml @@ -13,6 +13,8 @@ xmlns:controls="clr-namespace:NETworkManager.Controls;assembly=NETworkManager.Controls" xmlns:wpfHelpers="clr-namespace:NETworkManager.Utilities.WPF;assembly=NETworkManager.Utilities.WPF" xmlns:models="clr-namespace:NETworkManager.Models;assembly=NETworkManager.Models" + xmlns:network="clr-namespace:NETworkManager.Models.Network;assembly=NETworkManager.Models" + xmlns:internalControls="clr-namespace:NETworkManager.Controls" Loaded="UserControl_Loaded" mc:Ignorable="d" d:DataContext="{d:DesignInstance viewModels:PortScannerViewModel}"> @@ -27,6 +29,7 @@ + @@ -253,7 +256,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + all exports the filtered results; the counters always count all ports. +### Changes since the last scan + +A scan of the same hosts and ports as the last completed scan in the tab shows what changed since then in the **Change** column: **Now open** (the port is open now, it was not open in the last scan) and **No longer open** (it was open in the last scan, it is closed or timed out now). A port that is no longer open is shown with its status, also if [Show closed ports](#show-closed-ports) is disabled; if its host was not scanned (e.g. it didn't answer the [ping before the scan](#ping-hosts-before-the-scan)), it is shown as timed out. The counters next to the **Result** header (e.g. `1 Now open · 2 No longer open`) and the column are only shown if the result is compared with a previous scan. **Last scan at** shows when the last scan was completed (the tooltip shows the date). + +Only completed scans are compared: a canceled scan or a scan with other hosts or ports (also a changed port profile) starts over. **Rescan** updates the rows; a rescanned host loses its changes (its rows are replaced). The last scan is only kept in memory, as long as the tab is open. The change is also in the search and the export (`Change`). + ### Keyboard shortcuts | Key | Action | diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index 8770405dfd..d3dcd19aad 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -133,6 +133,7 @@ Release date: **xx.xx.2026** - The export runs in the background, so the window stays responsive while a large result is exported, and uses the order shown in the result (sorting, grouping) instead of the order in which the ports were scanned. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) - Ports are started one after another (every 1 ms, shorter with short timeouts and many threads) instead of all at the same moment. This avoids bursts of hundreds of connection requests within a few milliseconds and makes the progress smoother. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) - IP addresses can be excluded from the scan with `!` in the host input (also in profiles and the run command), e.g. `10.0.0.0/24; !10.0.0.1; !10.0.0.200-254`. Works with IP addresses, ranges, subnets and hostnames (all IPv4 and IPv6 addresses of the hostname, nothing is scanned if the hostname can't be resolved); a message is shown if every IP address is excluded. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) +- A scan of the same hosts and ports as the last completed scan in the tab shows what changed in the new **Change** column: **Now open** and **No longer open** (shown with its status, also without **Show closed ports**), counters next to the **Result** header (e.g. `1 Now open · 2 No longer open`) and **Last scan at** with the time. Kept in memory while the tab is open; also in the search and the export (`Change`). [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) **Ping Monitor** From 52ae9d778331bb313ebc18d02d42d10ab31c785d Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Sun, 1 Nov 2026 02:08:42 +0100 Subject: [PATCH 093/131] IP Scanner: Repeat the scan automatically (E2) "Automatically scan every" (1 min - 1 h, AutoRefreshTime.GetScanDefaults) in the result header with the shared AutoRefreshTimer / AutoRefreshControl and a countdown ("Next scan in"). Starts after a completed scan of the input, the interval is the pause after a scan (the timer waits for ScanComplete). A repeated scan keeps the rows: results are copied into the row of the host (every host is reported, so a host that is down now updates its row), new hosts are added, a canceled / failed repeat restores the rows. At the end the comparison marks the changes, down rows without a change are removed (unless unreachable hosts are shown). No confirmation, no history entry. Stops after a cancel / error, a changed input or when the tab is closed; a manual scan starts the interval again after it. Keeps running while hidden. The checkbox is per tab, the interval is saved (IPScanner_AutoRefreshTime). Co-Authored-By: Claude Opus 5.5 --- .../Resources/Strings.Designer.cs | 18 ++ .../Resources/Strings.resx | 6 + .../GlobalStaticConfiguration.cs | 3 + .../NETworkManager.Settings/SettingsInfo.cs | 16 ++ .../AutoRefreshTime.cs | 13 + .../ViewModels/IPScannerViewModel.cs | 249 +++++++++++++++++- .../NETworkManager/Views/IPScannerView.xaml | 8 + Website/docs/application/ip-scanner.md | 8 + Website/docs/changelog/next-release.md | 1 + 9 files changed, 309 insertions(+), 13 deletions(-) diff --git a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs index 9e30c19adb..05f7985b58 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs +++ b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs @@ -1392,6 +1392,15 @@ public static string Authorities { } } + /// + /// Looks up a localized string similar to Automatically scan every. + /// + public static string AutomaticallyScanEvery { + get { + return ResourceManager.GetString("AutomaticallyScanEvery", resourceCulture); + } + } + /// /// Looks up a localized string similar to Automatically update every. /// @@ -8553,6 +8562,15 @@ public static string NextRefreshInX { } } + /// + /// Looks up a localized string similar to Next scan in {0}. + /// + public static string NextScanInX { + get { + return ResourceManager.GetString("NextScanInX", resourceCulture); + } + } + /// /// Looks up a localized string similar to No. /// diff --git a/Source/NETworkManager.Localization/Resources/Strings.resx b/Source/NETworkManager.Localization/Resources/Strings.resx index a684883a48..5a495617ad 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.resx +++ b/Source/NETworkManager.Localization/Resources/Strings.resx @@ -4036,6 +4036,12 @@ Right-click for more options. Next refresh in {0} + + Next scan in {0} + + + Automatically scan every + Failed to load hosts file: {0} diff --git a/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs b/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs index a663707bb0..de308ca877 100644 --- a/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs +++ b/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs @@ -174,6 +174,9 @@ public static class GlobalStaticConfiguration public static string IPScanner_SNMPCommunity => "public"; public static int IPScanner_SNMPTimeout => 2000; public static bool IPScanner_ExpandHostView => false; + + public static AutoRefreshTimeInfo IPScanner_AutoRefreshTime => + AutoRefreshTime.GetScanDefaults.First(x => x.Value == 5 && x.TimeUnit == TimeUnit.Minute); public static ExportFileType IPScanner_ExportFileType => ExportFileType.Csv; // Larger scans need a confirmation, even larger ones are refused (every IP address / port is kept in memory) public static long IPScanner_ConfirmHostCount => 65536; diff --git a/Source/NETworkManager.Settings/SettingsInfo.cs b/Source/NETworkManager.Settings/SettingsInfo.cs index e087e97840..d0032d4186 100644 --- a/Source/NETworkManager.Settings/SettingsInfo.cs +++ b/Source/NETworkManager.Settings/SettingsInfo.cs @@ -1220,6 +1220,22 @@ public bool IPScanner_ExpandHostView } } = GlobalStaticConfiguration.IPScanner_ExpandHostView; + /// + /// Interval of "Automatically scan every" (the checkbox is per tab and not saved, see the view model). + /// + public AutoRefreshTimeInfo IPScanner_AutoRefreshTime + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } = GlobalStaticConfiguration.IPScanner_AutoRefreshTime; + public bool IPScanner_ICMPEnabled { get; diff --git a/Source/NETworkManager.Utilities/AutoRefreshTime.cs b/Source/NETworkManager.Utilities/AutoRefreshTime.cs index dd8897e832..a7c514ae91 100644 --- a/Source/NETworkManager.Utilities/AutoRefreshTime.cs +++ b/Source/NETworkManager.Utilities/AutoRefreshTime.cs @@ -20,6 +20,19 @@ public static class AutoRefreshTime new(5, TimeUnit.Minute) }; + /// + /// Method returns a list with s to repeat a scan (IP Scanner, Port Scanner): a + /// scan takes seconds to minutes, the interval is the pause after it. + /// + public static IEnumerable GetScanDefaults => new List + { + new(1, TimeUnit.Minute), + new(5, TimeUnit.Minute), + new(15, TimeUnit.Minute), + new(30, TimeUnit.Minute), + new(1, TimeUnit.Hour) + }; + /// /// Method to calculate a based on . /// diff --git a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs index bcd7e42d30..eb5ad1da61 100644 --- a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs @@ -68,6 +68,22 @@ public class IPScannerViewModel : ViewModelBase, IProfileManagerMinimal // Input of the running / last scan (not of a rescan of selected hosts) private string _scanInput; + // Repeat of the scan ("Automatically scan every"): the input of the last completed scan, null if the scan is not + // repeated (until the next completed scan: after a manual start, a cancel, an error or a changed input) + private string _repeatInput; + private readonly AutoRefreshTimer _autoRefreshTimer; + + // Completed by ScanComplete, so the repeat timer waits for the end of the scan it started + private TaskCompletionSource _scanCompletion; + + // Repeated scan: the rows of the last scan stay and are updated in place (selection, scroll position and expanded + // details stay) - row per IP address, new results are copied into it (_repeatResults: result -> row), new hosts + // are added (_repeatAdded). The values before the scan are restored if it is canceled or fails. + private Dictionary _repeatRows; + private readonly Dictionary _repeatResults = []; + private readonly List _repeatAdded = []; + private Dictionary _repeatSnapshots; + // Rescan of shown hosts (see RescanSelectedHosts), null if no rescan is running: row per rescanned IP address // (every IP address is scanned once, see HostRangeHelper.ResolveAsync). The scanner creates new results - their // values are copied into the existing rows (selection and expanded details stay), the updates of a new result are @@ -106,6 +122,15 @@ public string Host return; field = value; + + // Another input is not repeated (until it was scanned) + if (_repeatInput != null && + !string.Equals(value?.Trim(), _repeatInput, StringComparison.OrdinalIgnoreCase)) + { + _repeatInput = null; + UpdateRepeat(); + } + OnPropertyChanged(); } } @@ -415,6 +440,59 @@ public DateTime? LastScanTime } } + /// + /// Gets or sets a value indicating whether the scan is repeated automatically after the interval (per tab and not + /// saved: a new tab or a profile doesn't start to repeat). Starts with the next completed scan. + /// + public bool AutoRefreshEnabled + { + get; + set + { + if (value == field) + return; + + field = value; + UpdateRepeat(); + OnPropertyChanged(); + } + } + + /// + /// Gets the intervals to repeat the scan. + /// + public ICollectionView AutoRefreshTimes { get; } + + /// + /// Gets or sets the interval to repeat the scan (the pause after a scan, saved for all tabs). + /// + public AutoRefreshTimeInfo SelectedAutoRefreshTime + { + get; + set + { + if (value == field) + return; + + field = value; + + // Not in the list (e.g. a removed interval of an old settings file): the previous interval is kept + if (value != null) + { + SettingsManager.Current.IPScanner_AutoRefreshTime = value; + _autoRefreshTimer.Interval = AutoRefreshTime.CalculateTimeSpan(value); + } + + OnPropertyChanged(); + } + } + + /// + /// Gets the timer of the repeated scan, for the countdown in the header (see + /// ). + /// + public AutoRefreshTimer AutoRefreshTimer => _autoRefreshTimer; + /// /// Gets or sets a value indicating whether the scan is being prepared. /// @@ -507,6 +585,15 @@ private set /// Starts it on the first load. If false, the input is only filled in. public IPScannerViewModel(Guid tabId, string hostOrIPRange, bool start = true) { + // Repeat of the scan (before Host is set, see its setter): not while a scan or rescan is running + _autoRefreshTimer = new AutoRefreshTimer(RepeatScanAsync, () => !IsRunning); + + AutoRefreshTimes = CollectionViewSource.GetDefaultView(AutoRefreshTime.GetScanDefaults); + SelectedAutoRefreshTime = AutoRefreshTimes.SourceCollection.Cast().FirstOrDefault(x => + x.Value == SettingsManager.Current.IPScanner_AutoRefreshTime.Value && + x.TimeUnit == SettingsManager.Current.IPScanner_AutoRefreshTime.TimeUnit) ?? + AutoRefreshTimes.SourceCollection.Cast().First(); + _firstLoad = start; ConfigurationManager.Current.IPScannerTabCount++; @@ -864,7 +951,7 @@ private void ExportAction() /// /// Starts the IP scan. /// - private async Task Start() + private async Task Start(bool isRepeat = false) { // A host is only found if it answers a probe - without ping, port scan, NetBIOS and SNMP every host is // unreachable (ARP / NDP alone isn't enough: the neighbor entry is only created by the packets of the probes) @@ -904,7 +991,8 @@ private async Task Start() return; } - if (hostCount > GlobalStaticConfiguration.IPScanner_ConfirmHostCount) + // Confirmed by the first scan, not asked again when the scan is repeated + if (!isRepeat && hostCount > GlobalStaticConfiguration.IPScanner_ConfirmHostCount) { var window = Application.Current.Windows.OfType().FirstOrDefault(x => x.IsActive) ?? Application.Current.MainWindow; @@ -930,15 +1018,31 @@ private async Task Start() _resultsBuffer.Clear(); } - Results.Clear(); + if (isRepeat) + { + // The rows of the last scan stay (and its changes until the scan is completed), see ApplyRepeatResult + _repeatRows = Results.DistinctBy(x => x.IPAddress).ToDictionary(x => x.IPAddress); + _repeatResults.Clear(); + _repeatAdded.Clear(); + _repeatSnapshots = _repeatRows.Values.ToDictionary(x => x, x => x.Clone()); + } + else + { + Results.Clear(); + + // Repeated from the end of this scan (see ScanComplete) + _repeatInput = null; + UpdateRepeat(); + + HasComparison = false; + HostsNew = 0; + HostsGone = 0; + HostsMACChanged = 0; + LastScanTime = null; + } // Compared with the previous scan of the same input when this scan is completed (see ScanComplete) _scanInput = Host?.Trim() ?? string.Empty; - HasComparison = false; - HostsNew = 0; - HostsGone = 0; - HostsMACChanged = 0; - LastScanTime = null; // No results are shown until the scan's options are known - follow the current settings ScanOptions = CreateResultOptions(); @@ -1009,7 +1113,8 @@ private async Task Start() PreparingScan = false; // Add host(s) to the history - ListHelper.Modify(SettingsManager.Current.IPScanner_HostHistory, Host, + if (!isRepeat) + ListHelper.Modify(SettingsManager.Current.IPScanner_HostHistory, Host, SettingsManager.Current.General_HistoryListEntries); var scanOptions = CreateScanOptions(ports, webPagePorts); @@ -1018,7 +1123,8 @@ private async Task Start() _lastScanOptions = scanOptions; - StartScanner(scanOptions, hosts.hosts); + // A repeated scan reports every host, so the row of a host that is down now is updated (see ApplyRepeatResult) + StartScanner(isRepeat ? scanOptions with { ReportAllHosts = true } : scanOptions, hosts.hosts); } /// @@ -1424,6 +1530,10 @@ public void OnClose() _closed = true; + // No repeated scan in the background + UpdateRepeat(); + _scanCompletion?.TrySetResult(); + SettingsManager.Current.PropertyChanged -= SettingsManager_PropertyChanged; // Stop scan @@ -1553,6 +1663,8 @@ private void FlushResultsBuffer() { if (_rescanRows != null) ApplyRescanResult(host, update); + else if (_repeatRows != null) + ApplyRepeatResult(host, update); else if (update == null) Results.Add(host); else @@ -1560,6 +1672,36 @@ private void FlushResultsBuffer() } } + /// + /// Applies a result of a repeated scan (see ): the row of the host is updated in place, a host + /// without a row is added if it is up (or unreachable hosts are shown). Every host is reported, also if it is down + /// now - its row is updated, other down hosts are ignored. + /// + /// Result of the scan. + /// Update of the result, null if the host was found. + private void ApplyRepeatResult(IPScannerHostInfo host, IPScannerHostUpdatedArgs update) + { + if (update == null) + { + if (_repeatRows.TryGetValue(host.IPAddress, out var row)) + { + row.CopyFrom(host); + _repeatResults[host] = row; + } + else if (host.IsReachable || _lastScanOptions.ShowAllResults) + { + Results.Add(host); + _repeatResults[host] = host; + _repeatAdded.Add(host); + } + + return; + } + + if (_repeatResults.TryGetValue(update.Host, out var target)) + update.ApplyTo(target); + } + /// /// Applies a buffered result of a rescan to the existing rows: a found host is copied into the row of its IP /// address, its updates are applied to this row. @@ -1665,6 +1807,27 @@ private void ScanComplete(object sender, ScanCompleteArgs e) row.CopyFrom(snapshot); } + // Canceled / failed repeated scan: the rows of the last scan with their values (before the counters are + // computed from them) + var isRepeat = _repeatRows != null; + + if (isRepeat && (e.IsCanceled || e.Exception != null)) + { + foreach (var (row, snapshot) in _repeatSnapshots) + row.CopyFrom(snapshot); + + foreach (var added in _repeatAdded) + Results.Remove(added); + } + + if (isRepeat) + { + _repeatRows = null; + _repeatResults.Clear(); + _repeatAdded.Clear(); + _repeatSnapshots = null; + } + // Canceled / failed: results of running lookups never arrive - no "..." for them (and Copy works) if (e.IsCanceled || e.Exception != null) { @@ -1714,9 +1877,18 @@ private void ScanComplete(object sender, ScanCompleteArgs e) AddStatusMessage(Strings.NoReachableHostsFound); } - // Only a completed scan of the input is compared and becomes the baseline of the next one - if (!isRescan && !e.IsCanceled && e.Exception == null && HostsToScan > 0) - CompareWithPreviousScan(); + // Only a completed scan of the input is compared and becomes the baseline of the next one - and is repeated + // (not after a cancel or an error, so a failing scan doesn't repeat) + if (!isRescan) + { + var isCompleted = !e.IsCanceled && e.Exception == null && HostsToScan > 0; + + if (isCompleted) + CompareWithPreviousScan(); + + _repeatInput = isCompleted ? _scanInput : null; + UpdateRepeat(); + } PreparingScan = false; IsCanceling = false; @@ -1724,9 +1896,45 @@ private void ScanComplete(object sender, ScanCompleteArgs e) // E.g. canceled during the preparation, or nothing found UpdateScanOptionsIfNoResults(); + + // The repeat timer waits for the end of the scan (see RepeatScanAsync) + _scanCompletion?.TrySetResult(); + _scanCompletion = null; }); } + /// + /// Starts or stops the timer of the repeated scan: runs while "Automatically scan every" is enabled and the last + /// scan of the input was completed. + /// + private void UpdateRepeat() + { + if (_autoRefreshTimer != null) + _autoRefreshTimer.IsEnabled = AutoRefreshEnabled && _repeatInput != null && !_closed; + } + + /// + /// Repeats the scan (tick of the repeat timer) and waits until it is finished, so the interval starts after it. + /// + private async Task RepeatScanAsync() + { + _scanCompletion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + await Start(isRepeat: true); + + // Not started (e.g. no probe enabled, invalid input - the message is shown): not repeated + if (!IsRunning && _scanCompletion != null) + { + _repeatInput = null; + UpdateRepeat(); + + _scanCompletion.TrySetResult(); + } + + if (_scanCompletion != null) + await _scanCompletion.Task; + } + /// /// Compares the completed scan with the previous completed scan of the same input in this tab: marks new hosts /// and hosts with another MAC address, adds the hosts that are gone (shown as down, also without "Show @@ -1734,6 +1942,13 @@ private void ScanComplete(object sender, ScanCompleteArgs e) /// private void CompareWithPreviousScan() { + // The changes of the last comparison (rows of a repeated scan) + foreach (var host in Results) + { + host.Change = IPScannerHostChange.None; + host.PreviousMACAddress = string.Empty; + } + var current = IPScannerScanComparer.CreateBaseline(Results); if (_baseline != null && string.Equals(_baselineInput, _scanInput, StringComparison.OrdinalIgnoreCase)) @@ -1766,6 +1981,14 @@ private void CompareWithPreviousScan() HasComparison = true; } + // A repeated scan updated the rows of hosts that are down now: only shown if they are gone (or unreachable hosts + // are shown) + if (!_lastScanOptions.ShowAllResults) + { + foreach (var host in Results.Where(x => !x.IsReachable && x.Change != IPScannerHostChange.Gone).ToList()) + Results.Remove(host); + } + _baseline = current; _baselineInput = _scanInput; LastScanTime = DateTime.Now; diff --git a/Source/NETworkManager/Views/IPScannerView.xaml b/Source/NETworkManager/Views/IPScannerView.xaml index 399552a296..8791bcb16f 100644 --- a/Source/NETworkManager/Views/IPScannerView.xaml +++ b/Source/NETworkManager/Views/IPScannerView.xaml @@ -390,10 +390,18 @@ + + Date: Sun, 1 Nov 2026 02:11:08 +0100 Subject: [PATCH 094/131] Port Scanner: Repeat the scan automatically (E2) Like the IP Scanner: "Automatically scan every" with countdown, starts after a completed scan, the interval is the pause after a scan. A repeated scan keeps the rows: the scanner reports every port with a row (ReportPorts), its state is updated in place (with the open ports of the host group), new open ports are added, a port of a host that was not scanned becomes timed out. A canceled / failed repeat restores the states. Ports that are not open without a change are removed at the end (unless closed ports are shown). No confirmation, no history entry. Interval saved (PortScanner_AutoRefreshTime). Co-Authored-By: Claude Opus 5.5 --- .../GlobalStaticConfiguration.cs | 3 + .../NETworkManager.Settings/SettingsInfo.cs | 16 + .../ViewModels/PortScannerViewModel.cs | 322 ++++++++++++++++-- .../NETworkManager/Views/PortScannerView.xaml | 8 + Website/docs/application/port-scanner.md | 8 + Website/docs/changelog/next-release.md | 1 + 6 files changed, 334 insertions(+), 24 deletions(-) diff --git a/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs b/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs index de308ca877..65d232fd67 100644 --- a/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs +++ b/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs @@ -191,6 +191,9 @@ public static class GlobalStaticConfiguration // local (ephemeral) ports of Windows (16,384), like the maximum of the IP Scanner (256 x 32) public static int PortScanner_MaxConnections => 8192; public static int PortScanner_Timeout => 2000; + + public static AutoRefreshTimeInfo PortScanner_AutoRefreshTime => + AutoRefreshTime.GetScanDefaults.First(x => x.Value == 5 && x.TimeUnit == TimeUnit.Minute); public static int PortScanner_Attempts => 1; public static bool PortScanner_PingHostsBeforeScan => false; public static int PortScanner_ICMPAttempts => 2; diff --git a/Source/NETworkManager.Settings/SettingsInfo.cs b/Source/NETworkManager.Settings/SettingsInfo.cs index d0032d4186..a82a25ae31 100644 --- a/Source/NETworkManager.Settings/SettingsInfo.cs +++ b/Source/NETworkManager.Settings/SettingsInfo.cs @@ -1700,6 +1700,22 @@ public int PortScanner_Timeout } } = GlobalStaticConfiguration.PortScanner_Timeout; + /// + /// Interval of "Automatically scan every" (the checkbox is per tab and not saved, see the view model). + /// + public AutoRefreshTimeInfo PortScanner_AutoRefreshTime + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } = GlobalStaticConfiguration.PortScanner_AutoRefreshTime; + public int PortScanner_Attempts { get; diff --git a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs index f2ca4df16b..2769a36acc 100644 --- a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs @@ -86,6 +86,23 @@ public class PortScannerViewModel : ViewModelBase, IProfileManagerMinimal // Input of the running / last scan (not of a rescan), null until the ports are resolved private string _scanInput; + // Repeat of the scan ("Automatically scan every"): the input of the last completed scan, null if the scan is not + // repeated (until the next completed scan: after a manual start, a cancel, an error or a changed input) + private string _repeatInput; + private readonly AutoRefreshTimer _autoRefreshTimer; + + // Completed by ScanComplete, so the repeat timer waits for the end of the scan it started + private TaskCompletionSource _scanCompletion; + + // Repeated scan: the rows of the last scan stay and are updated in place (selection, scroll position and collapsed + // groups stay) - row per IP address and port, new open ports are added (_repeatAdded), the reported ports are + // remembered (a port of a host that was not scanned is not reported). The states before the scan are restored if + // it is canceled or fails. + private Dictionary<(IPAddress IPAddress, int Port), PortScannerPortInfo> _repeatRows; + private readonly List _repeatAdded = []; + private readonly HashSet<(IPAddress IPAddress, int Port)> _repeatReported = []; + private Dictionary _repeatSnapshots; + // Message of the tab, shown until the first scan has started or the ports are changed (see constructor) private string _message; @@ -131,6 +148,10 @@ public string Host return; field = value; + + // Another input is not repeated (until it was scanned) + StopRepeat(); + OnPropertyChanged(); } } @@ -154,6 +175,9 @@ public string Ports field = value; OnPropertyChanged(); + // Other ports are not repeated (until they were scanned) + StopRepeat(); + // The message of the tab is about the ports passed in (e.g. a port profile of the profile was not // found), it no longer applies when they are changed. Hidden only if it is still shown - not an error of // a scan that didn't start (e.g. invalid hosts) @@ -440,6 +464,59 @@ public DateTime? LastScanTime } } + /// + /// Gets or sets a value indicating whether the scan is repeated automatically after the interval (per tab and not + /// saved: a new tab or a profile doesn't start to repeat). Starts with the next completed scan. + /// + public bool AutoRefreshEnabled + { + get; + set + { + if (value == field) + return; + + field = value; + UpdateRepeat(); + OnPropertyChanged(); + } + } + + /// + /// Gets the intervals to repeat the scan. + /// + public ICollectionView AutoRefreshTimes { get; } + + /// + /// Gets or sets the interval to repeat the scan (the pause after a scan, saved for all tabs). + /// + public AutoRefreshTimeInfo SelectedAutoRefreshTime + { + get; + set + { + if (value == field) + return; + + field = value; + + // Not in the list (e.g. a removed interval of an old settings file): the previous interval is kept + if (value != null) + { + SettingsManager.Current.PortScanner_AutoRefreshTime = value; + _autoRefreshTimer.Interval = AutoRefreshTime.CalculateTimeSpan(value); + } + + OnPropertyChanged(); + } + } + + /// + /// Gets the timer of the repeated scan, for the countdown in the header (see + /// ). + /// + public AutoRefreshTimer AutoRefreshTimer => _autoRefreshTimer; + /// /// Gets or sets a value indicating whether the scan is being prepared. /// @@ -548,6 +625,15 @@ private set /// public PortScannerViewModel(Guid tabId, string host, string port, bool start = true, string message = null) { + // Repeat of the scan (before Host / Ports are set, see their setters): not while a scan or rescan is running + _autoRefreshTimer = new AutoRefreshTimer(RepeatScanAsync, () => !IsRunning); + + AutoRefreshTimes = CollectionViewSource.GetDefaultView(AutoRefreshTime.GetScanDefaults); + SelectedAutoRefreshTime = AutoRefreshTimes.SourceCollection.Cast().FirstOrDefault(x => + x.Value == SettingsManager.Current.PortScanner_AutoRefreshTime.Value && + x.TimeUnit == SettingsManager.Current.PortScanner_AutoRefreshTime.TimeUnit) ?? + AutoRefreshTimes.SourceCollection.Cast().First(); + _firstLoad = start; ConfigurationManager.Current.PortScannerTabCount++; @@ -636,6 +722,10 @@ public void OnClose() _closed = true; + // No repeated scan in the background + UpdateRepeat(); + _scanCompletion?.TrySetResult(); + SettingsManager.Current.PropertyChanged -= SettingsManager_PropertyChanged; // Stop scan @@ -886,7 +976,7 @@ private void AddStatusMessage(string message) IsStatusMessageDisplayed = true; } - private async Task Start() + private async Task Start(bool isRepeat = false) { // Port profiles in the input (e.g. "Webserver; 8080") are replaced by their ports. The input is validated, but // a port profile may have been renamed or deleted since. @@ -931,7 +1021,8 @@ private async Task Start() return; } - if (portCount > GlobalStaticConfiguration.PortScanner_ConfirmPortCount) + // Confirmed by the first scan, not asked again when the scan is repeated + if (!isRepeat && portCount > GlobalStaticConfiguration.PortScanner_ConfirmPortCount) { var window = Application.Current.Windows.OfType().FirstOrDefault(x => x.IsActive) ?? Application.Current.MainWindow; @@ -969,16 +1060,34 @@ private async Task Start() _resultsBuffer.Clear(); } - OpenPortsPerHost.Clear(); - Results.Clear(); + // Set from the scanner at the end of the scan (see ScanComplete) _portsNotOpenPerHost.Clear(); + if (isRepeat) + { + // The rows of the last scan stay (and its changes until the scan is completed), see ApplyRepeatResult + _repeatRows = Results.GroupBy(x => (x.IPAddress, x.Port)).ToDictionary(x => x.Key, x => x.First()); + _repeatAdded.Clear(); + _repeatReported.Clear(); + _repeatSnapshots = _repeatRows.Values.ToDictionary(x => x, x => x.State); + } + else + { + OpenPortsPerHost.Clear(); + Results.Clear(); + + // Repeated from the end of this scan (see ScanComplete) + _repeatInput = null; + UpdateRepeat(); + + HasComparison = false; + PortsNowOpen = 0; + PortsNoLongerOpen = 0; + LastScanTime = null; + } + // Compared with the previous scan of the same input when this scan is completed (see ScanComplete) _scanInput = null; - HasComparison = false; - PortsNowOpen = 0; - PortsNoLongerOpen = 0; - LastScanTime = null; // No results are shown until the scan's options are known - follow the current settings ScanOptions = CreateScanOptions(); @@ -1045,13 +1154,16 @@ private async Task Start() PreparingScan = false; - // Add host(s) to the history - ListHelper.Modify(SettingsManager.Current.PortScanner_HostHistory, Host, - SettingsManager.Current.General_HistoryListEntries); - // A single port profile name is already in the list, only other inputs are added to the history - if (PortProfile.FindByName(SettingsManager.Current.PortScanner_PortProfiles, Ports) == null) - ListHelper.Modify(SettingsManager.Current.PortScanner_PortHistory, Ports, + // Add host(s) to the history (not again when the scan is repeated) + if (!isRepeat) + { + ListHelper.Modify(SettingsManager.Current.PortScanner_HostHistory, Host, SettingsManager.Current.General_HistoryListEntries); + // A single port profile name is already in the list, only other inputs are added to the history + if (PortProfile.FindByName(SettingsManager.Current.PortScanner_PortProfiles, Ports) == null) + ListHelper.Modify(SettingsManager.Current.PortScanner_PortHistory, Ports, + SettingsManager.Current.General_HistoryListEntries); + } ScanOptions = CreateScanOptions(); @@ -1062,10 +1174,17 @@ private async Task Start() _scanInput = $"{Host?.Trim()}|{string.Join(";", ports)}"; // The open ports of the previous scan of this input are also reported if they are closed now, so they get a - // row with their state (not in ScanOptions: not for a rescan, not for the columns) - var options = IsComparedWithBaseline() - ? ScanOptions with { ReportPorts = _baseline.Keys.ToHashSet() } - : ScanOptions; + // row with their state - a repeated scan reports every port with a row, so it is updated (not in ScanOptions: + // not for a rescan, not for the columns) + var reportPorts = new HashSet<(IPAddress IPAddress, int Port)>(); + + if (IsComparedWithBaseline()) + reportPorts.UnionWith(_baseline.Keys); + + if (_repeatRows != null) + reportPorts.UnionWith(_repeatRows.Keys); + + var options = reportPorts.Count > 0 ? ScanOptions with { ReportPorts = reportPorts } : ScanOptions; StartScanner(options, (portScanner, ct) => portScanner.ScanAsync(hosts.hosts, ports, ct)); } @@ -1204,6 +1323,56 @@ private void UpdatePortsNotOpenPerHost(IPAddress ipAddress, PortState state, int _portsNotOpenPerHost[ipAddress] = (closed, timedOut); } + /// + /// Applies the results of a repeated scan (see ): the row of the port is updated in place, new + /// ports (open, or closed ones if they are shown) are added. + /// + /// Results of the scan. + private void ApplyRepeatResults(List results) + { + var added = new List(); + + foreach (var result in results) + { + var key = (result.IPAddress, result.Port); + _repeatReported.Add(key); + + if (_repeatRows.TryGetValue(key, out var row)) + { + SetPortState(row, result.State); + continue; + } + + added.Add(result); + _repeatRows[key] = result; + _repeatAdded.Add(result); + } + + if (added.Count > 0) + AddResults(added); + } + + /// + /// Changes the state of a shown row and its count in (group header). + /// + /// Row to change. + /// New state. + private void SetPortState(PortScannerPortInfo row, PortState state) + { + if (row.State == state) + return; + + if (row.State == PortState.Open || state == PortState.Open) + { + OpenPortsPerHost[row.IPAddress] = Math.Max(0, + OpenPortsPerHost.GetValueOrDefault(row.IPAddress) + (state == PortState.Open ? 1 : -1)); + + OpenPortsPerHostVersion++; + } + + row.State = state; + } + /// /// Applies a result of a port rescan to the row of the port (see ). /// @@ -1540,6 +1709,13 @@ private void FlushResultsBuffer() return; } + if (_repeatRows != null) + { + ApplyRepeatResults(itemsToAdd); + + return; + } + // Many rows (e.g. 1-65535 with closed ports shown): with one reset instead of one by one - WPF inserts every // added row into its group of the sorted view with a linear search of the group (rows x rows, the window hung // for minutes). A reset sorts and groups all rows once. @@ -1624,6 +1800,33 @@ private void ScanComplete(object sender, ScanCompleteArgs e) FlushProgress(); + // Repeated scan: canceled / failed - the rows of the last scan with their states; completed - a port of a + // host that was not scanned (e.g. it didn't answer the ping before the scan) didn't answer + if (_repeatRows != null) + { + if (e.IsCanceled || e.Exception != null) + { + foreach (var (row, state) in _repeatSnapshots) + SetPortState(row, state); + + foreach (var row in _repeatAdded) + { + SetPortState(row, PortState.TimedOut); + Results.Remove(row); + } + } + else + { + foreach (var row in _repeatSnapshots.Keys.Where(x => !_repeatReported.Contains((x.IPAddress, x.Port)))) + SetPortState(row, PortState.TimedOut); + } + + _repeatRows = null; + _repeatAdded.Clear(); + _repeatReported.Clear(); + _repeatSnapshots = null; + } + // Canceled / failed rescan: keep the previous results of the hosts (their rows were removed when the // rescan started, only some ports were scanned again) var isPortRescan = _rescanPortRows != null; @@ -1714,9 +1917,18 @@ private void ScanComplete(object sender, ScanCompleteArgs e) AddStatusMessage(string.Format(Strings.XHostsDidNotAnswerThePingAndWereNotScanned, pingScanner.HostsSkipped)); - // Only a completed scan of the input is compared and becomes the baseline of the next one - if (!isRescan && !e.IsCanceled && e.Exception == null && _scanInput != null && PortsToScan > 0) - CompareWithPreviousScan(); + // Only a completed scan of the input is compared and becomes the baseline of the next one - and is repeated + // (not after a cancel or an error, so a failing scan doesn't repeat) + if (!isRescan) + { + var isCompleted = !e.IsCanceled && e.Exception == null && _scanInput != null && PortsToScan > 0; + + if (isCompleted) + CompareWithPreviousScan(); + + _repeatInput = isCompleted ? _scanInput : null; + UpdateRepeat(); + } PreparingScan = false; IsCanceling = false; @@ -1724,9 +1936,57 @@ private void ScanComplete(object sender, ScanCompleteArgs e) // E.g. canceled during the preparation, or nothing found UpdateScanOptionsIfNoResults(); + + // The repeat timer waits for the end of the scan (see RepeatScanAsync) + _scanCompletion?.TrySetResult(); + _scanCompletion = null; }); } + /// + /// Starts or stops the timer of the repeated scan: runs while "Automatically scan every" is enabled and the last + /// scan of the input was completed. + /// + private void UpdateRepeat() + { + if (_autoRefreshTimer != null) + _autoRefreshTimer.IsEnabled = AutoRefreshEnabled && _repeatInput != null && !_closed; + } + + /// + /// Stops the repetition (the input was changed), until the next completed scan. + /// + private void StopRepeat() + { + if (_repeatInput == null) + return; + + _repeatInput = null; + UpdateRepeat(); + } + + /// + /// Repeats the scan (tick of the repeat timer) and waits until it is finished, so the interval starts after it. + /// + private async Task RepeatScanAsync() + { + _scanCompletion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + await Start(isRepeat: true); + + // Not started (e.g. invalid ports - the message is shown): not repeated + if (!IsRunning && _scanCompletion != null) + { + _repeatInput = null; + UpdateRepeat(); + + _scanCompletion.TrySetResult(); + } + + if (_scanCompletion != null) + await _scanCompletion.Task; + } + /// /// Checks if the running scan is compared with the previous scan (same input). /// @@ -1743,6 +2003,10 @@ private bool IsComparedWithBaseline() /// private void CompareWithPreviousScan() { + // The changes of the last comparison (rows of a repeated scan) + foreach (var row in Results) + row.Change = PortScannerPortChange.None; + if (IsComparedWithBaseline()) { var comparison = PortScannerScanComparer.Compare(_baseline, Results); @@ -1765,11 +2029,21 @@ private void CompareWithPreviousScan() PortsNoLongerOpen = comparison.Closed.Count + comparison.Missing.Count; HasComparison = true; - // The view isn't filtered live (see the constructor) - if (!string.IsNullOrEmpty(Search)) - ResultsView.Refresh(); } + // A repeated scan reports every port with a row: a port that is not open now is only shown if it is no longer + // open (or closed ports are shown) + if (!ScanOptions.ShowAllResults) + { + foreach (var row in Results.Where(x => x.State != PortState.Open && x.Change == PortScannerPortChange.None) + .ToList()) + Results.Remove(row); + } + + // The view isn't filtered live (see the constructor) + if (!string.IsNullOrEmpty(Search)) + ResultsView.Refresh(); + _baseline = PortScannerScanComparer.CreateBaseline(Results); _baselineInput = _scanInput; LastScanTime = DateTime.Now; diff --git a/Source/NETworkManager/Views/PortScannerView.xaml b/Source/NETworkManager/Views/PortScannerView.xaml index 6c458919c7..8dc0b958bc 100644 --- a/Source/NETworkManager/Views/PortScannerView.xaml +++ b/Source/NETworkManager/Views/PortScannerView.xaml @@ -285,10 +285,18 @@ + + Date: Sun, 1 Nov 2026 02:13:56 +0100 Subject: [PATCH 095/131] IP Scanner: Notification of the changes of a repeated scan Settings Notification (popup, sound, display duration; default off, also the Port Scanner settings): a repeated scan with changes shows one notification (title tool + input, up to three changes one per line, otherwise the counters) and / or plays a sound - only new hosts like a host that is up (green, asterisk), a gone host / changed MAC like a host that is down (red, exclamation), as in the Ping Monitor. Not for a manual scan. Co-Authored-By: Claude Opus 5.5 --- .../Resources/Strings.Designer.cs | 27 ++++++ .../Resources/Strings.resx | 9 ++ .../GlobalStaticConfiguration.cs | 8 ++ .../NETworkManager.Settings/SettingsInfo.cs | 96 +++++++++++++++++++ .../ViewModels/IPScannerSettingsViewModel.cs | 60 ++++++++++++ .../ViewModels/IPScannerViewModel.cs | 57 ++++++++++- .../Views/IPScannerSettingsView.xaml | 18 ++++ Website/docs/application/ip-scanner.md | 24 +++++ Website/docs/changelog/next-release.md | 1 + 9 files changed, 298 insertions(+), 2 deletions(-) diff --git a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs index 05f7985b58..71bcae1c33 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs +++ b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs @@ -6248,6 +6248,15 @@ public static string HelpMessage_SaveCredentials { } } + /// + /// Looks up a localized string similar to Only when the scan is repeated automatically ("Automatically scan every"): one notification per scan with the changes since the previous scan.. + /// + public static string HelpMessage_ScanChangesNotification { + get { + return ResourceManager.GetString("HelpMessage_ScanChangesNotification", resourceCulture); + } + } + /// /// Looks up a localized string similar to Only IPv4 addresses are scanned for NetBIOS, NetBIOS doesn't exist over IPv6.. /// @@ -9490,6 +9499,15 @@ public static string Placeholder { } } + /// + /// Looks up a localized string similar to Play sound on changes. + /// + public static string PlaySoundOnChanges { + get { + return ResourceManager.GetString("PlaySoundOnChanges", resourceCulture); + } + } + /// /// Looks up a localized string similar to Play sound on status change. /// @@ -13185,6 +13203,15 @@ public static string ShowMap { } } + /// + /// Looks up a localized string similar to Show notification popup on changes. + /// + public static string ShowNotificationPopupOnChanges { + get { + return ResourceManager.GetString("ShowNotificationPopupOnChanges", resourceCulture); + } + } + /// /// Looks up a localized string similar to Show notification popup on status change. /// diff --git a/Source/NETworkManager.Localization/Resources/Strings.resx b/Source/NETworkManager.Localization/Resources/Strings.resx index 5a495617ad..a3a8e23c2d 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.resx +++ b/Source/NETworkManager.Localization/Resources/Strings.resx @@ -4042,6 +4042,15 @@ Right-click for more options. Automatically scan every + + Show notification popup on changes + + + Play sound on changes + + + Only when the scan is repeated automatically ("Automatically scan every"): one notification per scan with the changes since the previous scan. + Failed to load hosts file: {0} diff --git a/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs b/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs index 65d232fd67..f6a890cf28 100644 --- a/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs +++ b/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs @@ -177,6 +177,10 @@ public static class GlobalStaticConfiguration public static AutoRefreshTimeInfo IPScanner_AutoRefreshTime => AutoRefreshTime.GetScanDefaults.First(x => x.Value == 5 && x.TimeUnit == TimeUnit.Minute); + + public static bool IPScanner_ShowNotificationPopup => false; + public static bool IPScanner_NotificationSound => false; + public static int IPScanner_NotificationCloseTime => 10; public static ExportFileType IPScanner_ExportFileType => ExportFileType.Csv; // Larger scans need a confirmation, even larger ones are refused (every IP address / port is kept in memory) public static long IPScanner_ConfirmHostCount => 65536; @@ -194,6 +198,10 @@ public static class GlobalStaticConfiguration public static AutoRefreshTimeInfo PortScanner_AutoRefreshTime => AutoRefreshTime.GetScanDefaults.First(x => x.Value == 5 && x.TimeUnit == TimeUnit.Minute); + + public static bool PortScanner_ShowNotificationPopup => false; + public static bool PortScanner_NotificationSound => false; + public static int PortScanner_NotificationCloseTime => 10; public static int PortScanner_Attempts => 1; public static bool PortScanner_PingHostsBeforeScan => false; public static int PortScanner_ICMPAttempts => 2; diff --git a/Source/NETworkManager.Settings/SettingsInfo.cs b/Source/NETworkManager.Settings/SettingsInfo.cs index a82a25ae31..2b1c134448 100644 --- a/Source/NETworkManager.Settings/SettingsInfo.cs +++ b/Source/NETworkManager.Settings/SettingsInfo.cs @@ -1236,6 +1236,54 @@ public AutoRefreshTimeInfo IPScanner_AutoRefreshTime } } = GlobalStaticConfiguration.IPScanner_AutoRefreshTime; + /// + /// Show a notification when a repeated scan found changes. + /// + public bool IPScanner_ShowNotificationPopup + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } = GlobalStaticConfiguration.IPScanner_ShowNotificationPopup; + + /// + /// Play a sound when a repeated scan found changes. + /// + public bool IPScanner_NotificationSound + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } = GlobalStaticConfiguration.IPScanner_NotificationSound; + + /// + /// Seconds until the notification of a repeated scan is closed. + /// + public int IPScanner_NotificationCloseTime + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } = GlobalStaticConfiguration.IPScanner_NotificationCloseTime; + public bool IPScanner_ICMPEnabled { get; @@ -1716,6 +1764,54 @@ public AutoRefreshTimeInfo PortScanner_AutoRefreshTime } } = GlobalStaticConfiguration.PortScanner_AutoRefreshTime; + /// + /// Show a notification when a repeated scan found changes. + /// + public bool PortScanner_ShowNotificationPopup + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } = GlobalStaticConfiguration.PortScanner_ShowNotificationPopup; + + /// + /// Play a sound when a repeated scan found changes. + /// + public bool PortScanner_NotificationSound + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } = GlobalStaticConfiguration.PortScanner_NotificationSound; + + /// + /// Seconds until the notification of a repeated scan is closed. + /// + public int PortScanner_NotificationCloseTime + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } = GlobalStaticConfiguration.PortScanner_NotificationCloseTime; + public int PortScanner_Attempts { get; diff --git a/Source/NETworkManager/ViewModels/IPScannerSettingsViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerSettingsViewModel.cs index dd17068208..728208f0a7 100644 --- a/Source/NETworkManager/ViewModels/IPScannerSettingsViewModel.cs +++ b/Source/NETworkManager/ViewModels/IPScannerSettingsViewModel.cs @@ -563,6 +563,63 @@ public int MaxHostThreads } } + /// + /// Gets or sets a value indicating whether a notification is shown when a repeated scan found changes. + /// + public bool ShowNotificationPopup + { + get; + set + { + if (value == field) + return; + + if (!_isLoading) + SettingsManager.Current.IPScanner_ShowNotificationPopup = value; + + field = value; + OnPropertyChanged(); + } + } + + /// + /// Gets or sets a value indicating whether a sound is played when a repeated scan found changes. + /// + public bool NotificationSound + { + get; + set + { + if (value == field) + return; + + if (!_isLoading) + SettingsManager.Current.IPScanner_NotificationSound = value; + + field = value; + OnPropertyChanged(); + } + } + + /// + /// Gets or sets the seconds until the notification is closed. + /// + public int NotificationCloseTime + { + get; + set + { + if (value == field) + return; + + if (!_isLoading) + SettingsManager.Current.IPScanner_NotificationCloseTime = value; + + field = value; + OnPropertyChanged(); + } + } + /// /// Gets or sets the maximum number of port threads. /// @@ -622,6 +679,9 @@ private void SettingsManager_PropertyChanged(object sender, PropertyChangedEvent private void LoadSettings() { ShowAllResults = SettingsManager.Current.IPScanner_ShowAllResults; + ShowNotificationPopup = SettingsManager.Current.IPScanner_ShowNotificationPopup; + NotificationSound = SettingsManager.Current.IPScanner_NotificationSound; + NotificationCloseTime = SettingsManager.Current.IPScanner_NotificationCloseTime; ExpandHostView = SettingsManager.Current.IPScanner_ExpandHostView; ICMPEnabled = SettingsManager.Current.IPScanner_ICMPEnabled; ICMPAttempts = SettingsManager.Current.IPScanner_ICMPAttempts; diff --git a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs index eb5ad1da61..f67c35ae5a 100644 --- a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs @@ -1,5 +1,6 @@ using log4net; using MahApps.Metro.Controls; +using MahApps.Metro.IconPacks; using MahApps.Metro.SimpleChildWindow; using NETworkManager.Controls; using NETworkManager.Converters; @@ -1884,7 +1885,7 @@ private void ScanComplete(object sender, ScanCompleteArgs e) var isCompleted = !e.IsCanceled && e.Exception == null && HostsToScan > 0; if (isCompleted) - CompareWithPreviousScan(); + CompareWithPreviousScan(isRepeat); _repeatInput = isCompleted ? _scanInput : null; UpdateRepeat(); @@ -1935,12 +1936,61 @@ private async Task RepeatScanAsync() await _scanCompletion.Task; } + /// + /// Shows a notification / plays a sound (see settings) with the changes of a repeated scan: one notification per + /// scan, up to three changes one per line, otherwise the counters. Like the host up / down notification of the + /// Ping Monitor: only new hosts = up, any gone / changed host = down. + /// + private void NotifyChanges() + { + var showPopup = SettingsManager.Current.IPScanner_ShowNotificationPopup; + var playSound = SettingsManager.Current.IPScanner_NotificationSound; + + if (!showPopup && !playSound) + return; + + var changes = Results.Where(x => x.Change != IPScannerHostChange.None).ToList(); + + if (changes.Count == 0) + return; + + changes.Sort((x, y) => IPAddressHelper.CompareIPAddresses(x.IPAddress, y.IPAddress)); + + var isWarning = changes.Any(x => x.Change != IPScannerHostChange.New); + + if (playSound) + NotificationManager.PlaySound(isWarning + ? System.Media.SystemSounds.Exclamation + : System.Media.SystemSounds.Asterisk); + + if (!showPopup) + return; + + const int maxLines = 3; + + var message = changes.Count <= maxLines + ? string.Join(Environment.NewLine, changes.Select(x => + $"{(string.IsNullOrEmpty(x.Hostname) ? x.IPAddress : $"{x.IPAddress} ({x.Hostname})")}: " + + IPScannerHostChangeToStringConverter.ToLocalizedString(x.Change))) + : string.Join(" · ", new[] + { + (Count: HostsNew, Format: Strings.XNew), (Count: HostsGone, Format: Strings.XGone), + (Count: HostsMACChanged, Format: Strings.XMACChanged) + } + .Where(x => x.Count > 0).Select(x => string.Format(x.Format, x.Count))); + + NotificationManager.Show(isWarning ? PackIconMaterialKind.LanDisconnect : PackIconMaterialKind.LanConnect, + isWarning ? "Red" : "#badc58", $"{Strings.IPScanner} · {_scanInput}", message, + SettingsManager.Current.IPScanner_NotificationCloseTime); + } + /// /// Compares the completed scan with the previous completed scan of the same input in this tab: marks new hosts /// and hosts with another MAC address, adds the hosts that are gone (shown as down, also without "Show /// unreachable"). The scan becomes the baseline of the next one. Called on the UI thread. /// - private void CompareWithPreviousScan() + /// The scan was repeated automatically: changes are notified (see settings). + private void CompareWithPreviousScan(bool isRepeat) { // The changes of the last comparison (rows of a repeated scan) foreach (var host in Results) @@ -1979,6 +2029,9 @@ private void CompareWithPreviousScan() HostsMACChanged = comparison.Changes.Values.Count(x => x.Change == IPScannerHostChange.MACChanged); HostsGone = comparison.Gone.Count; HasComparison = true; + + if (isRepeat) + NotifyChanges(); } // A repeated scan updated the rows of hosts that are down now: only shown if they are gone (or unreachable hosts diff --git a/Source/NETworkManager/Views/IPScannerSettingsView.xaml b/Source/NETworkManager/Views/IPScannerSettingsView.xaml index 3d15e34fa7..54ffbc11c3 100644 --- a/Source/NETworkManager/Views/IPScannerSettingsView.xaml +++ b/Source/NETworkManager/Views/IPScannerSettingsView.xaml @@ -255,6 +255,24 @@ + + + + + + - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Source/NETworkManager/Views/PortScannerView.xaml b/Source/NETworkManager/Views/PortScannerView.xaml index 039849de5a..f7b478c5d3 100644 --- a/Source/NETworkManager/Views/PortScannerView.xaml +++ b/Source/NETworkManager/Views/PortScannerView.xaml @@ -903,55 +903,42 @@ SortMemberPath="State" MinWidth="100" /> - + - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Website/docs/application/ip-scanner.md b/Website/docs/application/ip-scanner.md index 9f537e482f..8081eacc26 100644 --- a/Website/docs/application/ip-scanner.md +++ b/Website/docs/application/ip-scanner.md @@ -118,7 +118,7 @@ The columns are shown according to the settings the scan was started with, setti | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | _Status_ | Up / down, the tooltip shows the probes that answered | | _Role_ | Icon if the host has a role for this computer: **This computer**, **Default-Gateway**, **DNS server** or **DHCP server** of a connected network interface (all roles in the tooltip) | -| **Change** | Change since the previous scan, see [Changes since the last scan](#changes-since-the-last-scan) (only if the result is compared with one) +| **Since last scan** | Change since the previous scan, see [Changes since the last scan](#changes-since-the-last-scan) (only if the result is compared with one) | **IP address** | Scanned IP address | | **Hostname** | DNS hostname (`PTR`), otherwise the mDNS hostname, otherwise the NetBIOS name | | **Ping status** | Status icon, the tooltip shows status, time and TTL (e.g. `Success (2 ms, TTL 64)`) | @@ -148,7 +148,7 @@ A scan of the same input as the last completed scan in the tab shows what change - **Gone**: the host was up in the last scan, it is not up now. The row is added at the end of the scan (as down, with the hostname and MAC address of the last scan), also if [Show unreachable IP addresses and ports](#show-unreachable-ip-addresses-and-ports) is disabled. - **MAC changed**: the host is up with another MAC address than in the last scan (both known), e.g. a replaced device, an IP address conflict or ARP spoofing. The tooltip shows the previous MAC address. -The **Change** column and the counters next to the **Result** header (e.g. `2 New · 1 Gone`) are only shown if the result is compared with a previous scan. **Last scan at** next to the counters shows when the last scan was completed (the tooltip shows the date). +The **Since last scan** column and the counters next to the **Result** header (e.g. `2 New · 1 Gone`) are only shown if the result is compared with a previous scan. **Last scan at** next to the counters shows when the last scan was completed (the tooltip shows the date). Only completed scans are compared: a canceled scan or a scan with another input starts over (the next scan of this input is compared with it). **Rescan** of selected hosts updates their rows and their changes against the same previous scan (e.g. a gone host that is up again), but is not a scan of the input. The last scan is only kept in memory, as long as the tab is open. The change is also in the search and the export (`Change`, `PreviousMACAddress`). diff --git a/Website/docs/application/port-scanner.md b/Website/docs/application/port-scanner.md index 7eb0adb308..5bd4e85ada 100644 --- a/Website/docs/application/port-scanner.md +++ b/Website/docs/application/port-scanner.md @@ -98,7 +98,7 @@ The search box right of the **Result** header filters the results by IP address, ### Changes since the last scan -A scan of the same hosts and ports as the last completed scan in the tab shows what changed since then in the **Change** column: **Now open** (the port is open now, it was not open in the last scan) and **No longer open** (it was open in the last scan, it is closed or timed out now). A port that is no longer open is shown with its status, also if [Show closed ports](#show-closed-ports) is disabled; if its host was not scanned (e.g. it didn't answer the [ping before the scan](#ping-hosts-before-the-scan)), it is shown as timed out. The counters next to the **Result** header (e.g. `1 Now open · 2 No longer open`) and the column are only shown if the result is compared with a previous scan. **Last scan at** shows when the last scan was completed (the tooltip shows the date). +A scan of the same hosts and ports as the last completed scan in the tab shows what changed since then in the **Since last scan** column: **Now open** (the port is open now, it was not open in the last scan) and **No longer open** (it was open in the last scan, it is closed or timed out now). A port that is no longer open is shown with its status, also if [Show closed ports](#show-closed-ports) is disabled; if its host was not scanned (e.g. it didn't answer the [ping before the scan](#ping-hosts-before-the-scan)), it is shown as timed out. The counters next to the **Result** header (e.g. `1 Now open · 2 No longer open`) and the column are only shown if the result is compared with a previous scan. **Last scan at** shows when the last scan was completed (the tooltip shows the date). Only completed scans are compared: a canceled scan or a scan with other hosts or ports (also a changed port profile) starts over. **Rescan** updates the rows and their changes against the same previous scan (e.g. a port that is open again), but is not a scan of the input. The last scan is only kept in memory, as long as the tab is open. The change is also in the search and the export (`Change`). diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index 0ec10d997b..2e5111889d 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -102,7 +102,7 @@ Release date: **xx.xx.2026** - The settings of the port scan (**Ports**, **Timeout**, **Attempts**) are disabled while **Scan ports** is disabled, like the settings of the other probes. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) - The **Detect local subnet** button opens a menu: **Detect local subnet** (as before), **Subnet of network interface** to select the IPv4 subnet of a specific network interface (e.g. LAN, Wi-Fi, VPN, Hyper-V) and **All local subnets** to scan the subnets of all network interfaces. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) - IP addresses can be excluded from the scan with `!` in the host input (also in profiles and the run command), e.g. `10.0.0.0/24; !10.0.0.1; !10.0.0.200-254`. Works with IP addresses, ranges, subnets and hostnames (all IPv4 and IPv6 addresses of the hostname, nothing is scanned if the hostname can't be resolved); a message is shown if every IP address is excluded. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) -- A scan of the same input as the last completed scan in the tab shows what changed: hosts that are **New** (up now, not before), **Gone** (up before, not now - added as down, also without **Show unreachable**) and **MAC changed** (another MAC address, tooltip with the previous one) in the new **Change** column, counters next to the **Result** header (e.g. `2 New · 1 Gone`) and **Last scan at** with the time. Kept in memory while the tab is open; also in the search and the export (`Change`, `PreviousMACAddress`). [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) +- A scan of the same input as the last completed scan in the tab shows what changed: hosts that are **New** (up now, not before), **Gone** (up before, not now - added as down, also without **Show unreachable**) and **MAC changed** (another MAC address, tooltip with the previous one) in the new **Since last scan** column, counters next to the **Result** header (e.g. `2 New · 1 Gone`) and **Last scan at** with the time. Kept in memory while the tab is open; also in the search and the export (`Change`, `PreviousMACAddress`). [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) - New **Automatically scan every** (`1 min` to `1 h`) right of the **Result** header: repeats the scan after the interval, with a countdown to the next scan (`Next scan in 04:32`). The rows are updated in place (selection, scroll position and search stay), new hosts are added and hosts that are down now are shown as **Gone**. Stops when a scan is canceled, the input is changed or the tab is closed (a failed repeated scan is tried again after the interval); also runs while the tab is not shown. Per tab, the interval is saved. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) - New **Notification** settings (**Show notification popup on changes**, **Play sound on changes**, **Display duration**, default off): a repeated scan (**Automatically scan every**) that found new or gone hosts or a changed MAC address shows one notification with the changes (up to three, otherwise the number), like the host up / down notification of the **Ping Monitor**. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) @@ -135,7 +135,7 @@ Release date: **xx.xx.2026** - The export runs in the background, so the window stays responsive while a large result is exported, and uses the order shown in the result (sorting, grouping) instead of the order in which the ports were scanned. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) - Ports are started one after another (every 1 ms, shorter with short timeouts and many threads) instead of all at the same moment. This avoids bursts of hundreds of connection requests within a few milliseconds and makes the progress smoother. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) - IP addresses can be excluded from the scan with `!` in the host input (also in profiles and the run command), e.g. `10.0.0.0/24; !10.0.0.1; !10.0.0.200-254`. Works with IP addresses, ranges, subnets and hostnames (all IPv4 and IPv6 addresses of the hostname, nothing is scanned if the hostname can't be resolved); a message is shown if every IP address is excluded. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) -- A scan of the same hosts and ports as the last completed scan in the tab shows what changed in the new **Change** column: **Now open** and **No longer open** (shown with its status, also without **Show closed ports**), counters next to the **Result** header (e.g. `1 Now open · 2 No longer open`) and **Last scan at** with the time. Kept in memory while the tab is open; also in the search and the export (`Change`). [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) +- A scan of the same hosts and ports as the last completed scan in the tab shows what changed in the new **Since last scan** column: **Now open** and **No longer open** (shown with its status, also without **Show closed ports**), counters next to the **Result** header (e.g. `1 Now open · 2 No longer open`) and **Last scan at** with the time. Kept in memory while the tab is open; also in the search and the export (`Change`). [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) - New **Automatically scan every** (`1 min` to `1 h`) right of the **Result** header: repeats the scan after the interval, with a countdown to the next scan (`Next scan in 04:32`). The rows are updated in place (selection, scroll position, collapsed hosts and search stay), new open ports are added and ports that are no longer open are marked. Stops when a scan is canceled, the hosts or ports are changed or the tab is closed (a failed repeated scan is tried again after the interval); also runs while the tab is not shown. Per tab, the interval is saved. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) - New **Notification** settings (**Show notification popup on changes**, **Play sound on changes**, **Display duration**, default off): a repeated scan (**Automatically scan every**) that found ports that are now open or no longer open shows one notification with the changes (up to three, e.g. `server (10.0.0.5): 3389 (rdp) No longer open`, otherwise the number), like the host up / down notification of the **Ping Monitor**. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) From e4a9317fa84c242c4909de3eb90291d11f6a91fb Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Sun, 1 Nov 2026 02:56:25 +0100 Subject: [PATCH 109/131] Refactor: Notifications: one model (NotificationInfo, NotificationStatus) NotificationManager.Show(NotificationInfo, popup, sound) replaces Show(icon, color, ...) and ShowStatusChange: the tool, the status, the title and the message; the window maps the status to icon, color and sound in one place, with the colors of the application (red #b95353 instead of "Red"). Four states (NotificationStatusHelper.FromChanges, with tests): up (only additions), down (only removals), up and down (both - the icon half green / half red, split along the diagonal; was red before), changed (e.g. a MAC address, orange). Co-Authored-By: Claude Opus 5.5 --- .../NotificationStatus.cs | 56 +++++++++++++++ .../NotificationStatusHelperTests.cs | 22 ++++++ Source/NETworkManager/NotificationInfo.cs | 19 +++++ Source/NETworkManager/NotificationManager.cs | 69 +++++++------------ Source/NETworkManager/NotificationWindow.xaml | 2 +- .../NETworkManager/NotificationWindow.xaml.cs | 55 ++++++++++++--- .../ViewModels/IPScannerViewModel.cs | 10 ++- .../ViewModels/PingMonitorViewModel.cs | 13 ++-- .../ViewModels/PortScannerViewModel.cs | 10 ++- 9 files changed, 189 insertions(+), 67 deletions(-) create mode 100644 Source/NETworkManager.Models/NotificationStatus.cs create mode 100644 Source/NETworkManager.Tests/NotificationStatusHelperTests.cs create mode 100644 Source/NETworkManager/NotificationInfo.cs diff --git a/Source/NETworkManager.Models/NotificationStatus.cs b/Source/NETworkManager.Models/NotificationStatus.cs new file mode 100644 index 0000000000..7a46a1cbb8 --- /dev/null +++ b/Source/NETworkManager.Models/NotificationStatus.cs @@ -0,0 +1,56 @@ +namespace NETworkManager.Models; + +/// +/// Status of a notification (e.g. a host of the Ping Monitor, the changes of a repeated scan), shown by its icon, +/// color and sound. +/// +public enum NotificationStatus +{ + /// + /// Something is up / was added (host is up, new hosts, ports open now). + /// + Up, + + /// + /// Something is down / went away (host is down, hosts gone, ports no longer open). + /// + Down, + + /// + /// Both: something is up and something is down (e.g. a new and a gone host). + /// + UpAndDown, + + /// + /// Another change that is neither up nor down (e.g. a MAC address changed). + /// + Changed +} + +/// +/// Methods for . +/// +public static class NotificationStatusHelper +{ + /// + /// Gets the status of a notification about changes: up if there are only additions, down if there are only + /// removals, up and down if there are both, otherwise changed (e.g. only a changed MAC address). + /// + /// Number of additions (e.g. new hosts, ports open now). + /// Number of removals (e.g. gone hosts, ports no longer open). + /// Number of other changes (e.g. changed MAC addresses). + /// Status of the notification. + public static NotificationStatus FromChanges(int additions, int removals, int otherChanges) + { + if (additions > 0 && removals > 0) + return NotificationStatus.UpAndDown; + + if (otherChanges > 0) + return NotificationStatus.Changed; + + if (additions > 0) + return NotificationStatus.Up; + + return removals > 0 ? NotificationStatus.Down : NotificationStatus.Changed; + } +} diff --git a/Source/NETworkManager.Tests/NotificationStatusHelperTests.cs b/Source/NETworkManager.Tests/NotificationStatusHelperTests.cs new file mode 100644 index 0000000000..e2e99b2277 --- /dev/null +++ b/Source/NETworkManager.Tests/NotificationStatusHelperTests.cs @@ -0,0 +1,22 @@ +using NETworkManager.Models; +using Xunit; + +namespace NETworkManager.Tests; + +public class NotificationStatusHelperTests +{ + [Theory] + [InlineData(1, 0, 0, NotificationStatus.Up)] + [InlineData(5, 0, 0, NotificationStatus.Up)] + [InlineData(0, 1, 0, NotificationStatus.Down)] + [InlineData(1, 1, 0, NotificationStatus.UpAndDown)] + [InlineData(2, 3, 1, NotificationStatus.UpAndDown)] + [InlineData(0, 0, 1, NotificationStatus.Changed)] + [InlineData(1, 0, 1, NotificationStatus.Changed)] + [InlineData(0, 1, 1, NotificationStatus.Changed)] + [InlineData(0, 0, 0, NotificationStatus.Changed)] + public void GetsTheStatusOfTheChanges(int additions, int removals, int otherChanges, NotificationStatus expected) + { + Assert.Equal(expected, NotificationStatusHelper.FromChanges(additions, removals, otherChanges)); + } +} diff --git a/Source/NETworkManager/NotificationInfo.cs b/Source/NETworkManager/NotificationInfo.cs new file mode 100644 index 0000000000..cf64994865 --- /dev/null +++ b/Source/NETworkManager/NotificationInfo.cs @@ -0,0 +1,19 @@ +using NETworkManager.Models; + +namespace NETworkManager; + +/// +/// A notification of a tool (see ): the same design for every tool - the tool +/// in the header, the status as icon (and sound), the target as title and the message. +/// +/// Tool that sends the notification (icon and name in the header). +/// Status (icon, color and sound). +/// Target, e.g. the host or the scanned input. +/// What happened, e.g. "Host is up" or the changes of a scan. +/// Seconds before the popup closes automatically. +public sealed record NotificationInfo( + ApplicationName Application, + NotificationStatus Status, + string Title, + string Message, + int CloseTimeSeconds); diff --git a/Source/NETworkManager/NotificationManager.cs b/Source/NETworkManager/NotificationManager.cs index f775334e04..4a452ed421 100644 --- a/Source/NETworkManager/NotificationManager.cs +++ b/Source/NETworkManager/NotificationManager.cs @@ -1,4 +1,4 @@ -using MahApps.Metro.IconPacks; +using NETworkManager.Models; using NETworkManager.Settings; using System; using System.Collections.Generic; @@ -28,64 +28,28 @@ public static class NotificationManager private static DateTime _lastSoundPlayed = DateTime.MinValue; /// - /// Shows a notification popup in the bottom-right corner of the primary screen. - /// Safe to call from any thread. + /// Shows a notification of a tool (popup in the bottom-right corner of the primary screen) and / or plays its sound + /// (settings of the tool): a gentle info sound if something is up, otherwise a warning sound. Safe to call from any + /// thread. /// - /// The Material icon shown on the left of the popup. - /// The icon fill color (e.g. "Red" or "#badc58"). - /// The bold header text (e.g. the host title). - /// The gray subtext below the header (e.g. "Host is up"). - /// Seconds before the popup closes automatically. - public static void Show(PackIconMaterialKind iconKind, string iconColor, string title, string message, int closeTimeSeconds) - { - ShowWindow(iconKind, iconColor, title, message, closeTimeSeconds); - } - - /// - /// Shows a status change (e.g. a host is up / down in the Ping Monitor, changes of a repeated scan): up with a - /// green connected icon and a gentle info sound, down with a red disconnected icon and a warning sound. Popup - /// and sound are optional (settings of the application). - /// - /// Up (e.g. new hosts only), otherwise down (e.g. a host is gone). - /// The bold header text (e.g. the host title). - /// The gray subtext below the header (e.g. "Host is up"). + /// The notification. /// Show the popup. /// Play the sound. - /// Seconds before the popup closes automatically. - public static void ShowStatusChange(bool isUp, string title, string message, bool showPopup, bool playSound, - int closeTimeSeconds) + public static void Show(NotificationInfo notification, bool showPopup, bool playSound) { if (playSound) - PlaySound(isUp ? SystemSounds.Asterisk : SystemSounds.Exclamation); + PlaySound(notification.Status == NotificationStatus.Up ? SystemSounds.Asterisk : SystemSounds.Exclamation); - if (showPopup) - Show(isUp ? PackIconMaterialKind.LanConnect : PackIconMaterialKind.LanDisconnect, isUp ? "#badc58" : "Red", - title, message, closeTimeSeconds); - } - - /// - /// Formats a list of changes for the message of a notification: up to changes, one - /// per line, otherwise the summary (e.g. "5 New · 2 Gone"), so the popup stays small. - /// - /// One line per change. - /// Summary of all changes. - /// Maximum number of lines. - /// Message of the notification. - public static string FormatChanges(IReadOnlyCollection lines, string summary, int maxLines = 3) - { - return lines.Count <= maxLines ? string.Join(Environment.NewLine, lines) : summary; - } + if (!showPopup) + return; - private static void ShowWindow(PackIconMaterialKind iconKind, string iconColor, string title, string message, - int closeTimeSeconds) - { // A late ping callback during application shutdown may find no Application instance. if (Application.Current is null) return; Application.Current.Dispatcher.BeginInvoke(() => { - var window = new NotificationWindow(iconKind, iconColor, title, message, closeTimeSeconds); + var window = new NotificationWindow(notification); window.Closed += (_, _) => { @@ -98,6 +62,19 @@ private static void ShowWindow(PackIconMaterialKind iconKind, string iconColor, }); } + /// + /// Formats a list of changes for the message of a notification: up to changes, one + /// per line, otherwise the summary (e.g. "5 New · 2 Gone"), so the popup stays small. + /// + /// One line per change. + /// Summary of all changes. + /// Maximum number of lines. + /// Message of the notification. + public static string FormatChanges(IReadOnlyCollection lines, string summary, int maxLines = 3) + { + return lines.Count <= maxLines ? string.Join(Environment.NewLine, lines) : summary; + } + /// /// Returns the combined height (including margins) of all windows stacked below the given /// window, i.e. the vertical offset from the bottom edge at which it should be placed. diff --git a/Source/NETworkManager/NotificationWindow.xaml b/Source/NETworkManager/NotificationWindow.xaml index fba46904b6..24da6d19e2 100644 --- a/Source/NETworkManager/NotificationWindow.xaml +++ b/Source/NETworkManager/NotificationWindow.xaml @@ -45,7 +45,7 @@ + Fill="{Binding IconBrush}"> diff --git a/Source/NETworkManager/NotificationWindow.xaml.cs b/Source/NETworkManager/NotificationWindow.xaml.cs index a638ec2615..4ba6aaf375 100644 --- a/Source/NETworkManager/NotificationWindow.xaml.cs +++ b/Source/NETworkManager/NotificationWindow.xaml.cs @@ -1,4 +1,5 @@ using MahApps.Metro.IconPacks; +using NETworkManager.Models; using NETworkManager.Utilities; using System; using System.ComponentModel; @@ -8,6 +9,7 @@ using System.Windows; using System.Windows.Forms; using System.Windows.Input; +using System.Windows.Media; using System.Windows.Threading; namespace NETworkManager; @@ -39,7 +41,7 @@ private void OnPropertyChanged([CallerMemberName] string propertyName = null) // Note: the header property is named NotificationTitle (not Title) to avoid clashing with // the inherited Window.Title property, which a "{Binding Title}" would otherwise resolve to. public PackIconMaterialKind IconKind { get; } - public string IconColor { get; } + public Brush IconBrush { get; } public string NotificationTitle { get; } public string Message { get; } @@ -69,18 +71,17 @@ private set #region Constructor - public NotificationWindow(PackIconMaterialKind iconKind, string iconColor, string title, string message, int closeTimeSeconds) + public NotificationWindow(NotificationInfo notification) { InitializeComponent(); DataContext = this; - IconKind = iconKind; - IconColor = iconColor; - NotificationTitle = title; - Message = message; + (IconKind, IconBrush) = GetStatusIcon(notification.Status); + NotificationTitle = notification.Title; + Message = notification.Message; Time = DateTime.Now.ToString("HH:mm:ss"); - TimeMax = closeTimeSeconds; - TimeRemaining = closeTimeSeconds; + TimeMax = notification.CloseTimeSeconds; + TimeRemaining = notification.CloseTimeSeconds; CloseCommand = new RelayCommand(_ => CloseWindow()); @@ -109,6 +110,44 @@ private void ShowMainWindow() #region Methods + // Colors of the application (status icons, "Since last scan" of the scanners) + private static readonly Color UpColor = (Color)ColorConverter.ConvertFromString("#badc58"); + private static readonly Color DownColor = (Color)ColorConverter.ConvertFromString("#b95353"); + private static readonly Color ChangedColor = (Color)ColorConverter.ConvertFromString("#f1b856"); + + /// + /// Gets the icon and its brush of a status: up green, down red, up and down half green / half red (split along the + /// diagonal), changed orange. + /// + /// Status of the notification. + /// Icon and brush. + private static (PackIconMaterialKind kind, Brush brush) GetStatusIcon(NotificationStatus status) + { + Brush brush = status switch + { + NotificationStatus.Up => new SolidColorBrush(UpColor), + NotificationStatus.Down => new SolidColorBrush(DownColor), + NotificationStatus.UpAndDown => new LinearGradientBrush(new GradientStopCollection + { + // Two stops at the same offset: a sharp diagonal edge instead of a gradient + new(UpColor, 0), new(UpColor, 0.5), new(DownColor, 0.5), new(DownColor, 1) + }, new Point(0, 0), new Point(1, 1)), + _ => new SolidColorBrush(ChangedColor) + }; + + brush.Freeze(); + + var kind = status switch + { + NotificationStatus.Up => PackIconMaterialKind.LanConnect, + NotificationStatus.Down => PackIconMaterialKind.LanDisconnect, + NotificationStatus.UpAndDown => PackIconMaterialKind.Lan, + _ => PackIconMaterialKind.LanPending + }; + + return (kind, brush); + } + /// /// Called by when the stack changes (a sibling closes or /// resizes) and this window may need to move. diff --git a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs index ef424a068d..8723bb4857 100644 --- a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs @@ -1899,9 +1899,13 @@ private void NotifyChanges() } .Where(x => x.Count > 0).Select(x => string.Format(x.Format, x.Count))); - NotificationManager.ShowStatusChange(changes.All(x => x.Change == IPScannerHostChange.New), - $"{Strings.IPScanner} · {_scanInput}", NotificationManager.FormatChanges(lines, summary), showPopup, - playSound, SettingsManager.Current.IPScanner_NotificationCloseTime); + // New = up, gone = down, both = up and down, a changed MAC address = changed + var status = NotificationStatusHelper.FromChanges(HostsNew, HostsGone, HostsMACChanged); + + NotificationManager.Show(new NotificationInfo(ApplicationName.IPScanner, status, + $"{Strings.IPScanner} · {_scanInput}", NotificationManager.FormatChanges(lines, summary), + SettingsManager.Current.IPScanner_NotificationCloseTime), + showPopup, playSound); } /// diff --git a/Source/NETworkManager/ViewModels/PingMonitorViewModel.cs b/Source/NETworkManager/ViewModels/PingMonitorViewModel.cs index 9a15bd5eb4..32e80db49f 100644 --- a/Source/NETworkManager/ViewModels/PingMonitorViewModel.cs +++ b/Source/NETworkManager/ViewModels/PingMonitorViewModel.cs @@ -7,6 +7,7 @@ using log4net; using MahApps.Metro.SimpleChildWindow; using NETworkManager.Localization.Resources; +using NETworkManager.Models; using NETworkManager.Models.Export; using NETworkManager.Models.Network; using NETworkManager.Settings; @@ -705,10 +706,10 @@ private void Ping_PingReceived(object sender, PingReceivedArgs e) StatusTime = DateTime.Now; IsReachable = true; - NotificationManager.ShowStatusChange(true, NotificationHostDisplay, Strings.HostIsUp, + NotificationManager.Show(new NotificationInfo(ApplicationName.PingMonitor, NotificationStatus.Up, + NotificationHostDisplay, Strings.HostIsUp, SettingsManager.Current.PingMonitor_NotificationCloseTime), SettingsManager.Current.PingMonitor_ShowNotificationPopup, - SettingsManager.Current.PingMonitor_NotificationSound, - SettingsManager.Current.PingMonitor_NotificationCloseTime); + SettingsManager.Current.PingMonitor_NotificationSound); } Received++; @@ -735,10 +736,10 @@ private void Ping_PingReceived(object sender, PingReceivedArgs e) StatusTime = DateTime.Now; IsReachable = false; - NotificationManager.ShowStatusChange(false, NotificationHostDisplay, Strings.HostIsDown, + NotificationManager.Show(new NotificationInfo(ApplicationName.PingMonitor, NotificationStatus.Down, + NotificationHostDisplay, Strings.HostIsDown, SettingsManager.Current.PingMonitor_NotificationCloseTime), SettingsManager.Current.PingMonitor_ShowNotificationPopup, - SettingsManager.Current.PingMonitor_NotificationSound, - SettingsManager.Current.PingMonitor_NotificationCloseTime); + SettingsManager.Current.PingMonitor_NotificationSound); } Lost++; diff --git a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs index b9ce5c1812..d8f4b02817 100644 --- a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs @@ -1956,9 +1956,13 @@ private void NotifyChanges() } .Where(x => x.Count > 0).Select(x => string.Format(x.Format, x.Count))); - NotificationManager.ShowStatusChange(changes.All(x => x.Change == PortScannerPortChange.Opened), - $"{Strings.PortScanner} · {_scanHost}", NotificationManager.FormatChanges(lines, summary), showPopup, - playSound, SettingsManager.Current.PortScanner_NotificationCloseTime); + // Open now = up, no longer open = down, both = up and down + var status = NotificationStatusHelper.FromChanges(PortsNowOpen, PortsNoLongerOpen, 0); + + NotificationManager.Show(new NotificationInfo(ApplicationName.PortScanner, status, + $"{Strings.PortScanner} · {_scanHost}", NotificationManager.FormatChanges(lines, summary), + SettingsManager.Current.PortScanner_NotificationCloseTime), + showPopup, playSound); } /// From dd453540f583a27257a667f93c00743551706d01 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Sun, 1 Nov 2026 02:57:13 +0100 Subject: [PATCH 110/131] Notifications: Header with the tool that sent it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Header row with the icon and the translated name of the tool (like the application list) and the time; below the status icon, the title (only the target - no "IP Scanner · " prefix any more) and the message. Co-Authored-By: Claude Opus 5.5 --- Source/NETworkManager/NotificationWindow.xaml | 131 ++++++++++-------- .../NETworkManager/NotificationWindow.xaml.cs | 11 ++ .../ViewModels/IPScannerViewModel.cs | 2 +- .../ViewModels/PortScannerViewModel.cs | 2 +- 4 files changed, 85 insertions(+), 61 deletions(-) diff --git a/Source/NETworkManager/NotificationWindow.xaml b/Source/NETworkManager/NotificationWindow.xaml index 24da6d19e2..2ea5bda753 100644 --- a/Source/NETworkManager/NotificationWindow.xaml +++ b/Source/NETworkManager/NotificationWindow.xaml @@ -34,80 +34,93 @@ - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + - + - + - + + - - - + + + + + - Date: Sun, 1 Nov 2026 02:58:53 +0100 Subject: [PATCH 111/131] Notifications: A click opens the tool and the tab that sent it The click only showed the main window. Now it opens the tool of the notification; the IP Scanner and Port Scanner select the tab of the scan (ShowTabRequest, SelectTab - only the tool if the tab was closed or moved to another window). Co-Authored-By: Claude Opus 5.5 --- Source/NETworkManager/ApplicationRequest.cs | 25 ++++++++++++++++++- Source/NETworkManager/MainWindow.xaml.cs | 3 ++- Source/NETworkManager/NotificationInfo.cs | 9 ++++++- .../NETworkManager/NotificationWindow.xaml.cs | 8 ++++++ .../ViewModels/IPScannerHostViewModel.cs | 17 +++++++++++++ .../ViewModels/IPScannerViewModel.cs | 5 +++- .../ViewModels/PortScannerHostViewModel.cs | 17 +++++++++++++ .../ViewModels/PortScannerViewModel.cs | 5 +++- .../Views/IPScannerHostView.xaml.cs | 7 ++++++ .../Views/PortScannerHostView.xaml.cs | 7 ++++++ 10 files changed, 98 insertions(+), 5 deletions(-) diff --git a/Source/NETworkManager/ApplicationRequest.cs b/Source/NETworkManager/ApplicationRequest.cs index ad2953dbcf..7adc6d201f 100644 --- a/Source/NETworkManager/ApplicationRequest.cs +++ b/Source/NETworkManager/ApplicationRequest.cs @@ -1,4 +1,5 @@ -using DnsClient; +using System; +using DnsClient; using NETworkManager.Models; using NETworkManager.Models.Lookup; using NETworkManager.Models.RDAP; @@ -108,3 +109,25 @@ public sealed record LookupRequest() : ApplicationRequest(ApplicationName.Lookup /// public LookupType Type { get; init; } } + +/// +/// Request to show a tab of a tool (e.g. the IP Scanner tab that sent a notification). Only the tool is shown if the +/// tab was closed (or is in another window). +/// +public sealed record ShowTabRequest : ApplicationRequest +{ + /// + /// Creates a request to show a tab. + /// + /// Tool of the tab. + /// Id of the tab (see DragablzTabItem.Id). + public ShowTabRequest(ApplicationName application, Guid tabId) : base(application) + { + TabId = tabId; + } + + /// + /// Id of the tab to show. + /// + public Guid TabId { get; } +} diff --git a/Source/NETworkManager/MainWindow.xaml.cs b/Source/NETworkManager/MainWindow.xaml.cs index b6f9bb0141..be5c4b8928 100644 --- a/Source/NETworkManager/MainWindow.xaml.cs +++ b/Source/NETworkManager/MainWindow.xaml.cs @@ -1063,7 +1063,8 @@ await DialogHelper.ShowMessageAsync(Application.Current.MainWindow, Strings.Erro SelectedApplication = application; // Only open the application, unless the request shows a part of it (e.g. the port lookup) - if (string.IsNullOrEmpty(request.Target) && request.Profile == null && request is not LookupRequest) + if (string.IsNullOrEmpty(request.Target) && request.Profile == null && + request is not (LookupRequest or ShowTabRequest)) return; // Create a new tab / perform the action in the application (only if it is shown) diff --git a/Source/NETworkManager/NotificationInfo.cs b/Source/NETworkManager/NotificationInfo.cs index cf64994865..8ec7adce25 100644 --- a/Source/NETworkManager/NotificationInfo.cs +++ b/Source/NETworkManager/NotificationInfo.cs @@ -16,4 +16,11 @@ public sealed record NotificationInfo( NotificationStatus Status, string Title, string Message, - int CloseTimeSeconds); + int CloseTimeSeconds) +{ + /// + /// Opened when the notification is clicked (e.g. the tab of the scan, see ), null + /// to open the tool. + /// + public ApplicationRequest Request { get; init; } +} diff --git a/Source/NETworkManager/NotificationWindow.xaml.cs b/Source/NETworkManager/NotificationWindow.xaml.cs index 73f6355664..c6015114e1 100644 --- a/Source/NETworkManager/NotificationWindow.xaml.cs +++ b/Source/NETworkManager/NotificationWindow.xaml.cs @@ -41,6 +41,9 @@ private void OnPropertyChanged([CallerMemberName] string propertyName = null) // Bound properties — all set once in the constructor, no change notifications needed. // Note: the header property is named NotificationTitle (not Title) to avoid clashing with // the inherited Window.Title property, which a "{Binding Title}" would otherwise resolve to. + // Opened when the notification is clicked (the tool or its tab) + private readonly ApplicationRequest _request; + // Header: the tool that sent the notification public Brush ApplicationIconBrush { get; } public string ApplicationTitle { get; } @@ -81,6 +84,8 @@ public NotificationWindow(NotificationInfo notification) InitializeComponent(); DataContext = this; + _request = notification.Request ?? new ApplicationRequest(notification.Application); + ApplicationIconBrush = new VisualBrush(ApplicationManager.GetIcon(notification.Application)) { Stretch = Stretch.Uniform @@ -115,6 +120,9 @@ private void ShowMainWindow() if (System.Windows.Application.Current.MainWindow is MainWindow mainWindow && mainWindow.ShowWindowCommand.CanExecute(null)) mainWindow.ShowWindowCommand.Execute(null); + + // The tool (and the tab) that sent the notification + NavigationManager.Open(_request); } #endregion diff --git a/Source/NETworkManager/ViewModels/IPScannerHostViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerHostViewModel.cs index 0667ecd3df..3341aba914 100644 --- a/Source/NETworkManager/ViewModels/IPScannerHostViewModel.cs +++ b/Source/NETworkManager/ViewModels/IPScannerHostViewModel.cs @@ -143,6 +143,23 @@ private static void CloseItemAction(ItemActionCallbackArgs args) #region Methods + /// + /// Selects a tab (e.g. the tab that sent a notification). Nothing happens if it was closed or moved to another + /// window. + /// + /// Id of the tab. + public void SelectTab(Guid tabId) + { + for (var i = 0; i < TabItems.Count; i++) + { + if (TabItems[i].Id != tabId) + continue; + + SelectedTabIndex = i; + return; + } + } + /// /// Adds a new tab for the specified host or IP range. /// diff --git a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs index 4b3e2df1ad..eadd60a146 100644 --- a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs @@ -1904,7 +1904,10 @@ private void NotifyChanges() NotificationManager.Show(new NotificationInfo(ApplicationName.IPScanner, status, _scanInput, NotificationManager.FormatChanges(lines, summary), - SettingsManager.Current.IPScanner_NotificationCloseTime), + SettingsManager.Current.IPScanner_NotificationCloseTime) + { + Request = new ShowTabRequest(ApplicationName.IPScanner, _tabId) + }, showPopup, playSound); } diff --git a/Source/NETworkManager/ViewModels/PortScannerHostViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerHostViewModel.cs index 2c881f7a51..c06986aeff 100644 --- a/Source/NETworkManager/ViewModels/PortScannerHostViewModel.cs +++ b/Source/NETworkManager/ViewModels/PortScannerHostViewModel.cs @@ -131,6 +131,23 @@ private static void CloseItemAction(ItemActionCallbackArgs args) #region Methods + /// + /// Selects a tab (e.g. the tab that sent a notification). Nothing happens if it was closed or moved to another + /// window. + /// + /// Id of the tab. + public void SelectTab(Guid tabId) + { + for (var i = 0; i < TabItems.Count; i++) + { + if (TabItems[i].Id != tabId) + continue; + + SelectedTabIndex = i; + return; + } + } + /// /// Adds a new tab with the specified host and ports. /// diff --git a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs index c66446599e..37d0fda5cb 100644 --- a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs @@ -1961,7 +1961,10 @@ private void NotifyChanges() NotificationManager.Show(new NotificationInfo(ApplicationName.PortScanner, status, _scanHost, NotificationManager.FormatChanges(lines, summary), - SettingsManager.Current.PortScanner_NotificationCloseTime), + SettingsManager.Current.PortScanner_NotificationCloseTime) + { + Request = new ShowTabRequest(ApplicationName.PortScanner, _tabId) + }, showPopup, playSound); } diff --git a/Source/NETworkManager/Views/IPScannerHostView.xaml.cs b/Source/NETworkManager/Views/IPScannerHostView.xaml.cs index bb30ba9bb0..a0481c2e4f 100644 --- a/Source/NETworkManager/Views/IPScannerHostView.xaml.cs +++ b/Source/NETworkManager/Views/IPScannerHostView.xaml.cs @@ -14,6 +14,13 @@ public IPScannerHostView() public void Open(ApplicationRequest request) { + // E.g. a click on a notification of the tab + if (request is ShowTabRequest showTabRequest) + { + _viewModel.SelectTab(showTabRequest.TabId); + return; + } + if (request.Profile != null) _viewModel.AddTab(request.Profile, request.Start); else diff --git a/Source/NETworkManager/Views/PortScannerHostView.xaml.cs b/Source/NETworkManager/Views/PortScannerHostView.xaml.cs index 61166f7bac..a42dd558dd 100644 --- a/Source/NETworkManager/Views/PortScannerHostView.xaml.cs +++ b/Source/NETworkManager/Views/PortScannerHostView.xaml.cs @@ -14,6 +14,13 @@ public PortScannerHostView() public void Open(ApplicationRequest request) { + // E.g. a click on a notification of the tab + if (request is ShowTabRequest showTabRequest) + { + _viewModel.SelectTab(showTabRequest.TabId); + return; + } + if (request.Profile != null) _viewModel.AddTab(request.Profile, request.Start); else From cd6885b149ed913c5666252ad755e5b6de89e684 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Sun, 1 Nov 2026 02:59:14 +0100 Subject: [PATCH 112/131] Docs: Notifications with the tool in the header, status colors, click Co-Authored-By: Claude Opus 5.5 --- Website/docs/application/ip-scanner.md | 2 +- Website/docs/application/ping-monitor.md | 4 ++-- Website/docs/application/port-scanner.md | 2 +- Website/docs/changelog/next-release.md | 5 +++-- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/Website/docs/application/ip-scanner.md b/Website/docs/application/ip-scanner.md index 8081eacc26..a8336f5f21 100644 --- a/Website/docs/application/ip-scanner.md +++ b/Website/docs/application/ip-scanner.md @@ -526,7 +526,7 @@ Timeout in milliseconds after which an SNMP request is considered lost. ### Show notification popup on changes -Show a notification popup in the bottom-right corner of the primary screen when a [repeated scan](#repeat-the-scan) found changes since the previous scan (see [Changes since the last scan](#changes-since-the-last-scan); only new hosts like a host that is up, a gone host or a changed MAC address like a host that is down). One notification per scan: up to three changes, one per line, otherwise the number of changes. Not for a scan that was started manually. +Show a notification popup in the bottom-right corner of the primary screen when a [repeated scan](#repeat-the-scan) found changes since the previous scan (see [Changes since the last scan](#changes-since-the-last-scan); only new hosts like a host that is up, a gone host or a changed MAC address like a host that is down). One notification per scan: up to three changes, one per line, otherwise the number of changes. Not for a scan that was started manually. The header shows the tool, the title the scanned input; the status icon is green for only new hosts, red for only gone hosts, half green / half red for both, orange for a changed MAC address. A click opens the tab of the scan. **Type:** `Boolean` diff --git a/Website/docs/application/ping-monitor.md b/Website/docs/application/ping-monitor.md index 146e087360..e18ceba209 100644 --- a/Website/docs/application/ping-monitor.md +++ b/Website/docs/application/ping-monitor.md @@ -80,9 +80,9 @@ Right-click a monitored host (anywhere except the chart) to open the context men When a monitored host changes its reachability state (up → down or down → up), a small notification popup can appear in the bottom-right corner of the primary screen, optionally accompanied by a system sound. -- The popup shows a status icon (green when the host is up, red when it is down), the hostname (or the IP address as a fallback) and the current status (**Host is up** / **Host is down**). +- The header shows the tool that sent the notification (**Ping Monitor** with its icon) and the time; below it a status icon (green when the host is up, red when it is down), the hostname (or the IP address as a fallback) and the current status (**Host is up** / **Host is down**). The [IP Scanner](./ip-scanner#show-notification-popup-on-changes) and the [Port Scanner](./port-scanner#show-notification-popup-on-changes) use the same notification. - Multiple notifications **stack** upwards, so several hosts changing state at once are all visible. -- **Click anywhere** on a notification to bring the main window to the front. Use the **×** button to dismiss a single notification without opening the main window. +- **Click anywhere** on a notification to bring the main window to the front and open the **Ping Monitor**. Use the **×** button to dismiss a single notification without opening the main window. - Each notification closes automatically after a configurable [duration](#display-duration-seconds); a thin progress bar at the bottom shows the remaining time. - To avoid noise from flapping hosts, a state change is only reported after a configurable number of consecutive successes ([Success threshold](#success-threshold)) or failures ([Failure threshold](#failure-threshold)). - The **initial** state of a host (established when monitoring starts) never triggers a notification or sound — only later transitions do. diff --git a/Website/docs/application/port-scanner.md b/Website/docs/application/port-scanner.md index 5bd4e85ada..eb83ef907c 100644 --- a/Website/docs/application/port-scanner.md +++ b/Website/docs/application/port-scanner.md @@ -313,7 +313,7 @@ Resolve the hostname for given IP addresses. ### Show notification popup on changes -Show a notification popup in the bottom-right corner of the primary screen when a [repeated scan](#repeat-the-scan) found changes since the previous scan (see [Changes since the last scan](#changes-since-the-last-scan); only ports that are open now like a host that is up, a port that is no longer open like a host that is down). One notification per scan: up to three changes, one per line, otherwise the number of changes. Not for a scan that was started manually. +Show a notification popup in the bottom-right corner of the primary screen when a [repeated scan](#repeat-the-scan) found changes since the previous scan (see [Changes since the last scan](#changes-since-the-last-scan); only ports that are open now like a host that is up, a port that is no longer open like a host that is down). One notification per scan: up to three changes, one per line, otherwise the number of changes. Not for a scan that was started manually. The header shows the tool, the title the scanned input; the status icon is green for only ports that are open now, red for only ports that are no longer open, half green / half red for both. A click opens the tab of the scan. **Type:** `Boolean` diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index 2e5111889d..450cfedeef 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -104,7 +104,7 @@ Release date: **xx.xx.2026** - IP addresses can be excluded from the scan with `!` in the host input (also in profiles and the run command), e.g. `10.0.0.0/24; !10.0.0.1; !10.0.0.200-254`. Works with IP addresses, ranges, subnets and hostnames (all IPv4 and IPv6 addresses of the hostname, nothing is scanned if the hostname can't be resolved); a message is shown if every IP address is excluded. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) - A scan of the same input as the last completed scan in the tab shows what changed: hosts that are **New** (up now, not before), **Gone** (up before, not now - added as down, also without **Show unreachable**) and **MAC changed** (another MAC address, tooltip with the previous one) in the new **Since last scan** column, counters next to the **Result** header (e.g. `2 New · 1 Gone`) and **Last scan at** with the time. Kept in memory while the tab is open; also in the search and the export (`Change`, `PreviousMACAddress`). [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) - New **Automatically scan every** (`1 min` to `1 h`) right of the **Result** header: repeats the scan after the interval, with a countdown to the next scan (`Next scan in 04:32`). The rows are updated in place (selection, scroll position and search stay), new hosts are added and hosts that are down now are shown as **Gone**. Stops when a scan is canceled, the input is changed or the tab is closed (a failed repeated scan is tried again after the interval); also runs while the tab is not shown. Per tab, the interval is saved. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) -- New **Notification** settings (**Show notification popup on changes**, **Play sound on changes**, **Display duration**, default off): a repeated scan (**Automatically scan every**) that found new or gone hosts or a changed MAC address shows one notification with the changes (up to three, otherwise the number), like the host up / down notification of the **Ping Monitor**. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) +- New **Notification** settings (**Show notification popup on changes**, **Play sound on changes**, **Display duration**, default off): a repeated scan (**Automatically scan every**) that found new or gone hosts or a changed MAC address shows one notification with the changes (up to three, otherwise the number) in the design of the **Ping Monitor** notification: green for only new hosts, red for only gone hosts, half green / half red for both, orange for a changed MAC address; a click opens the tab of the scan. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) **Port Scanner** @@ -137,7 +137,7 @@ Release date: **xx.xx.2026** - IP addresses can be excluded from the scan with `!` in the host input (also in profiles and the run command), e.g. `10.0.0.0/24; !10.0.0.1; !10.0.0.200-254`. Works with IP addresses, ranges, subnets and hostnames (all IPv4 and IPv6 addresses of the hostname, nothing is scanned if the hostname can't be resolved); a message is shown if every IP address is excluded. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) - A scan of the same hosts and ports as the last completed scan in the tab shows what changed in the new **Since last scan** column: **Now open** and **No longer open** (shown with its status, also without **Show closed ports**), counters next to the **Result** header (e.g. `1 Now open · 2 No longer open`) and **Last scan at** with the time. Kept in memory while the tab is open; also in the search and the export (`Change`). [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) - New **Automatically scan every** (`1 min` to `1 h`) right of the **Result** header: repeats the scan after the interval, with a countdown to the next scan (`Next scan in 04:32`). The rows are updated in place (selection, scroll position, collapsed hosts and search stay), new open ports are added and ports that are no longer open are marked. Stops when a scan is canceled, the hosts or ports are changed or the tab is closed (a failed repeated scan is tried again after the interval); also runs while the tab is not shown. Per tab, the interval is saved. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) -- New **Notification** settings (**Show notification popup on changes**, **Play sound on changes**, **Display duration**, default off): a repeated scan (**Automatically scan every**) that found ports that are now open or no longer open shows one notification with the changes (up to three, e.g. `server (10.0.0.5): 3389 (rdp) No longer open`, otherwise the number), like the host up / down notification of the **Ping Monitor**. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) +- New **Notification** settings (**Show notification popup on changes**, **Play sound on changes**, **Display duration**, default off): a repeated scan (**Automatically scan every**) that found ports that are now open or no longer open shows one notification with the changes (up to three, e.g. `server (10.0.0.5): 3389 (rdp) No longer open`, otherwise the number) in the design of the **Ping Monitor** notification: green for only ports open now, red for only ports no longer open, half green / half red for both; a click opens the tab of the scan. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) **Ping Monitor** @@ -148,6 +148,7 @@ Release date: **xx.xx.2026** - Host input now accepts newline-separated hosts (one per line, e.g. a column pasted from Excel), converted automatically to the semicolon-separated form. [#3568](https://github.com/BornToBeRoot/NETworkManager/pull/3568) - Host input now supports shorthand IPv4 ranges like `192.168.0.1-100`, in addition to the existing `192.168.0.0-192.168.0.100` and `192.168.[0-100].1` range formats. [#3568](https://github.com/BornToBeRoot/NETworkManager/pull/3568) - IP addresses can be excluded with `!` in the host input (also in profiles and the run command), e.g. `10.0.0.0/24; !10.0.0.1; !10.0.0.200-254`. Works with IP addresses, ranges, subnets and hostnames (all IPv4 and IPv6 addresses of the hostname, nothing is scanned if the hostname can't be resolved); a message is shown if every IP address is excluded. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) +- The notification shows the tool that sent it (**Ping Monitor** with its icon) in its header, a click opens the **Ping Monitor** (not only the main window). Down is shown in the red of the application. Notifications of the **IP Scanner** and **Port Scanner** use the same design. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) **Traceroute** From 462e7c8b6d904e131546aa6af3c02e23a7607353 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Sun, 1 Nov 2026 03:02:27 +0100 Subject: [PATCH 113/131] Changelog: Shorten / consolidate the entries of this branch One entry per tool for the refresh changes (status in the header + faster refresh), IP Scanner: status icons + row details and the Options button + the disabled port scan settings merged; long entries (web pages, SNMP, local subnet, mDNS) shortened - details are in the docs. Co-Authored-By: Claude Opus 5.5 --- Website/docs/changelog/next-release.md | 68 ++++++++++++-------------- 1 file changed, 30 insertions(+), 38 deletions(-) diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index 450cfedeef..60f8d3a7ee 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -87,24 +87,22 @@ Release date: **xx.xx.2026** - Closed ports are reported immediately. Windows repeated the connection request after the host refused it and reported a closed port only after about 2 seconds. The connection request is sent once. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) - New **Attempts** setting for the port scan (default `1`): another connection request is sent if there was no answer, e.g. on an unreliable network (Wi-Fi, VPN) or for hosts on the internet. Open and closed ports are not repeated. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) - Added **RDAP...** to the context menu to query the registration data of the IP address or hostname of a host in the [**RDAP**](../application/rdap.md) tool. [#3642](https://github.com/BornToBeRoot/NETworkManager/pull/3642) -- The probes a host answered (**Ping**, **TCP**, **NetBIOS**) are shown in the tooltip of the status icon (e.g. `Up (detected by Ping, TCP)`) and as status icon (check / cross, like the action of a rule in the **Firewall**) in the header of the probe in the row details, e.g. to see why a host is up. The export contains them as `DetectedBy`. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) -- New **Ping hosts** setting (default on) to scan without ping, e.g. if ICMP is blocked or rate limited in the network: hosts are found by their ports or NetBIOS answer, and the scan is faster because an unreachable host doesn't wait for the ping attempts. At least one of ping, port scan, NetBIOS or SNMP must be enabled. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) -- New **Find hosts in the local subnet** setting (default on): hosts in a local subnet that don't answer the ping, a port or NetBIOS (e.g. a Windows client in a public network, a phone or an IoT device with a firewall that drops everything) are found by their ARP / NDP entry, which Windows creates when the probes resolve the MAC address of the host. No additional packets are sent, except to check an entry from earlier traffic again (also one that was already reachable before the probes, e.g. of a device that was switched off right before a rescan). Answers with the MAC address of the default gateway (proxy ARP) are ignored. The setting and **Resolve MAC address and vendor** are in the new **ARP / NDP** section of the settings, both with a help icon that explains the difference (finding hosts vs. showing the MAC address of found hosts). [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) -- New **OS (guess)** column with the operating system guessed from the TTL of the ping reply (icon and text: `Linux / Unix`, `Windows` or `Network device`, the tooltip shows the number of hops), can be disabled with the new setting **Guess the operating system from the TTL** (default on). No additional packets are sent; IPv4 only. Also in the search, **Copy** and the export (`OSGuess`). [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) -- An icon in the result marks this computer, the default gateway and the DNS and DHCP servers of the connected network interfaces (all roles in the tooltip), e.g. to find the router in a `/24`. Also in the search and the export (`Role`). [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) -- New **Resolve mDNS hostname** setting (default off, with **Timeout**): the mDNS hostname (e.g. `macbook.local`) of reachable hosts is resolved with a reverse lookup sent to UDP port `5353` of the host (Apple devices, Linux with Avahi, Android, printers, IoT devices). Shown in the row details (new **Hostnames** section with the DNS, mDNS and NetBIOS name) and used as **Hostname** if there is no DNS hostname (before the NetBIOS name). Also in the search, **Copy** and the export (`MDNSHostname`). [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) -- New **Read web pages** setting (default off, with **Timeout**): the web page (`GET /`) of reachable hosts with an open web port is read - the title is shown in the new **Web** column (URL in the tooltip, `Server` header if the page has no title), e.g. to identify routers, printers and NAS. The row details (new **Web** section) show the URL, the HTTP status code, the title, the `Server` and `X-Powered-By` headers, the realm of a login prompt (`WWW-Authenticate`, often the model of a router or camera) the target of a redirect to another host and, for https, the TLS certificate (subject, issuer or `Self-signed`, valid until, `(expired)`). The ports are configurable (**Ports**, default `443; 80; 8443; 8080`, tried in this order, with a button to restore the default); only ports that are open in the port scan are requested (the setting is disabled while **Scan ports** is disabled), https on `443` / `8443`, http on `80` / `8080`, https and then http on other ports. Redirects on the same host are followed, certificate errors are accepted, no cookies or credentials are sent. **Web Console** in the context menu opens the port that answered. Also in the search, **Copy** > **Web** (title, URL, server) and the export (`WebTitle`, `WebURL`, `WebStatusCode`, `WebServer`, `WebPoweredBy`, `WebRealm`, `WebRedirectURL`, `WebCertificateSubject`, `WebCertificateIssuer`, `WebCertificateNotAfter`, `WebCertificateSelfSigned`). [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) -- New **Read the SNMP system information** setting (default off, with **Version** `v1` / `v2c`, **Community** and **Timeout**): name, description and location (`sysName`, `sysDescr`, `sysLocation`) are read with the other probes from every IP address - a host that answers is shown as up (**Detected by** `SNMP`), also if it blocks the ping and all ports - and shown in the row details (new **SNMP** section), the name also in the new **SNMP name** column, e.g. to identify switches and printers. The community is saved as plain text in the settings (warning icon next to the field, use a read-only community). Also in the search, **Copy** > **SNMP** and the export (`SNMPName`, `SNMPDescription`, `SNMPLocation`). [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) -- The **Hostname** and **MAC address** / **Vendor** columns (and the new **Web** and **SNMP name** columns and the new row details) show `...` while the lookup / request is running instead of `-/-`, like the ping, port and NetBIOS columns, so a missing value can be told apart from one that is still resolved. **Hostname** and **MAC address** / **Vendor** show `...` until all their sources are finished (DNS, mDNS and NetBIOS name / neighbor table and NetBIOS). **Copy** > **Hostname** / **MAC address** / **Vendor** is disabled until the value arrived. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) -- The **Ping status**, **Port status** and **NetBIOS reachable** columns show a status icon (green check / red cross, like the action of a rule in the **Firewall**) instead of the text (e.g. `Success` / `Timed out`, `Open` / `Closed`, `Yes` / `No`); the tooltip shows the details: ping status with time and TTL, the open ports with their service and the NetBIOS computer name and workgroup / domain. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) -- The row details use four columns: **Ping details** / **Hostnames**, **Ports**, **Web** and **NetBIOS** / **SNMP**; a section of a disabled setting shows a note like the ports and NetBIOS. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) -- Added an **Options** button next to **Scan** to turn the probes and lookups (ping, hostname, port scan, NetBIOS, ARP / NDP, MAC address, mDNS, web pages, SNMP), **Show unreachable IP addresses and ports** and **Expand host view** on or off without opening the settings, and to open the settings. The options are the settings: a change applies to all tabs and is shown in the settings. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) -- The settings of the port scan (**Ports**, **Timeout**, **Attempts**) are disabled while **Scan ports** is disabled, like the settings of the other probes. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) -- The **Detect local subnet** button opens a menu: **Detect local subnet** (as before), **Subnet of network interface** to select the IPv4 subnet of a specific network interface (e.g. LAN, Wi-Fi, VPN, Hyper-V) and **All local subnets** to scan the subnets of all network interfaces. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) -- IP addresses can be excluded from the scan with `!` in the host input (also in profiles and the run command), e.g. `10.0.0.0/24; !10.0.0.1; !10.0.0.200-254`. Works with IP addresses, ranges, subnets and hostnames (all IPv4 and IPv6 addresses of the hostname, nothing is scanned if the hostname can't be resolved); a message is shown if every IP address is excluded. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) -- A scan of the same input as the last completed scan in the tab shows what changed: hosts that are **New** (up now, not before), **Gone** (up before, not now - added as down, also without **Show unreachable**) and **MAC changed** (another MAC address, tooltip with the previous one) in the new **Since last scan** column, counters next to the **Result** header (e.g. `2 New · 1 Gone`) and **Last scan at** with the time. Kept in memory while the tab is open; also in the search and the export (`Change`, `PreviousMACAddress`). [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) -- New **Automatically scan every** (`1 min` to `1 h`) right of the **Result** header: repeats the scan after the interval, with a countdown to the next scan (`Next scan in 04:32`). The rows are updated in place (selection, scroll position and search stay), new hosts are added and hosts that are down now are shown as **Gone**. Stops when a scan is canceled, the input is changed or the tab is closed (a failed repeated scan is tried again after the interval); also runs while the tab is not shown. Per tab, the interval is saved. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) -- New **Notification** settings (**Show notification popup on changes**, **Play sound on changes**, **Display duration**, default off): a repeated scan (**Automatically scan every**) that found new or gone hosts or a changed MAC address shows one notification with the changes (up to three, otherwise the number) in the design of the **Ping Monitor** notification: green for only new hosts, red for only gone hosts, half green / half red for both, orange for a changed MAC address; a click opens the tab of the scan. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) +- The tooltip of the status icon shows the probes the host answered (e.g. `Up (detected by Ping, TCP)`), also as icons in the row details and in the export (`DetectedBy`). [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) +- New **Ping hosts** setting (default on) to scan without ping, e.g. if ICMP is blocked: hosts are found by their ports, NetBIOS or SNMP answer, and unreachable hosts don't wait for the ping. At least one probe must be enabled. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) +- New **Find hosts in the local subnet** setting (default on): hosts in a local subnet that answer no probe (e.g. a Windows client in a public network, a phone, an IoT device) are found by their ARP / NDP entry, without additional packets; proxy ARP of the gateway is ignored. In the new **ARP / NDP** section of the settings, with **Resolve MAC address and vendor**. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) +- New **OS (guess)** column: the operating system guessed from the TTL of the ping reply (`Linux / Unix`, `Windows`, `Network device`; IPv4, no additional packets), setting **Guess the operating system from the TTL** (default on). Also in the search, **Copy** and the export (`OSGuess`). [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) +- An icon marks this computer, the default gateway and the DNS and DHCP servers of the connected network interfaces (all roles in the tooltip), e.g. to find the router. Also in the search and the export (`Role`). [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) +- New **Resolve mDNS hostname** setting (default off): the `.local` name of reachable hosts (Apple, Linux with Avahi, Android, printers, IoT devices), used as **Hostname** if there is no DNS name. The new **Hostnames** section of the row details shows the DNS, mDNS and NetBIOS name. Also in the search, **Copy** and the export (`MDNSHostname`). [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) +- New **Read web pages** setting (default off): the web page (`GET /`) of reachable hosts is read from their open web ports (**Ports**, default `443; 80; 8443; 8080`, needs the port scan), e.g. to identify routers, printers and NAS. The title (or the realm of a login prompt, the `Server` header, a redirect) is shown in the new **Web** column; URL, status code, headers and the TLS certificate (issuer or `Self-signed`, expiry) in the row details. No cookies or credentials are sent. **Web Console** in the context menu opens the port that answered. Also in the search, **Copy** > **Web** and the export (`Web…`). [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) +- New **Read the SNMP system information** setting (default off, `v1` / `v2c`): name, description and location of every IP address, e.g. to identify switches and printers; a host that answers is up, also if it blocks ping and ports. The name is shown in the new **SNMP name** column, all values in the row details. The community is saved as plain text (use a read-only one). Also in the search, **Copy** > **SNMP** and the export (`SNMP…`). [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) +- Values that are still being resolved show `...` instead of `-/-` (**Hostname** and **MAC address** / **Vendor** until all their sources are finished, the new columns and row details), so a missing value can be told apart from a pending one. **Copy** is disabled until the value arrived. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) +- **Ping status**, **Port status** and **NetBIOS reachable** show a status icon instead of the text, the details in the tooltip (e.g. `Success (2 ms, TTL 64)`, the open ports). The row details use four columns (**Ping details** / **Hostnames**, **Ports**, **Web**, **NetBIOS** / **SNMP**); a section of a disabled setting shows a note. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) +- New **Options** button next to **Scan**: turns the probes and lookups, **Show unreachable IP addresses and ports** and **Expand host view** on or off (the same settings as on the settings page, for all tabs) and opens the settings. On the settings page, the port scan settings and **Read web pages** are disabled while **Scan ports** is disabled. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) +- **Detect local subnet** opens a menu: the local subnet (as before), **Subnet of network interface** (e.g. LAN, Wi-Fi, VPN, Hyper-V) and **All local subnets**. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) +- IP addresses can be excluded with `!` in the host input (also in profiles and the run command), e.g. `10.0.0.0/24; !10.0.0.1; !10.0.0.200-254`: IP addresses, ranges, subnets and hostnames (nothing is scanned if an excluded hostname can't be resolved). [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) +- Changes since the last completed scan of the same input in the tab: **New**, **Gone** (shown as down) and **MAC changed** (the previous MAC address in the tooltip) in the new **Since last scan** column, counters next to **Result** (e.g. `2 New · 1 Gone`) and **Last scan at**. Kept while the tab is open; also in the search and the export (`Change`, `PreviousMACAddress`). [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) +- New **Automatically scan every** (`1 min` to `1 h`, with a countdown to the next scan): repeats the scan and updates the rows in place (selection, scroll position and search stay). Stops when a scan is canceled, the input is changed or the tab is closed; also runs while the tab is hidden. Per tab, the interval is saved. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) +- New **Notification** settings (popup, sound, display duration; default off): a repeated scan with changes shows one notification like the **Ping Monitor** (up to three changes, otherwise the counters) - green for only new hosts, red for only gone hosts, half green / half red for both, orange for a changed MAC address; a click opens the tab. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) **Port Scanner** @@ -134,10 +132,10 @@ Release date: **xx.xx.2026** - Groups: new **Ports** setting (ports, port ranges and port profiles) for the Port Scanner, used by the profiles of the group that don't override them (new **Ports** checkbox in the profile, like the other settings that can be set in the group). Existing profiles with ports keep them (the checkbox is enabled when the profile file is loaded). [#3667](https://github.com/BornToBeRoot/NETworkManager/pull/3667) - The export runs in the background, so the window stays responsive while a large result is exported, and uses the order shown in the result (sorting, grouping) instead of the order in which the ports were scanned. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) - Ports are started one after another (every 1 ms, shorter with short timeouts and many threads) instead of all at the same moment. This avoids bursts of hundreds of connection requests within a few milliseconds and makes the progress smoother. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) -- IP addresses can be excluded from the scan with `!` in the host input (also in profiles and the run command), e.g. `10.0.0.0/24; !10.0.0.1; !10.0.0.200-254`. Works with IP addresses, ranges, subnets and hostnames (all IPv4 and IPv6 addresses of the hostname, nothing is scanned if the hostname can't be resolved); a message is shown if every IP address is excluded. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) -- A scan of the same hosts and ports as the last completed scan in the tab shows what changed in the new **Since last scan** column: **Now open** and **No longer open** (shown with its status, also without **Show closed ports**), counters next to the **Result** header (e.g. `1 Now open · 2 No longer open`) and **Last scan at** with the time. Kept in memory while the tab is open; also in the search and the export (`Change`). [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) -- New **Automatically scan every** (`1 min` to `1 h`) right of the **Result** header: repeats the scan after the interval, with a countdown to the next scan (`Next scan in 04:32`). The rows are updated in place (selection, scroll position, collapsed hosts and search stay), new open ports are added and ports that are no longer open are marked. Stops when a scan is canceled, the hosts or ports are changed or the tab is closed (a failed repeated scan is tried again after the interval); also runs while the tab is not shown. Per tab, the interval is saved. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) -- New **Notification** settings (**Show notification popup on changes**, **Play sound on changes**, **Display duration**, default off): a repeated scan (**Automatically scan every**) that found ports that are now open or no longer open shows one notification with the changes (up to three, e.g. `server (10.0.0.5): 3389 (rdp) No longer open`, otherwise the number) in the design of the **Ping Monitor** notification: green for only ports open now, red for only ports no longer open, half green / half red for both; a click opens the tab of the scan. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) +- IP addresses can be excluded with `!` in the host input (also in profiles and the run command), e.g. `10.0.0.0/24; !10.0.0.1; !10.0.0.200-254`: IP addresses, ranges, subnets and hostnames (nothing is scanned if an excluded hostname can't be resolved). [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) +- Changes since the last completed scan of the same hosts and ports in the tab: **Now open** and **No longer open** (shown with its status, also without **Show closed ports**) in the new **Since last scan** column, counters next to **Result** and **Last scan at**. Kept while the tab is open; also in the search and the export (`Change`). [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) +- New **Automatically scan every** (`1 min` to `1 h`, with a countdown to the next scan): repeats the scan and updates the rows in place (selection, scroll position, collapsed hosts and search stay). Stops when a scan is canceled, the hosts or ports are changed or the tab is closed; also runs while the tab is hidden. Per tab, the interval is saved. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) +- New **Notification** settings (popup, sound, display duration; default off): a repeated scan with changes shows one notification like the **Ping Monitor** (up to three ports, e.g. `server (10.0.0.5): 3389 (rdp) No longer open`, otherwise the counters) - green for only ports open now, red for only ports no longer open, half green / half red for both; a click opens the tab. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) **Ping Monitor** @@ -147,8 +145,8 @@ Release date: **xx.xx.2026** - Reworked each monitored host's card: the collapsed quick info now shows labeled values (`Received: X · Lost: Y · Packet loss: Z%`) instead of unlabeled numbers, the expanded view shows only the latency chart (with more room, since Hostname/IP address are already visible in the header) and the last status change time moved to a tooltip on the connectivity icon. The **Status change** field was also renamed to **Last status change**. [#3572](https://github.com/BornToBeRoot/NETworkManager/pull/3572) - Host input now accepts newline-separated hosts (one per line, e.g. a column pasted from Excel), converted automatically to the semicolon-separated form. [#3568](https://github.com/BornToBeRoot/NETworkManager/pull/3568) - Host input now supports shorthand IPv4 ranges like `192.168.0.1-100`, in addition to the existing `192.168.0.0-192.168.0.100` and `192.168.[0-100].1` range formats. [#3568](https://github.com/BornToBeRoot/NETworkManager/pull/3568) -- IP addresses can be excluded with `!` in the host input (also in profiles and the run command), e.g. `10.0.0.0/24; !10.0.0.1; !10.0.0.200-254`. Works with IP addresses, ranges, subnets and hostnames (all IPv4 and IPv6 addresses of the hostname, nothing is scanned if the hostname can't be resolved); a message is shown if every IP address is excluded. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) -- The notification shows the tool that sent it (**Ping Monitor** with its icon) in its header, a click opens the **Ping Monitor** (not only the main window). Down is shown in the red of the application. Notifications of the **IP Scanner** and **Port Scanner** use the same design. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) +- IP addresses can be excluded with `!` in the host input (also in profiles and the run command), e.g. `10.0.0.0/24; !10.0.0.1; !10.0.0.200-254`: IP addresses, ranges, subnets and hostnames (nothing is scanned if an excluded hostname can't be resolved). [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) +- The notification shows the tool in its header (icon and name), a click opens the **Ping Monitor**; down uses the red of the application. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) **Traceroute** @@ -166,8 +164,7 @@ Release date: **xx.xx.2026** - Multiple entries can now be selected to enable, disable or delete them at once, or to change their comment via **Edit entries...** (`F2`). The edit dialog switches to a bulk mode in which only the comment can be changed. [#3624](https://github.com/BornToBeRoot/NETworkManager/pull/3624) - Entries that don't change (e.g. enabling an already enabled entry, or confirming the edit dialog without changes) are no longer rewritten in the `hosts` file, so custom formatting such as tabs or alignment is kept. [#3624](https://github.com/BornToBeRoot/NETworkManager/pull/3624) - The search filters after a short delay while typing instead of on every key press (smoother with many entries), `Enter` filters right away. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) -- A manual refresh is faster: the wait of 2.5 seconds (so the refresh button can't be spammed) now runs while loading instead of before it, and the refresh icon spins until loading is finished. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) -- The time of the last refresh (`Reloaded at 14:32`) is shown in the header instead of a status line below the list (the tooltip shows the date and time). The line below the list only shows errors. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) +- The time of the last refresh (`Reloaded at 14:32`, the date in the tooltip) is shown in the header instead of a status line below the list; the line below the list only shows errors. A manual refresh is faster (the minimum of 2.5 seconds runs while loading, not before) and the refresh icon spins until it is finished. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) **Firewall** @@ -187,8 +184,7 @@ Release date: **xx.xx.2026** - The export now contains whether a rule is managed by NETworkManager and its source. [#3637](https://github.com/BornToBeRoot/NETworkManager/pull/3637) - Faster loading of the rules managed by NETworkManager if there are many of them (the ports, addresses, programs etc. of all rules are queried at once instead of per rule). [#3637](https://github.com/BornToBeRoot/NETworkManager/pull/3637) - `Enter` in the search box filters the rules right away instead of after the delay while typing. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) -- A manual refresh is faster: the wait of 2.5 seconds (so the refresh button can't be spammed) now runs while loading instead of before it, and the refresh icon spins until the rules are loaded (also if this takes longer, e.g. with **Show all rules**). [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) -- The time of the last refresh (`Reloaded at 14:32`) is shown in the header instead of a status line below the list (the tooltip shows the date and time). The line below the list only shows errors (e.g. rules loaded with errors, a failed action). [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) +- The time of the last refresh (`Reloaded at 14:32`, the date in the tooltip) is shown in the header instead of a status line below the list; the line below the list only shows errors (e.g. rules loaded with errors, a failed action). A manual refresh is faster (the minimum of 2.5 seconds runs while loading, not before) and the refresh icon spins until the rules are loaded (also with **Show all rules**). [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) **Profiles** @@ -197,30 +193,26 @@ Release date: **xx.xx.2026** **Connections** - The search filters after a short delay while typing instead of on every key press (smoother with many connections), `Enter` filters right away. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) -- A manual refresh is faster: the wait of 2.5 seconds (so the refresh button can't be spammed) now runs while loading instead of before it, and the refresh icon spins until loading is finished. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) -- The time of the last refresh (`Reloaded at 14:32`) is shown in the header instead of a status line below the list; with **Automatically update every**, a countdown to the next refresh (`Next refresh in 00:04`). The tooltip shows the date and time. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) +- The time of the last refresh (`Reloaded at 14:32`, the date in the tooltip) is shown in the header instead of a status line below the list; with **Automatically update every**, a countdown to the next refresh (`Next refresh in 00:04`). A manual refresh is faster (the minimum of 2.5 seconds runs while loading, not before) and the refresh icon spins until it is finished. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) **Listeners** - The search filters after a short delay while typing instead of on every key press (smoother with many listeners), `Enter` filters right away. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) -- A manual refresh is faster: the wait of 2.5 seconds (so the refresh button can't be spammed) now runs while loading instead of before it, and the refresh icon spins until loading is finished. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) -- The time of the last refresh (`Reloaded at 14:32`) is shown in the header instead of a status line below the list; with **Automatically update every**, a countdown to the next refresh (`Next refresh in 00:04`). The tooltip shows the date and time. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) +- The time of the last refresh (`Reloaded at 14:32`, the date in the tooltip) is shown in the header instead of a status line below the list; with **Automatically update every**, a countdown to the next refresh (`Next refresh in 00:04`). A manual refresh is faster (the minimum of 2.5 seconds runs while loading, not before) and the refresh icon spins until it is finished. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) **Neighbor Table** - The search filters after a short delay while typing instead of on every key press (smoother with many entries), `Enter` filters right away. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) -- A manual refresh is faster: the wait of 2.5 seconds (so the refresh button can't be spammed) now runs while loading instead of before it, and the refresh icon spins until loading is finished. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) -- The time of the last refresh (`Reloaded at 14:32`) is shown in the header instead of a status line below the list; with **Automatically update every**, a countdown to the next refresh (`Next refresh in 00:04`). The tooltip shows the date and time. The line below the list only shows errors. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) +- The time of the last refresh (`Reloaded at 14:32`, the date in the tooltip) is shown in the header instead of a status line below the list; with **Automatically update every**, a countdown to the next refresh (`Next refresh in 00:04`). The line below the list only shows errors. A manual refresh is faster (the minimum of 2.5 seconds runs while loading, not before) and the refresh icon spins until it is finished. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) **WiFi** - The search filters after a short delay while typing instead of on every key press, `Enter` filters right away. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) -- Reloading the network adapters is faster: the wait of 2.5 seconds (so the reload button can't be spammed) now runs while loading instead of before it, and the reload icon spins until loading is finished. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) -- The time of the last scan (`Last scan at 14:32`) is shown in the header of the networks instead of the status line below; with **Automatically update every**, a countdown to the next scan (`Next refresh in 00:04`). The tooltip shows the date and time. The line below only shows errors and the connection status; "Searching for networks..." is gone (the search icon spins). [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) +- The time of the last scan of the networks (`Last scan at 14:32`, the date in the tooltip) is shown in their header, with **Automatically update every** a countdown to the next scan; the line below only shows errors and the connection status. Reloading the network adapters is faster (the minimum of 2.5 seconds runs while loading) and the reload icon spins until it is finished. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) **Dashboard** -- Checking the **IP geolocation** and the **DNS resolver** is faster: the wait of 2.5 seconds (so the check can't be spammed) now runs while the request is sent instead of before it. The refresh icons (also of **Network connection**) spin until the check is finished. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) +- Checking the **IP geolocation** and the **DNS resolver** is faster (the minimum of 2.5 seconds runs during the request, not before); the refresh icons spin until the check is finished. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) ## Bug Fixes @@ -420,7 +412,7 @@ Release date: **xx.xx.2026** **Hosts File Editor** -- Fixed the retry of a locked hosts file waiting 5 seconds between the attempts instead of the 2 seconds in the message, and 2.5 seconds after the last attempt. It waits 2.5 seconds between the attempts now (the message says so), not after the last one. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) +- Fixed the retry of a locked hosts file waiting 5 seconds between the attempts (the message said 2) and once more after the last attempt. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) ## Dependencies, Refactoring & Documentation From df7e96ddf3c0b8b72927146b1efd1e5cbb16fbd9 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Sun, 1 Nov 2026 14:47:42 +0100 Subject: [PATCH 114/131] Fix: Exclusion of a hostname: a failed A or AAAA query excluded only some addresses (Copilot review) A timeout / SERVFAIL of one query became an empty list, so a successful query of the other family returned a partial result and the address that should be excluded could be scanned. A failed query now rejects the exclusion (nothing is scanned); NXDOMAIN still counts as no record. Co-Authored-By: Claude Opus 5.5 --- .../Network/HostRangeHelper.cs | 4 +++- Source/NETworkManager.Utilities/DNSClient.cs | 18 ++++++++++++------ Website/docs/application/ip-scanner.md | 2 +- Website/docs/application/ping-monitor.md | 2 +- Website/docs/application/port-scanner.md | 2 +- 5 files changed, 18 insertions(+), 10 deletions(-) diff --git a/Source/NETworkManager.Models/Network/HostRangeHelper.cs b/Source/NETworkManager.Models/Network/HostRangeHelper.cs index 7c109b5b3f..f937b30693 100644 --- a/Source/NETworkManager.Models/Network/HostRangeHelper.cs +++ b/Source/NETworkManager.Models/Network/HostRangeHelper.cs @@ -454,7 +454,9 @@ private static async Task> CreateExclusionAsync(string hos var ipAddresses = await DNSClient.GetInstance().ResolveAllAAndAaaaAsync(input.Hostname) .WaitAsync(cancellationToken).ConfigureAwait(false); - if (ipAddresses.Count == 0) + // A failed query (e.g. the A query timed out, the AAAA query returned records) - not all addresses + // of the host are known, an address that should be excluded could be scanned + if (ipAddresses == null || ipAddresses.Count == 0) return null; var ipAddressExclusions = ipAddresses.Select(CreateIPAddressExclusion).ToList(); diff --git a/Source/NETworkManager.Utilities/DNSClient.cs b/Source/NETworkManager.Utilities/DNSClient.cs index 08a3d5c1a5..eec198ce2f 100644 --- a/Source/NETworkManager.Utilities/DNSClient.cs +++ b/Source/NETworkManager.Utilities/DNSClient.cs @@ -179,7 +179,10 @@ public async Task ResolveAaaaAsync(string query) /// of a host. Both queries are sent at the same time. /// /// Hostname or FQDN as string like "example.com". - /// IP addresses of the host (A records first), empty if none could be resolved (error or no record). + /// + /// IP addresses of the host (A records first), empty if the host has no record. Null if one of the queries + /// failed (e.g. timeout, SERVFAIL) - the addresses of the host are then not completely known. + /// public async Task> ResolveAllAAndAaaaAsync(string query) { if (!_isConfigured) @@ -192,7 +195,7 @@ public async Task> ResolveAllAAndAaaaAsync(string query) var results = await Task.WhenAll(QueryIPAddressesAsync(query, QueryType.A, state), QueryIPAddressesAsync(query, QueryType.AAAA, state)); - return [.. results.SelectMany(x => x)]; + return results.Any(x => x == null) ? null : [.. results.SelectMany(x => x)]; } /// @@ -201,7 +204,9 @@ public async Task> ResolveAllAAndAaaaAsync(string query) /// Hostname or FQDN (with the DNS suffix, if configured). /// or . /// Resolver state snapshot captured at the start of the resolve call. - /// IP addresses of the records, empty on an error. + /// + /// IP addresses of the records, empty if there is no record (incl. NXDOMAIN), null if the query failed. + /// private static async Task> QueryIPAddressesAsync(string query, QueryType queryType, ResolverState state) { @@ -209,8 +214,9 @@ private static async Task> QueryIPAddressesAsync(string query, Q { var result = await state.Client.QueryAsync(query, queryType); + // NXDOMAIN: the host has no record (not a failure like a timeout or SERVFAIL) if (result.HasError) - return []; + return IsNotFoundResponseCode(result.Header.ResponseCode) ? [] : null; // Only the records of the type (e.g. not the CNAME records of the answer) return queryType == QueryType.A @@ -219,12 +225,12 @@ private static async Task> QueryIPAddressesAsync(string query, Q } catch (DnsResponseException) { - return []; + return null; } catch (Exception ex) { Log.Error($"Error while resolving {queryType} records (Query string is \"{query}\").", ex); - return []; + return null; } } diff --git a/Website/docs/application/ip-scanner.md b/Website/docs/application/ip-scanner.md index a8336f5f21..b5c8aea0cc 100644 --- a/Website/docs/application/ip-scanner.md +++ b/Website/docs/application/ip-scanner.md @@ -38,7 +38,7 @@ With the **IP Scanner** you can scan IP ranges, subnets and hostnames for active Multiple inputs can be combined with a semicolon (`;`). A column pasted from Excel (one entry per line) is converted to the semicolon-separated form automatically. -An input starting with `!` excludes its IP addresses from the other inputs, e.g. `10.0.0.0/24; !10.0.0.1; !10.0.0.200-254` (all formats of the table above; a hostname excludes all its IPv4 and IPv6 addresses, independent of the IPv4 preference). At least one input without `!` is required; if the exclusions remove every IP address, a message is shown instead. If the hostname of an exclusion can't be resolved, nothing is scanned (otherwise its IP addresses would be scanned). +An input starting with `!` excludes its IP addresses from the other inputs, e.g. `10.0.0.0/24; !10.0.0.1; !10.0.0.200-254` (all formats of the table above; a hostname excludes all its IPv4 and IPv6 addresses, independent of the IPv4 preference). At least one input without `!` is required; if the exclusions remove every IP address, a message is shown instead. If the hostname of an exclusion can't be resolved (also if only the IPv4 or the IPv6 lookup fails, e.g. a timeout), nothing is scanned (otherwise its IP addresses would be scanned). Example: `10.0.0.0/24; 10.0.[10-20].1; 10.0.0.100-199` diff --git a/Website/docs/application/ping-monitor.md b/Website/docs/application/ping-monitor.md index e18ceba209..5acb38874a 100644 --- a/Website/docs/application/ping-monitor.md +++ b/Website/docs/application/ping-monitor.md @@ -35,7 +35,7 @@ ICMP (Internet Control Message Protocol) is a network-layer protocol used to sen Multiple inputs can be combined with a semicolon (`;`). A column pasted from Excel (one entry per line) is converted to the semicolon-separated form automatically. -An input starting with `!` excludes its IP addresses from the other inputs, e.g. `10.0.0.0/24; !10.0.0.1; !10.0.0.200-254` (all formats of the table above; a hostname excludes all its IPv4 and IPv6 addresses, independent of the IPv4 preference). At least one input without `!` is required; if the exclusions remove every IP address, a message is shown instead. If the hostname of an exclusion can't be resolved, nothing is scanned (otherwise its IP addresses would be scanned). +An input starting with `!` excludes its IP addresses from the other inputs, e.g. `10.0.0.0/24; !10.0.0.1; !10.0.0.200-254` (all formats of the table above; a hostname excludes all its IPv4 and IPv6 addresses, independent of the IPv4 preference). At least one input without `!` is required; if the exclusions remove every IP address, a message is shown instead. If the hostname of an exclusion can't be resolved (also if only the IPv4 or the IPv6 lookup fails, e.g. a timeout), nothing is scanned (otherwise its IP addresses would be scanned). Example: `10.0.0.0/24; 10.0.[10-20].1; 10.0.0.100-199` diff --git a/Website/docs/application/port-scanner.md b/Website/docs/application/port-scanner.md index eb83ef907c..ce5c73721e 100644 --- a/Website/docs/application/port-scanner.md +++ b/Website/docs/application/port-scanner.md @@ -50,7 +50,7 @@ TCP (Transmission Control Protocol) is a connection-oriented transport-layer pro Multiple inputs can be combined with a semicolon (`;`). A column pasted from Excel (one entry per line) is converted to the semicolon-separated form automatically. -An input starting with `!` excludes its IP addresses from the other inputs, e.g. `10.0.0.0/24; !10.0.0.1; !10.0.0.200-254` (all formats of the table above; a hostname excludes all its IPv4 and IPv6 addresses, independent of the IPv4 preference). At least one input without `!` is required; if the exclusions remove every IP address, a message is shown instead. If the hostname of an exclusion can't be resolved, nothing is scanned (otherwise its IP addresses would be scanned). +An input starting with `!` excludes its IP addresses from the other inputs, e.g. `10.0.0.0/24; !10.0.0.1; !10.0.0.200-254` (all formats of the table above; a hostname excludes all its IPv4 and IPv6 addresses, independent of the IPv4 preference). At least one input without `!` is required; if the exclusions remove every IP address, a message is shown instead. If the hostname of an exclusion can't be resolved (also if only the IPv4 or the IPv6 lookup fails, e.g. a timeout), nothing is scanned (otherwise its IP addresses would be scanned). Example: `10.0.0.0/24; 10.0.[10-20].1; 10.0.0.100-199` or `1-1024; 8080; 8443` From 530c8e3785a5e865e6bfb5bb82644d80aa9a6e17 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Sun, 1 Nov 2026 14:49:33 +0100 Subject: [PATCH 115/131] IP Scanner / Port Scanner: Counters below the Result header, header no longer overflows (Copilot review) The counters (incl. the changes since the last scan), the last scan status, the repeat and the search were in one right-aligned horizontal StackPanel: wider than the view at 1024px (profiles open) and the status was measured with infinite width, so it was never trimmed. The header is now a grid like the other tools (status in the star column, trimmed first), the counters have their own line below it. Co-Authored-By: Claude Opus 5.5 --- .../NETworkManager/Views/IPScannerView.xaml | 172 +++++++++--------- .../NETworkManager/Views/PortScannerView.xaml | 166 +++++++++-------- Website/docs/application/ip-scanner.md | 4 +- Website/docs/application/port-scanner.md | 4 +- Website/docs/changelog/next-release.md | 14 +- 5 files changed, 191 insertions(+), 169 deletions(-) diff --git a/Source/NETworkManager/Views/IPScannerView.xaml b/Source/NETworkManager/Views/IPScannerView.xaml index 6479bbb0fe..2980522588 100644 --- a/Source/NETworkManager/Views/IPScannerView.xaml +++ b/Source/NETworkManager/Views/IPScannerView.xaml @@ -51,6 +51,7 @@ + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -221,90 +222,101 @@ Visibility="{Binding IsStatusMessageDisplayed, Converter={StaticResource BooleanToVisibilityCollapsedConverter}}" Style="{DynamicResource StatusMessageTextBlock}" Margin="0,10,0,0" /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + all exports the filtered results; the counters always count all ports. ### Changes since the last scan -A scan of the same hosts and ports as the last completed scan in the tab shows what changed since then in the **Since last scan** column: **Now open** (the port is open now, it was not open in the last scan) and **No longer open** (it was open in the last scan, it is closed or timed out now). A port that is no longer open is shown with its status, also if [Show closed ports](#show-closed-ports) is disabled; if its host was not scanned (e.g. it didn't answer the [ping before the scan](#ping-hosts-before-the-scan)), it is shown as timed out. The counters next to the **Result** header (e.g. `1 Now open · 2 No longer open`) and the column are only shown if the result is compared with a previous scan. **Last scan at** shows when the last scan was completed (the tooltip shows the date). +A scan of the same hosts and ports as the last completed scan in the tab shows what changed since then in the **Since last scan** column: **Now open** (the port is open now, it was not open in the last scan) and **No longer open** (it was open in the last scan, it is closed or timed out now). A port that is no longer open is shown with its status, also if [Show closed ports](#show-closed-ports) is disabled; if its host was not scanned (e.g. it didn't answer the [ping before the scan](#ping-hosts-before-the-scan)), it is shown as timed out. The counters below the **Result** header (e.g. `1 Now open · 2 No longer open`) and the column are only shown if the result is compared with a previous scan. **Last scan at** in the header shows when the last scan was completed (the tooltip shows the date). Only completed scans are compared: a canceled scan or a scan with other hosts or ports (also a changed port profile) starts over. **Rescan** updates the rows and their changes against the same previous scan (e.g. a port that is open again), but is not a scan of the input. The last scan is only kept in memory, as long as the tab is open. The change is also in the search and the export (`Change`). diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index 60f8d3a7ee..68c2716d4d 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -63,7 +63,7 @@ Release date: **xx.xx.2026** **IP Scanner** -- Added a live count of hosts up/down next to the **Result** header. Stays visible after the scan finishes. [#3572](https://github.com/BornToBeRoot/NETworkManager/pull/3572) +- Added a live count of hosts up/down below the **Result** header. Stays visible after the scan finishes. [#3572](https://github.com/BornToBeRoot/NETworkManager/pull/3572) - Host input now accepts newline-separated hosts (one per line, e.g. a column pasted from Excel), converted automatically to the semicolon-separated form. Thanks to [@dearmb](https://github.com/dearmb) [#3568](https://github.com/BornToBeRoot/NETworkManager/pull/3568) - Host input now supports shorthand IPv4 ranges like `192.168.0.1-100`, in addition to the existing `192.168.0.0-192.168.0.100` and `192.168.[0-100].1` range formats. [#3568](https://github.com/BornToBeRoot/NETworkManager/pull/3568) - Added `21` (FTP, e.g. NAS), `23` (Telnet, e.g. older switches), `135` (RPC), `554` (RTSP, e.g. IP cameras), `631` (IPP, printers), `5900` (VNC), `5985` (WinRM), `7000` (AirPlay), `8009` (Chromecast), `8080` / `8443` (web interfaces of devices and appliances), `9100` (raw printing) and `62078` (iPhone / iPad) to the default **Ports** list used to detect if a host is reachable, e.g. hosts that block ICMP. An unreachable host is still finished after about 4 seconds (22 ports with **Max. concurrent port threads** `16`, like 2 ICMP attempts). Settings that are still on the old default are updated. [#3564](https://github.com/BornToBeRoot/NETworkManager/pull/3564) [#3667](https://github.com/BornToBeRoot/NETworkManager/pull/3667) @@ -75,7 +75,7 @@ Release date: **xx.xx.2026** - The export runs in the background, so the window stays responsive while a large result is exported, and uses the order shown in the result (sorting) instead of the order in which the hosts were scanned. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) - The DNS hostname and the MAC address of a host are resolved at the same time instead of one after the other. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) - New **Expand host view** setting to show the details of every host in the result (also hosts added while the scan is running), like in the **Ping Monitor**. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) -- The up / down counter next to the **Result** header is translated as one text, so translations can put the number in a different position. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) +- The up / down counter below the **Result** header is translated as one text, so translations can put the number in a different position. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) - **Rescan** in the context menu scans the selected host(s) again with the settings of the shown scan and updates their rows, e.g. to check if a host is up again. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) - Added **Web Console** (https / http depending on the open ports) and **Wake on LAN** (with the MAC address) to the context menu. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) - Added a search box above the result to filter the hosts by IP address, hostname, MAC address, vendor and open ports, also while the scan is running. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) @@ -100,21 +100,21 @@ Release date: **xx.xx.2026** - New **Options** button next to **Scan**: turns the probes and lookups, **Show unreachable IP addresses and ports** and **Expand host view** on or off (the same settings as on the settings page, for all tabs) and opens the settings. On the settings page, the port scan settings and **Read web pages** are disabled while **Scan ports** is disabled. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) - **Detect local subnet** opens a menu: the local subnet (as before), **Subnet of network interface** (e.g. LAN, Wi-Fi, VPN, Hyper-V) and **All local subnets**. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) - IP addresses can be excluded with `!` in the host input (also in profiles and the run command), e.g. `10.0.0.0/24; !10.0.0.1; !10.0.0.200-254`: IP addresses, ranges, subnets and hostnames (nothing is scanned if an excluded hostname can't be resolved). [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) -- Changes since the last completed scan of the same input in the tab: **New**, **Gone** (shown as down) and **MAC changed** (the previous MAC address in the tooltip) in the new **Since last scan** column, counters next to **Result** (e.g. `2 New · 1 Gone`) and **Last scan at**. Kept while the tab is open; also in the search and the export (`Change`, `PreviousMACAddress`). [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) +- Changes since the last completed scan of the same input in the tab: **New**, **Gone** (shown as down) and **MAC changed** (the previous MAC address in the tooltip) in the new **Since last scan** column, counters below **Result** (e.g. `2 New · 1 Gone`) and **Last scan at**. Kept while the tab is open; also in the search and the export (`Change`, `PreviousMACAddress`). [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) - New **Automatically scan every** (`1 min` to `1 h`, with a countdown to the next scan): repeats the scan and updates the rows in place (selection, scroll position and search stay). Stops when a scan is canceled, the input is changed or the tab is closed; also runs while the tab is hidden. Per tab, the interval is saved. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) - New **Notification** settings (popup, sound, display duration; default off): a repeated scan with changes shows one notification like the **Ping Monitor** (up to three changes, otherwise the counters) - green for only new hosts, red for only gone hosts, half green / half red for both, orange for a changed MAC address; a click opens the tab. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) **Port Scanner** - Added a port status icon column to the results, matching the port icon used in the IP Scanner's extended port info, to indicate at a glance whether a port is open (green) or closed (red). [#3558](https://github.com/BornToBeRoot/NETworkManager/issues/3558) -- Added a live count of ports open/closed next to the **Result** header. Stays visible after the scan finishes. [#3572](https://github.com/BornToBeRoot/NETworkManager/pull/3572) +- Added a live count of ports open/closed below the **Result** header. Stays visible after the scan finishes. [#3572](https://github.com/BornToBeRoot/NETworkManager/pull/3572) - Host and Ports input fields now accept newline-separated entries (one per line, e.g. a column pasted from Excel), converted automatically to the semicolon-separated form. [#3568](https://github.com/BornToBeRoot/NETworkManager/pull/3568) - Host input now supports shorthand IPv4 ranges like `192.168.0.1-100`, in addition to the existing `192.168.0.0-192.168.0.100` and `192.168.[0-100].1` range formats. [#3568](https://github.com/BornToBeRoot/NETworkManager/pull/3568) - New defaults: **Timeout** `4000` → `2000` ms (still allows one retransmission of a lost connection request), **Max. concurrent host threads** `5` → `4` and **Max. concurrent port threads** `256` → `128`, which puts less simultaneous load on the scanned hosts (up to 512 instead of 1280 connections at the same time). Settings that are still on the old default are updated. [#3564](https://github.com/BornToBeRoot/NETworkManager/pull/3564) [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) - Closed ports are reported immediately. Windows repeated the connection request after the host refused it and reported a closed port only after about 2 seconds, so a scan of a host that refuses most ports is much faster now (e.g. `1-1024`: about 1 second instead of about 16 seconds). The connection request is sent once. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) - New **Attempts** setting (default `1`): another connection request is sent if there was no answer, e.g. on an unreliable network (Wi-Fi, VPN) or for hosts on the internet. Open and closed ports are not repeated. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) -- The counter next to the **Result** header shows timed out ports (no answer) separately: `Open · Closed · Timed out`. Before, they were counted as closed. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) -- The open / closed counter next to the **Result** header is translated as one text, so translations can put the number in a different position. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) +- The counter below the **Result** header shows timed out ports (no answer) separately: `Open · Closed · Timed out`. Before, they were counted as closed. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) +- The open / closed counter below the **Result** header is translated as one text, so translations can put the number in a different position. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) - **Rescan** in the context menu scans the selected port(s) again and updates their state in place; **Rescan host** in the context menu of a host (group header) scans the host again with all ports of the shown scan and replaces its results. The context menu of a host also opens it in other tools, adds it as a profile and copies its IP address, hostname or open ports (e.g. `22; 80; 443`). [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) - Added **Web Console** to the context menu of an open web port (`80`, `443`, `8080`, `8443`). [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) - Added **Ping Monitor**, **Traceroute**, **DNS lookup...**, **RDAP...**, **Remote Desktop**, **PowerShell**, **PuTTY**, **TigerVNC** and **SNMP** to the context menu to open the host (hostname, otherwise the IP address) in the tool, like in the **IP Scanner**. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) @@ -133,7 +133,7 @@ Release date: **xx.xx.2026** - The export runs in the background, so the window stays responsive while a large result is exported, and uses the order shown in the result (sorting, grouping) instead of the order in which the ports were scanned. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) - Ports are started one after another (every 1 ms, shorter with short timeouts and many threads) instead of all at the same moment. This avoids bursts of hundreds of connection requests within a few milliseconds and makes the progress smoother. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) - IP addresses can be excluded with `!` in the host input (also in profiles and the run command), e.g. `10.0.0.0/24; !10.0.0.1; !10.0.0.200-254`: IP addresses, ranges, subnets and hostnames (nothing is scanned if an excluded hostname can't be resolved). [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) -- Changes since the last completed scan of the same hosts and ports in the tab: **Now open** and **No longer open** (shown with its status, also without **Show closed ports**) in the new **Since last scan** column, counters next to **Result** and **Last scan at**. Kept while the tab is open; also in the search and the export (`Change`). [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) +- Changes since the last completed scan of the same hosts and ports in the tab: **Now open** and **No longer open** (shown with its status, also without **Show closed ports**) in the new **Since last scan** column, counters below **Result** and **Last scan at**. Kept while the tab is open; also in the search and the export (`Change`). [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) - New **Automatically scan every** (`1 min` to `1 h`, with a countdown to the next scan): repeats the scan and updates the rows in place (selection, scroll position, collapsed hosts and search stay). Stops when a scan is canceled, the hosts or ports are changed or the tab is closed; also runs while the tab is hidden. Per tab, the interval is saved. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) - New **Notification** settings (popup, sound, display duration; default off): a repeated scan with changes shows one notification like the **Ping Monitor** (up to three ports, e.g. `server (10.0.0.5): 3389 (rdp) No longer open`, otherwise the counters) - green for only ports open now, red for only ports no longer open, half green / half red for both; a click opens the tab. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) From 76b9ce6b7c1223ae54f02d9e1e64202205cefc3b Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Sun, 1 Nov 2026 15:02:36 +0100 Subject: [PATCH 116/131] =?UTF-8?q?Notifications:=20One=20line=20per=20cha?= =?UTF-8?q?nge=20"address=20=C2=B7=20details=20=C2=B7=20status",=20the=20s?= =?UTF-8?q?tatus=20in=20its=20color?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ping Monitor, IP Scanner and Port Scanner use the same syntax: the IP address (and hostname) first, then the details, the status last - no more ":" (e.g. "10.0.0.5 (server) · New", "10.0.0.5 (server) · 22/tcp (ssh) · No longer open" with the protocol, "10.0.0.1 · Up"). The status is shown in the color of its status (up green, down red, changed orange); more than three changes show one line per counter. Removes the unused strings HostIsUp / HostIsDown. Co-Authored-By: Claude Opus 5.5 --- .../Resources/Strings.Designer.cs | 18 ------- .../Resources/Strings.resx | 6 --- Source/NETworkManager/NotificationInfo.cs | 47 ++++++++++++++++-- Source/NETworkManager/NotificationManager.cs | 13 ++--- Source/NETworkManager/NotificationWindow.xaml | 22 ++++++--- .../NETworkManager/NotificationWindow.xaml.cs | 48 ++++++++++++++----- .../ViewModels/IPScannerViewModel.cs | 29 ++++++++--- .../ViewModels/PingMonitorViewModel.cs | 12 ++++- .../ViewModels/PortScannerViewModel.cs | 21 ++++---- Website/docs/application/ip-scanner.md | 2 +- Website/docs/application/ping-monitor.md | 6 +-- Website/docs/application/port-scanner.md | 2 +- Website/docs/changelog/next-release.md | 6 +-- 13 files changed, 157 insertions(+), 75 deletions(-) diff --git a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs index 73b026327b..dac245aa4c 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs +++ b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs @@ -6410,24 +6410,6 @@ public static string Hosting { } } - /// - /// Looks up a localized string similar to Host is down. - /// - public static string HostIsDown { - get { - return ResourceManager.GetString("HostIsDown", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Host is up. - /// - public static string HostIsUp { - get { - return ResourceManager.GetString("HostIsUp", resourceCulture); - } - } - /// /// Looks up a localized string similar to Hostkey. /// diff --git a/Source/NETworkManager.Localization/Resources/Strings.resx b/Source/NETworkManager.Localization/Resources/Strings.resx index c848ddbfdf..70fe9b2236 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.resx +++ b/Source/NETworkManager.Localization/Resources/Strings.resx @@ -2527,12 +2527,6 @@ is disabled! Display duration (seconds) - - Host is up - - - Host is down - The application will be restarted... diff --git a/Source/NETworkManager/NotificationInfo.cs b/Source/NETworkManager/NotificationInfo.cs index 8ec7adce25..2e63321a85 100644 --- a/Source/NETworkManager/NotificationInfo.cs +++ b/Source/NETworkManager/NotificationInfo.cs @@ -1,21 +1,24 @@ using NETworkManager.Models; +using System.Collections.Generic; +using System.Linq; +using System.Net; namespace NETworkManager; /// /// A notification of a tool (see ): the same design for every tool - the tool -/// in the header, the status as icon (and sound), the target as title and the message. +/// in the header, the status as icon (and sound), the target as title and one line per change. /// /// Tool that sends the notification (icon and name in the header). /// Status (icon, color and sound). /// Target, e.g. the host or the scanned input. -/// What happened, e.g. "Host is up" or the changes of a scan. +/// What happened, one line per change (e.g. "10.0.0.1 · Up"), see . /// Seconds before the popup closes automatically. public sealed record NotificationInfo( ApplicationName Application, NotificationStatus Status, string Title, - string Message, + IReadOnlyList Lines, int CloseTimeSeconds) { /// @@ -24,3 +27,41 @@ public sealed record NotificationInfo( /// public ApplicationRequest Request { get; init; } } + +/// +/// A line of a : what changed and its status, shown as "10.0.0.5 (server) · New" with +/// the status in its color (like the status icon). The same syntax for every tool: the address first, then the +/// details (e.g. "22/tcp (ssh)"), the status last. +/// +/// What changed, e.g. "10.0.0.5 (server)", null for a line with only the status (e.g. "5 New"). +/// Status text, e.g. "Up", "New" or "No longer open". +/// Status (color of the status text): up, down or changed. +public sealed record NotificationLine(string Text, string Status, NotificationStatus StatusKind) +{ + /// + /// Separator between the parts of a line (the address, the details and the status), like the counters of the + /// tools. + /// + public const string Separator = " · "; + + /// + /// Joins the parts of the text of a line (empty parts are skipped), e.g. "10.0.0.5 (server)" and "22/tcp (ssh)". + /// + /// Parts of the text. + /// Text of the line. + public static string JoinText(params string[] parts) + { + return string.Join(Separator, parts.Where(x => !string.IsNullOrEmpty(x))); + } + + /// + /// Formats an IP address with its hostname, if any, e.g. "10.0.0.5 (server)". + /// + /// IP address. + /// Hostname, null or empty if unknown. + /// IP address with the hostname. + public static string FormatHost(IPAddress ipAddress, string hostname) + { + return string.IsNullOrEmpty(hostname) ? $"{ipAddress}" : $"{ipAddress} ({hostname})"; + } +} diff --git a/Source/NETworkManager/NotificationManager.cs b/Source/NETworkManager/NotificationManager.cs index 4a452ed421..25172f3092 100644 --- a/Source/NETworkManager/NotificationManager.cs +++ b/Source/NETworkManager/NotificationManager.cs @@ -63,16 +63,17 @@ public static void Show(NotificationInfo notification, bool showPopup, bool play } /// - /// Formats a list of changes for the message of a notification: up to changes, one - /// per line, otherwise the summary (e.g. "5 New · 2 Gone"), so the popup stays small. + /// Gets the lines of a notification about changes: up to changes, one per line, + /// otherwise the summary (one line per counter, e.g. "5 New" and "2 Gone"), so the popup stays small. /// /// One line per change. - /// Summary of all changes. + /// Summary of all changes (counters without a value of 0). /// Maximum number of lines. - /// Message of the notification. - public static string FormatChanges(IReadOnlyCollection lines, string summary, int maxLines = 3) + /// Lines of the notification. + public static IReadOnlyList FormatChanges(IReadOnlyList lines, + IReadOnlyList summary, int maxLines = 3) { - return lines.Count <= maxLines ? string.Join(Environment.NewLine, lines) : summary; + return lines.Count <= maxLines ? lines : summary; } /// diff --git a/Source/NETworkManager/NotificationWindow.xaml b/Source/NETworkManager/NotificationWindow.xaml index 2ea5bda753..e3edf3f8da 100644 --- a/Source/NETworkManager/NotificationWindow.xaml +++ b/Source/NETworkManager/NotificationWindow.xaml @@ -90,7 +90,8 @@ - + @@ -113,11 +114,20 @@ - + + + + + + + + + diff --git a/Source/NETworkManager/NotificationWindow.xaml.cs b/Source/NETworkManager/NotificationWindow.xaml.cs index c6015114e1..a315eed52d 100644 --- a/Source/NETworkManager/NotificationWindow.xaml.cs +++ b/Source/NETworkManager/NotificationWindow.xaml.cs @@ -3,7 +3,9 @@ using NETworkManager.Models; using NETworkManager.Utilities; using System; +using System.Collections.Generic; using System.ComponentModel; +using System.Linq; using System.Diagnostics; using System.Runtime.CompilerServices; using System.Threading.Tasks; @@ -51,7 +53,7 @@ private void OnPropertyChanged([CallerMemberName] string propertyName = null) public PackIconMaterialKind IconKind { get; } public Brush IconBrush { get; } public string NotificationTitle { get; } - public string Message { get; } + public IReadOnlyList Lines { get; } // Timestamp shown in the header — captured when the notification is created, which is the // moment the status change occurred. @@ -94,7 +96,9 @@ public NotificationWindow(NotificationInfo notification) (IconKind, IconBrush) = GetStatusIcon(notification.Status); NotificationTitle = notification.Title; - Message = notification.Message; + Lines = [.. notification.Lines.Select(x => new NotificationLineItem( + string.IsNullOrEmpty(x.Text) ? string.Empty : x.Text + NotificationLine.Separator, x.Status, + GetStatusBrush(x.StatusKind)))]; Time = DateTime.Now.ToString("HH:mm:ss"); TimeMax = notification.CloseTimeSeconds; TimeRemaining = notification.CloseTimeSeconds; @@ -141,6 +145,27 @@ private void ShowMainWindow() /// Status of the notification. /// Icon and brush. private static (PackIconMaterialKind kind, Brush brush) GetStatusIcon(NotificationStatus status) + { + var brush = GetStatusBrush(status); + + var kind = status switch + { + NotificationStatus.Up => PackIconMaterialKind.LanConnect, + NotificationStatus.Down => PackIconMaterialKind.LanDisconnect, + NotificationStatus.UpAndDown => PackIconMaterialKind.Lan, + _ => PackIconMaterialKind.LanPending + }; + + return (kind, brush); + } + + /// + /// Gets the brush of a status (status icon, status text of a line): up green, down red, up and down half green / + /// half red (split along the diagonal), changed orange. + /// + /// Status. + /// Frozen brush. + private static Brush GetStatusBrush(NotificationStatus status) { Brush brush = status switch { @@ -156,15 +181,7 @@ private static (PackIconMaterialKind kind, Brush brush) GetStatusIcon(Notificati brush.Freeze(); - var kind = status switch - { - NotificationStatus.Up => PackIconMaterialKind.LanConnect, - NotificationStatus.Down => PackIconMaterialKind.LanDisconnect, - NotificationStatus.UpAndDown => PackIconMaterialKind.Lan, - _ => PackIconMaterialKind.LanPending - }; - - return (kind, brush); + return brush; } /// @@ -251,3 +268,12 @@ private async void Timer_Tick(object sender, EventArgs e) #endregion } + +/// +/// A line of the notification as shown: the text with the separator (empty if the line has only a status), the status +/// and its brush (see ). +/// +/// Text of the line with the separator, e.g. "10.0.0.5 (server) · ". +/// Status text, e.g. "New". +/// Brush of the status text. +public sealed record NotificationLineItem(string Prefix, string Status, Brush StatusBrush); diff --git a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs index eadd60a146..0c3c2ffa38 100644 --- a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs @@ -1888,16 +1888,18 @@ private void NotifyChanges() changes.Sort((x, y) => IPAddressHelper.CompareIPAddresses(x.IPAddress, y.IPAddress)); - var lines = changes.Select(x => - $"{(string.IsNullOrEmpty(x.Hostname) ? x.IPAddress : $"{x.IPAddress} ({x.Hostname})")}: " + - IPScannerHostChangeToStringConverter.ToLocalizedString(x.Change)).ToList(); + // "10.0.0.5 (server) · New" - new = up, gone = down, a changed MAC address = changed + var lines = changes.Select(x => new NotificationLine(NotificationLine.FormatHost(x.IPAddress, x.Hostname), + IPScannerHostChangeToStringConverter.ToLocalizedString(x.Change), GetNotificationStatus(x.Change))).ToList(); - var summary = string.Join(" · ", new[] + var summary = new[] { - (Count: HostsNew, Format: Strings.XNew), (Count: HostsGone, Format: Strings.XGone), - (Count: HostsMACChanged, Format: Strings.XMACChanged) + (Count: HostsNew, Format: Strings.XNew, Status: NotificationStatus.Up), + (Count: HostsGone, Format: Strings.XGone, Status: NotificationStatus.Down), + (Count: HostsMACChanged, Format: Strings.XMACChanged, Status: NotificationStatus.Changed) } - .Where(x => x.Count > 0).Select(x => string.Format(x.Format, x.Count))); + .Where(x => x.Count > 0) + .Select(x => new NotificationLine(null, string.Format(x.Format, x.Count), x.Status)).ToList(); // New = up, gone = down, both = up and down, a changed MAC address = changed var status = NotificationStatusHelper.FromChanges(HostsNew, HostsGone, HostsMACChanged); @@ -1911,6 +1913,19 @@ private void NotifyChanges() showPopup, playSound); } + /// + /// Gets the status of a change in a notification: new = up, gone = down, a changed MAC address = changed. + /// + private static NotificationStatus GetNotificationStatus(IPScannerHostChange change) + { + return change switch + { + IPScannerHostChange.New => NotificationStatus.Up, + IPScannerHostChange.Gone => NotificationStatus.Down, + _ => NotificationStatus.Changed + }; + } + /// /// Marks the changes of the rows against the baseline of the previous scan: new hosts and hosts with another MAC /// address, the hosts that are gone (a shown row is marked, otherwise a row is added). Updates the counters. diff --git a/Source/NETworkManager/ViewModels/PingMonitorViewModel.cs b/Source/NETworkManager/ViewModels/PingMonitorViewModel.cs index 32e80db49f..93eee43dcf 100644 --- a/Source/NETworkManager/ViewModels/PingMonitorViewModel.cs +++ b/Source/NETworkManager/ViewModels/PingMonitorViewModel.cs @@ -108,6 +108,12 @@ private set private string NotificationHostDisplay => DNSClientHelper.SanitizeHostname(Hostname) is { Length: > 0 } hostname ? hostname : IPAddress.ToString(); + /// + /// IP address in the line of a notification ("10.0.0.1 · Up"), null if the title already is the IP address. + /// + private string NotificationIPAddress => + NotificationHostDisplay == IPAddress.ToString() ? null : IPAddress.ToString(); + /// /// Gets the hostname of the monitored host. /// @@ -707,7 +713,8 @@ private void Ping_PingReceived(object sender, PingReceivedArgs e) IsReachable = true; NotificationManager.Show(new NotificationInfo(ApplicationName.PingMonitor, NotificationStatus.Up, - NotificationHostDisplay, Strings.HostIsUp, SettingsManager.Current.PingMonitor_NotificationCloseTime), + NotificationHostDisplay, [new NotificationLine(NotificationIPAddress, Strings.Up, NotificationStatus.Up)], + SettingsManager.Current.PingMonitor_NotificationCloseTime), SettingsManager.Current.PingMonitor_ShowNotificationPopup, SettingsManager.Current.PingMonitor_NotificationSound); } @@ -737,7 +744,8 @@ private void Ping_PingReceived(object sender, PingReceivedArgs e) IsReachable = false; NotificationManager.Show(new NotificationInfo(ApplicationName.PingMonitor, NotificationStatus.Down, - NotificationHostDisplay, Strings.HostIsDown, SettingsManager.Current.PingMonitor_NotificationCloseTime), + NotificationHostDisplay, [new NotificationLine(NotificationIPAddress, Strings.Down, NotificationStatus.Down)], + SettingsManager.Current.PingMonitor_NotificationCloseTime), SettingsManager.Current.PingMonitor_ShowNotificationPopup, SettingsManager.Current.PingMonitor_NotificationSound); } diff --git a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs index 37d0fda5cb..e5d502ebde 100644 --- a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs @@ -1945,16 +1945,21 @@ private void NotifyChanges() return result != 0 ? result : x.Port.CompareTo(y.Port); }); - var lines = changes.Select(x => - $"{x.HostAsString}: {x.Port}" + - (string.IsNullOrEmpty(x.LookupInfo.Service) ? string.Empty : $" ({x.LookupInfo.Service})") + " " + - PortScannerPortChangeToStringConverter.ToLocalizedString(x.Change)).ToList(); - - var summary = string.Join(" · ", new[] + // "10.0.0.5 (server) · 22/tcp (ssh) · Now open" - open now = up, no longer open = down + var lines = changes.Select(x => new NotificationLine( + NotificationLine.JoinText(NotificationLine.FormatHost(x.IPAddress, x.Hostname), + $"{x.Port}/{x.LookupInfo.Protocol.ToString().ToLowerInvariant()}" + + (string.IsNullOrEmpty(x.LookupInfo.Service) ? string.Empty : $" ({x.LookupInfo.Service})")), + PortScannerPortChangeToStringConverter.ToLocalizedString(x.Change), + x.Change == PortScannerPortChange.Opened ? NotificationStatus.Up : NotificationStatus.Down)).ToList(); + + var summary = new[] { - (Count: PortsNowOpen, Format: Strings.XNowOpen), (Count: PortsNoLongerOpen, Format: Strings.XNoLongerOpen) + (Count: PortsNowOpen, Format: Strings.XNowOpen, Status: NotificationStatus.Up), + (Count: PortsNoLongerOpen, Format: Strings.XNoLongerOpen, Status: NotificationStatus.Down) } - .Where(x => x.Count > 0).Select(x => string.Format(x.Format, x.Count))); + .Where(x => x.Count > 0) + .Select(x => new NotificationLine(null, string.Format(x.Format, x.Count), x.Status)).ToList(); // Open now = up, no longer open = down, both = up and down var status = NotificationStatusHelper.FromChanges(PortsNowOpen, PortsNoLongerOpen, 0); diff --git a/Website/docs/application/ip-scanner.md b/Website/docs/application/ip-scanner.md index a2af10c04e..5fe37941a9 100644 --- a/Website/docs/application/ip-scanner.md +++ b/Website/docs/application/ip-scanner.md @@ -526,7 +526,7 @@ Timeout in milliseconds after which an SNMP request is considered lost. ### Show notification popup on changes -Show a notification popup in the bottom-right corner of the primary screen when a [repeated scan](#repeat-the-scan) found changes since the previous scan (see [Changes since the last scan](#changes-since-the-last-scan); only new hosts like a host that is up, a gone host or a changed MAC address like a host that is down). One notification per scan: up to three changes, one per line, otherwise the number of changes. Not for a scan that was started manually. The header shows the tool, the title the scanned input; the status icon is green for only new hosts, red for only gone hosts, half green / half red for both, orange for a changed MAC address. A click opens the tab of the scan. +Show a notification popup in the bottom-right corner of the primary screen when a [repeated scan](#repeat-the-scan) found changes since the previous scan (see [Changes since the last scan](#changes-since-the-last-scan); only new hosts like a host that is up, a gone host or a changed MAC address like a host that is down). One notification per scan: up to three changes, one per line with the IP address (and hostname) and the change in its color (e.g. `10.0.0.5 (server) · New`, green for new, red for gone, orange for a changed MAC address), otherwise one line per counter (e.g. `5 New`, `2 Gone`). Not for a scan that was started manually. The header shows the tool, the title the scanned input; the status icon is green for only new hosts, red for only gone hosts, half green / half red for both, orange for a changed MAC address. A click opens the tab of the scan. **Type:** `Boolean` diff --git a/Website/docs/application/ping-monitor.md b/Website/docs/application/ping-monitor.md index 5acb38874a..eeb0e79b8c 100644 --- a/Website/docs/application/ping-monitor.md +++ b/Website/docs/application/ping-monitor.md @@ -80,7 +80,7 @@ Right-click a monitored host (anywhere except the chart) to open the context men When a monitored host changes its reachability state (up → down or down → up), a small notification popup can appear in the bottom-right corner of the primary screen, optionally accompanied by a system sound. -- The header shows the tool that sent the notification (**Ping Monitor** with its icon) and the time; below it a status icon (green when the host is up, red when it is down), the hostname (or the IP address as a fallback) and the current status (**Host is up** / **Host is down**). The [IP Scanner](./ip-scanner#show-notification-popup-on-changes) and the [Port Scanner](./port-scanner#show-notification-popup-on-changes) use the same notification. +- The header shows the tool that sent the notification (**Ping Monitor** with its icon) and the time; below it a status icon (green when the host is up, red when it is down), the hostname (or the IP address as a fallback) and a line with the IP address and the status in its color (e.g. `10.0.0.1 · Up` / `10.0.0.1 · Down`; only the status if the title is the IP address). The [IP Scanner](./ip-scanner#show-notification-popup-on-changes) and the [Port Scanner](./port-scanner#show-notification-popup-on-changes) use the same notification. - Multiple notifications **stack** upwards, so several hosts changing state at once are all visible. - **Click anywhere** on a notification to bring the main window to the front and open the **Ping Monitor**. Use the **×** button to dismiss a single notification without opening the main window. - Each notification closes automatically after a configurable [duration](#display-duration-seconds); a thin progress bar at the bottom shows the remaining time. @@ -198,7 +198,7 @@ Play a system sound when a monitored host changes its reachability state. This i ### Success threshold -Number of consecutive successful pings required before a **Host is up** notification is shown. Higher values reduce noise from flapping hosts. +Number of consecutive successful pings required before an **Up** notification is shown. Higher values reduce noise from flapping hosts. **Type:** `Integer` [Min `1`, Max `10`] @@ -206,7 +206,7 @@ Number of consecutive successful pings required before a **Host is up** notifica ### Failure threshold -Number of consecutive failed pings (timeouts) required before a **Host is down** notification is shown. Higher values reduce noise from flapping hosts. +Number of consecutive failed pings (timeouts) required before a **Down** notification is shown. Higher values reduce noise from flapping hosts. **Type:** `Integer` [Min `1`, Max `10`] diff --git a/Website/docs/application/port-scanner.md b/Website/docs/application/port-scanner.md index 1e4d7af3f2..6bddede878 100644 --- a/Website/docs/application/port-scanner.md +++ b/Website/docs/application/port-scanner.md @@ -313,7 +313,7 @@ Resolve the hostname for given IP addresses. ### Show notification popup on changes -Show a notification popup in the bottom-right corner of the primary screen when a [repeated scan](#repeat-the-scan) found changes since the previous scan (see [Changes since the last scan](#changes-since-the-last-scan); only ports that are open now like a host that is up, a port that is no longer open like a host that is down). One notification per scan: up to three changes, one per line, otherwise the number of changes. Not for a scan that was started manually. The header shows the tool, the title the scanned input; the status icon is green for only ports that are open now, red for only ports that are no longer open, half green / half red for both. A click opens the tab of the scan. +Show a notification popup in the bottom-right corner of the primary screen when a [repeated scan](#repeat-the-scan) found changes since the previous scan (see [Changes since the last scan](#changes-since-the-last-scan); only ports that are open now like a host that is up, a port that is no longer open like a host that is down). One notification per scan: up to three changes, one per line with the IP address (and hostname), the port with its protocol (and service) and the change in its color (e.g. `10.0.0.5 (server) · 22/tcp (ssh) · No longer open`, green for open now, red for no longer open), otherwise one line per counter (e.g. `5 Now open`). Not for a scan that was started manually. The header shows the tool, the title the scanned input; the status icon is green for only ports that are open now, red for only ports that are no longer open, half green / half red for both. A click opens the tab of the scan. **Type:** `Boolean` diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index 68c2716d4d..a5776f3102 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -102,7 +102,7 @@ Release date: **xx.xx.2026** - IP addresses can be excluded with `!` in the host input (also in profiles and the run command), e.g. `10.0.0.0/24; !10.0.0.1; !10.0.0.200-254`: IP addresses, ranges, subnets and hostnames (nothing is scanned if an excluded hostname can't be resolved). [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) - Changes since the last completed scan of the same input in the tab: **New**, **Gone** (shown as down) and **MAC changed** (the previous MAC address in the tooltip) in the new **Since last scan** column, counters below **Result** (e.g. `2 New · 1 Gone`) and **Last scan at**. Kept while the tab is open; also in the search and the export (`Change`, `PreviousMACAddress`). [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) - New **Automatically scan every** (`1 min` to `1 h`, with a countdown to the next scan): repeats the scan and updates the rows in place (selection, scroll position and search stay). Stops when a scan is canceled, the input is changed or the tab is closed; also runs while the tab is hidden. Per tab, the interval is saved. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) -- New **Notification** settings (popup, sound, display duration; default off): a repeated scan with changes shows one notification like the **Ping Monitor** (up to three changes, otherwise the counters) - green for only new hosts, red for only gone hosts, half green / half red for both, orange for a changed MAC address; a click opens the tab. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) +- New **Notification** settings (popup, sound, display duration; default off): a repeated scan with changes shows one notification like the **Ping Monitor** (up to three changes, e.g. `10.0.0.5 (server) · New` with the change in its color, otherwise the counters) - green for only new hosts, red for only gone hosts, half green / half red for both, orange for a changed MAC address; a click opens the tab. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) **Port Scanner** @@ -135,7 +135,7 @@ Release date: **xx.xx.2026** - IP addresses can be excluded with `!` in the host input (also in profiles and the run command), e.g. `10.0.0.0/24; !10.0.0.1; !10.0.0.200-254`: IP addresses, ranges, subnets and hostnames (nothing is scanned if an excluded hostname can't be resolved). [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) - Changes since the last completed scan of the same hosts and ports in the tab: **Now open** and **No longer open** (shown with its status, also without **Show closed ports**) in the new **Since last scan** column, counters below **Result** and **Last scan at**. Kept while the tab is open; also in the search and the export (`Change`). [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) - New **Automatically scan every** (`1 min` to `1 h`, with a countdown to the next scan): repeats the scan and updates the rows in place (selection, scroll position, collapsed hosts and search stay). Stops when a scan is canceled, the hosts or ports are changed or the tab is closed; also runs while the tab is hidden. Per tab, the interval is saved. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) -- New **Notification** settings (popup, sound, display duration; default off): a repeated scan with changes shows one notification like the **Ping Monitor** (up to three ports, e.g. `server (10.0.0.5): 3389 (rdp) No longer open`, otherwise the counters) - green for only ports open now, red for only ports no longer open, half green / half red for both; a click opens the tab. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) +- New **Notification** settings (popup, sound, display duration; default off): a repeated scan with changes shows one notification like the **Ping Monitor** (up to three ports, e.g. `10.0.0.5 (server) · 3389/tcp (rdp) · No longer open` with the change in its color, otherwise the counters) - green for only ports open now, red for only ports no longer open, half green / half red for both; a click opens the tab. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) **Ping Monitor** @@ -146,7 +146,7 @@ Release date: **xx.xx.2026** - Host input now accepts newline-separated hosts (one per line, e.g. a column pasted from Excel), converted automatically to the semicolon-separated form. [#3568](https://github.com/BornToBeRoot/NETworkManager/pull/3568) - Host input now supports shorthand IPv4 ranges like `192.168.0.1-100`, in addition to the existing `192.168.0.0-192.168.0.100` and `192.168.[0-100].1` range formats. [#3568](https://github.com/BornToBeRoot/NETworkManager/pull/3568) - IP addresses can be excluded with `!` in the host input (also in profiles and the run command), e.g. `10.0.0.0/24; !10.0.0.1; !10.0.0.200-254`: IP addresses, ranges, subnets and hostnames (nothing is scanned if an excluded hostname can't be resolved). [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) -- The notification shows the tool in its header (icon and name), a click opens the **Ping Monitor**; down uses the red of the application. [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) +- The notification shows the tool in its header (icon and name), a click opens the **Ping Monitor**; down uses the red of the application. The message is a line with the IP address and the status in its color (e.g. `10.0.0.1 · Up`) like the notifications of the **IP Scanner** and the **Port Scanner** (was `Host is up`). [#3685](https://github.com/BornToBeRoot/NETworkManager/pull/3685) **Traceroute** From ea15163e57aa03ecc5ca5dc496a9d3d84b9eba97 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Sun, 1 Nov 2026 15:14:00 +0100 Subject: [PATCH 117/131] Fix: IP Scanner / Port Scanner: Repeated scan without a host to scan lost the counters / marked all ports timed out (review #2) A repeated scan that ended before scanning (e.g. a hostname or an excluded hostname could not be resolved once) kept the rows but not the counters (reset by Start); the Port Scanner even set every port of the last scan to timed out, because no port was reported. It is now discarded like a canceled repeated scan: rows and counters of the last scan, tried again after the interval. Co-Authored-By: Claude Opus 5.5 --- .../NETworkManager/ViewModels/IPScannerViewModel.cs | 13 +++++++------ .../ViewModels/PortScannerViewModel.cs | 12 +++++++----- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs index 0c3c2ffa38..bccb338ace 100644 --- a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs @@ -1753,10 +1753,11 @@ private void ScanComplete(object sender, ScanCompleteArgs e) row.CopyFrom(snapshot); } - // Canceled / failed repeated scan: the rows of the last scan with their values (before the counters are - // computed from them) + // Canceled / failed repeated scan, or not started (no host left, e.g. a hostname could not be resolved): + // the rows of the last scan with their values and counters (Start already reset the counters) var isRepeat = _repeatRows != null; - var isRepeatDiscarded = isRepeat && (e.IsCanceled || e.Exception != null); + var isNothingScanned = HostsToScan == 0; + var isRepeatDiscarded = isRepeat && (e.IsCanceled || e.Exception != null || isNothingScanned); if (isRepeatDiscarded) { @@ -1766,7 +1767,7 @@ private void ScanComplete(object sender, ScanCompleteArgs e) foreach (var added in _repeatAdded) Results.Remove(added); } - else if (isRepeat && HostsToScan > 0) + else if (isRepeat) { // A row of an IP address that is no longer part of the input (e.g. a hostname resolves to another // address now) was not scanned - it would stay unchanged forever @@ -1830,7 +1831,7 @@ private void ScanComplete(object sender, ScanCompleteArgs e) StatusMessage = e.Exception.Message; IsStatusMessageDisplayed = true; } - else if (Results.Count == 0 && HostsToScan > 0) + else if (Results.Count == 0 && !isNothingScanned) { // Keep a message from the preparation (e.g. hostnames that could not be resolved) AddStatusMessage(Strings.NoReachableHostsFound); @@ -1850,7 +1851,7 @@ private void ScanComplete(object sender, ScanCompleteArgs e) // scan, and a repeated scan that failed (tried again after the interval, see ScanRepeater.OnScanEnded) if (!isRescan) { - var isCompleted = !e.IsCanceled && e.Exception == null && HostsToScan > 0; + var isCompleted = !e.IsCanceled && e.Exception == null && !isNothingScanned; if (isCompleted) CompareWithPreviousScan(isRepeat); diff --git a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs index e5d502ebde..a5eaf36a00 100644 --- a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs @@ -1763,9 +1763,11 @@ private void ScanComplete(object sender, ScanCompleteArgs e) FlushProgress(); - // Repeated scan: canceled / failed - the rows of the last scan with their states; completed - a port of a - // host that was not scanned (e.g. it didn't answer the ping before the scan) didn't answer - var isRepeatDiscarded = _repeatRows != null && (e.IsCanceled || e.Exception != null); + // Repeated scan: canceled / failed or not started (no host left, e.g. a hostname could not be resolved) - + // the rows of the last scan with their states and counters; completed - a port of a host that was not + // scanned (e.g. it didn't answer the ping before the scan) didn't answer + var isNothingScanned = PortsToScan == 0; + var isRepeatDiscarded = _repeatRows != null && (e.IsCanceled || e.Exception != null || isNothingScanned); if (_repeatRows != null) { @@ -1879,7 +1881,7 @@ private void ScanComplete(object sender, ScanCompleteArgs e) AddStatusMessage(e.Exception.Message); } - else if (Results.Count == 0 && PortsToScan > 0) + else if (Results.Count == 0 && !isNothingScanned) { AddStatusMessage(Strings.NoOpenPortsFound); } @@ -1902,7 +1904,7 @@ private void ScanComplete(object sender, ScanCompleteArgs e) // scan, and a repeated scan that failed (tried again after the interval, see ScanRepeater.OnScanEnded) if (!isRescan) { - var isCompleted = !e.IsCanceled && e.Exception == null && _scanInput != null && PortsToScan > 0; + var isCompleted = !e.IsCanceled && e.Exception == null && _scanInput != null && !isNothingScanned; if (isCompleted) CompareWithPreviousScan(isRepeat); From 68a751a573e95ffe9ce3ecd87c745f2eb676d994 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Sun, 1 Nov 2026 15:14:30 +0100 Subject: [PATCH 118/131] Fix: IP Scanner / Port Scanner: Repeat started a second scan while a manual scan waited for its confirmation (review #1) IsRunning is only set after the "scan many IP addresses / ports" dialog, so the repeat timer could start a repeated scan while it was open; after the confirmation both scans ran (one cancellation token, mixed rows and counters). The repeat now waits while the dialog is open. Co-Authored-By: Claude Opus 5.5 --- .../ViewModels/IPScannerViewModel.cs | 25 ++++++++++++++++--- .../ViewModels/PortScannerViewModel.cs | 25 ++++++++++++++++--- 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs index bccb338ace..d4b7490508 100644 --- a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs @@ -45,6 +45,9 @@ public class IPScannerViewModel : ViewModelBase, IProfileManagerMinimal private bool _firstLoad = true; private bool _closed; + // A manual scan waits for the confirmation of a large scan (IsRunning is still false) - not repeated meanwhile + private bool _isConfirmingScan; + // Background HostFound / HostUpdated events append here instead of hopping to the UI thread per // item - a DispatcherTimer periodically flushes this into the Results collection instead, so a large // scan doesn't flood the dispatcher queue with one BeginInvoke per host. One list for both, so an @@ -530,8 +533,9 @@ private set /// Starts it on the first load. If false, the input is only filled in. public IPScannerViewModel(Guid tabId, string hostOrIPRange, bool start = true) { - // Repeat of the scan (before the input is set, see its setter): not while a scan or rescan is running - Repeat = new ScanRepeater(() => Start(isRepeat: true), () => IsRunning, + // Repeat of the scan (before the input is set, see its setter): not while a scan or rescan is running, or a + // manual scan waits for its confirmation + Repeat = new ScanRepeater(() => Start(isRepeat: true), () => IsRunning || _isConfirmingScan, SettingsManager.Current.IPScanner_AutoRefreshTime, x => SettingsManager.Current.IPScanner_AutoRefreshTime = x); _firstLoad = start; @@ -937,9 +941,22 @@ private async Task Start(bool isRepeat = false) var window = Application.Current.Windows.OfType().FirstOrDefault(x => x.IsActive) ?? Application.Current.MainWindow; - if (!await DialogHelper.ShowConfirmationMessageAsync(window, Strings.Scan, + bool confirmed; + + _isConfirmingScan = true; + + try + { + confirmed = await DialogHelper.ShowConfirmationMessageAsync(window, Strings.Scan, string.Format(Strings.ScanManyIPAddressesMessage, hostCount.ToString("N0")), - ChildWindowIcon.Warn, Strings.Scan)) + ChildWindowIcon.Warn, Strings.Scan); + } + finally + { + _isConfirmingScan = false; + } + + if (!confirmed) return; // The tab may have been closed while the dialog was open (OnClose only stops a running scan) diff --git a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs index a5eaf36a00..aa2c83892a 100644 --- a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs @@ -57,6 +57,9 @@ public class PortScannerViewModel : ViewModelBase, IProfileManagerMinimal private bool _firstLoad = true; private bool _closed; + // A manual scan waits for the confirmation of a large scan (IsRunning is still false) - not repeated meanwhile + private bool _isConfirmingScan; + // Background PortScanned events append here instead of hopping to the UI thread per item - // a DispatcherTimer periodically flushes this into the Results collection instead, so a large // scan doesn't flood the dispatcher queue with one BeginInvoke per port. @@ -577,8 +580,9 @@ private set /// public PortScannerViewModel(Guid tabId, string host, string port, bool start = true, string message = null) { - // Repeat of the scan (before the input is set, see its setter): not while a scan or rescan is running - Repeat = new ScanRepeater(() => Start(isRepeat: true), () => IsRunning, + // Repeat of the scan (before the input is set, see its setter): not while a scan or rescan is running, or a + // manual scan waits for its confirmation + Repeat = new ScanRepeater(() => Start(isRepeat: true), () => IsRunning || _isConfirmingScan, SettingsManager.Current.PortScanner_AutoRefreshTime, x => SettingsManager.Current.PortScanner_AutoRefreshTime = x); _firstLoad = start; @@ -973,10 +977,23 @@ private async Task Start(bool isRepeat = false) var window = Application.Current.Windows.OfType().FirstOrDefault(x => x.IsActive) ?? Application.Current.MainWindow; - if (!await DialogHelper.ShowConfirmationMessageAsync(window, Strings.Scan, + bool confirmed; + + _isConfirmingScan = true; + + try + { + confirmed = await DialogHelper.ShowConfirmationMessageAsync(window, Strings.Scan, string.Format(Strings.ScanManyPortsMessage, portCount.ToString("N0"), hostCount.ToString("N0"), portsPerHost.ToString("N0")), - ChildWindowIcon.Warn, Strings.Scan)) + ChildWindowIcon.Warn, Strings.Scan); + } + finally + { + _isConfirmingScan = false; + } + + if (!confirmed) return; // The tab may have been closed while the dialog was open (OnClose only stops a running scan) From 797bff309a61b164e2c4a1ba7f7e8d31b7fedc57 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Sun, 1 Nov 2026 15:15:40 +0100 Subject: [PATCH 119/131] Fix: IP Scanner: Row of a gone host without its role (review #5) The baseline of a scan didn't keep the role, so the row added for a gone default gateway / DNS server had no role icon, tooltip, search match and exported role. The role of the previous scan is now kept. Co-Authored-By: Claude Opus 5.5 --- Source/NETworkManager.Models/Network/IPScannerHostInfo.cs | 3 ++- .../NETworkManager.Models/Network/IPScannerScanComparer.cs | 6 ++++-- .../Network/IPScannerScanComparerTests.cs | 4 +++- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/Source/NETworkManager.Models/Network/IPScannerHostInfo.cs b/Source/NETworkManager.Models/Network/IPScannerHostInfo.cs index b81c9ecda7..2a7e926076 100644 --- a/Source/NETworkManager.Models/Network/IPScannerHostInfo.cs +++ b/Source/NETworkManager.Models/Network/IPScannerHostInfo.cs @@ -685,7 +685,7 @@ public string PreviousMACAddress /// /// Creates the row of a host that was up in the previous scan and is not up now - /// (), with the hostname and MAC address of the previous scan. + /// (), with the hostname, MAC address and role of the previous scan. /// /// IP address of the host. /// The host in the previous scan. @@ -699,6 +699,7 @@ public static IPScannerHostInfo CreateGone(IPAddress ipAddress, IPScannerBaselin DNSHostname = previous.Hostname, NeighborMACAddress = previous.MACAddress, NeighborVendor = previous.Vendor, + Role = previous.Role, Change = IPScannerHostChange.Gone }; diff --git a/Source/NETworkManager.Models/Network/IPScannerScanComparer.cs b/Source/NETworkManager.Models/Network/IPScannerScanComparer.cs index 8f67156bfb..49ca6466b4 100644 --- a/Source/NETworkManager.Models/Network/IPScannerScanComparer.cs +++ b/Source/NETworkManager.Models/Network/IPScannerScanComparer.cs @@ -12,7 +12,9 @@ namespace NETworkManager.Models.Network; /// Shown hostname (DNS, mDNS or NetBIOS), empty if unknown. /// MAC address, empty if unknown. /// Vendor of the MAC address, empty if unknown. -public sealed record IPScannerBaselineHost(bool IsUp, string Hostname, string MACAddress, string Vendor); +/// Roles of the host (e.g. the default gateway), kept by the row of a gone host. +public sealed record IPScannerBaselineHost(bool IsUp, string Hostname, string MACAddress, string Vendor, + IPScannerHostRole Role = IPScannerHostRole.None); /// /// Result of . @@ -40,7 +42,7 @@ public static Dictionary CreateBaseline(IEnume foreach (var host in hosts) { baseline[host.IPAddress] = new IPScannerBaselineHost(host.IsReachable, host.Hostname ?? string.Empty, - host.MACAddress ?? string.Empty, host.Vendor ?? string.Empty); + host.MACAddress ?? string.Empty, host.Vendor ?? string.Empty, host.Role); } return baseline; diff --git a/Source/NETworkManager.Tests/Network/IPScannerScanComparerTests.cs b/Source/NETworkManager.Tests/Network/IPScannerScanComparerTests.cs index 0c0aa2c357..1d45b183fd 100644 --- a/Source/NETworkManager.Tests/Network/IPScannerScanComparerTests.cs +++ b/Source/NETworkManager.Tests/Network/IPScannerScanComparerTests.cs @@ -105,12 +105,14 @@ public void BaselineHasEveryHostWithItsState() [Fact] public void GoneRowHasThePreviousValues() { - var gone = IPScannerHostInfo.CreateGone(A, new IPScannerBaselineHost(true, "nas.local", "00:11", "Synology")); + var gone = IPScannerHostInfo.CreateGone(A, + new IPScannerBaselineHost(true, "nas.local", "00:11", "Synology", IPScannerHostRole.DefaultGateway)); Assert.False(gone.IsReachable); Assert.Equal(IPScannerHostChange.Gone, gone.Change); Assert.Equal("nas.local", gone.Hostname); Assert.Equal("00:11", gone.MACAddress); + Assert.Equal(IPScannerHostRole.DefaultGateway, gone.Role); Assert.False(gone.IsHostnamePending); Assert.False(gone.IsMACAddressPending); } From e8eced13346e9bae0e77d99da16e84d18060f42e Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Sun, 1 Nov 2026 15:33:32 +0100 Subject: [PATCH 120/131] Refactor: SNMP: SNMPv3 privacy provider and error codes in SNMPV3Helper The privacy / authentication providers of SNMPv3 credentials and the error codes of a report (usmStats) move from SNMPClient into a shared helper, so the IP Scanner can use them for SNMPv3. Behaviour unchanged. Co-Authored-By: Claude Opus 5.5 --- .../Network/SNMPClient.cs | 110 ++---------------- .../Network/SNMPV3Helper.cs | 99 ++++++++++++++++ 2 files changed, 107 insertions(+), 102 deletions(-) create mode 100644 Source/NETworkManager.Models/Network/SNMPV3Helper.cs diff --git a/Source/NETworkManager.Models/Network/SNMPClient.cs b/Source/NETworkManager.Models/Network/SNMPClient.cs index cb42015148..66212b0d07 100644 --- a/Source/NETworkManager.Models/Network/SNMPClient.cs +++ b/Source/NETworkManager.Models/Network/SNMPClient.cs @@ -14,19 +14,6 @@ namespace NETworkManager.Models.Network; public sealed class SNMPClient { - #region Variables - - /// - /// List of known SNMPv3 error codes with their Object Identifier. - /// - private readonly Dictionary _snmpv3ErrorOIDs = new() - { - { new ObjectIdentifier("1.3.6.1.6.3.15.1.1.3.0"), SNMPV3ErrorCode.UnknownUserName }, - { new ObjectIdentifier("1.3.6.1.6.3.15.1.1.5.0"), SNMPV3ErrorCode.AuthenticationFailed } - }; - - #endregion - #region Events /// @@ -192,7 +179,7 @@ public void GetAsyncV3(IPAddress ipAddress, List oids, SNMPOptionsV3 opt if (response is ReportMessage) // Check for SNMPv3 error codes if (pdu.Variables.Count > 0 && - _snmpv3ErrorOIDs.TryGetValue(pdu.Variables[0].Id, out var errorCodeV3)) + SNMPV3Helper.TryGetErrorCode(pdu.Variables[0].Id, out var errorCodeV3)) { OnError(new SNMPErrorArgs(errorCodeV3)); @@ -359,7 +346,7 @@ public void WalkAsyncV3(IPAddress ipAddress, string oid, SNMPOptionsV3 options) { // Check for SNMPv3 error codes if (pdu.Variables.Count > 0 && - _snmpv3ErrorOIDs.TryGetValue(pdu.Variables[0].Id, out var errorCodeV3)) + SNMPV3Helper.TryGetErrorCode(pdu.Variables[0].Id, out var errorCodeV3)) { OnError(new SNMPErrorArgs(errorCodeV3)); @@ -393,7 +380,7 @@ public void WalkAsyncV3(IPAddress ipAddress, string oid, SNMPOptionsV3 options) if (response is ReportMessage) // Check for SNMPv3 error codes if (pdu.Variables.Count > 0 && - _snmpv3ErrorOIDs.TryGetValue(pdu.Variables[0].Id, out errorCodeV3)) + SNMPV3Helper.TryGetErrorCode(pdu.Variables[0].Id, out errorCodeV3)) { OnError(new SNMPErrorArgs(errorCodeV3)); @@ -537,7 +524,7 @@ public void SetAsyncV3(IPAddress ipAddress, string oid, string data, SNMPOptions if (response is ReportMessage) // Check for SNMPv3 error codes if (pdu.Variables.Count > 0 && - _snmpv3ErrorOIDs.TryGetValue(pdu.Variables[0].Id, out var errorCodeV3)) + SNMPV3Helper.TryGetErrorCode(pdu.Variables[0].Id, out var errorCodeV3)) { OnError(new SNMPErrorArgs(errorCodeV3)); @@ -568,91 +555,10 @@ public void SetAsyncV3(IPAddress ipAddress, string oid, string data, SNMPOptions /// Privacy provider. private static IPrivacyProvider GetPrivacyProvider(SNMPOptionsV3 options) { - return options.Security switch - { - // AuthPriv - SNMPV3Security.AuthPriv => GetPrivacyProvider(options.AuthProvider, options.Auth, options.PrivProvider, - options.Priv), - - // AuthNoPriv - SNMPV3Security.AuthNoPriv => GetPrivacyProvider(options.AuthProvider, options.Auth), - - // NoAuthNoPriv - _ => GetPrivacyProvider() - }; - } - - /// - /// Create the privacy provider with default values. - /// - /// Privacy provider. - private static IPrivacyProvider GetPrivacyProvider() - { - return new DefaultPrivacyProvider(DefaultAuthenticationProvider.Instance); - } - - /// - /// Create the privacy provider based on the given information's. - /// - /// Authentication provider to use. - /// Authentication password. - /// Privacy provider. - private static IPrivacyProvider GetPrivacyProvider(SNMPV3AuthenticationProvider authProvider, SecureString auth) - { - return new DefaultPrivacyProvider(GetAuthenticationProvider(authProvider, auth)); - } - - /// - /// Create the privacy provider based on the given information's. - /// - /// Authentication provider to use. - /// Authentication password. - /// Privacy provider to use. - /// Privacy password. - /// Privacy provider. - private static IPrivacyProvider GetPrivacyProvider(SNMPV3AuthenticationProvider authProvider, SecureString auth, - SNMPV3PrivacyProvider privProvider, SecureString priv) - { - var privPlain = SecureStringHelper.ConvertToString(priv); - - return privProvider switch - { -#pragma warning disable CS0618 // Allow outdated algorithms. We provide the function also for old devices. The user should use newer algorithms... - SNMPV3PrivacyProvider.DES => new DESPrivacyProvider(new OctetString(privPlain), - GetAuthenticationProvider(authProvider, auth)), -#pragma warning restore CS0618 // Allow outdated algorithms. We provide the function also for old devices. The user should use newer algorithms... - SNMPV3PrivacyProvider.AES => new AESPrivacyProvider(new OctetString(privPlain), - GetAuthenticationProvider(authProvider, auth)), - SNMPV3PrivacyProvider.AES192 => new AES192PrivacyProvider(new OctetString(privPlain), - GetAuthenticationProvider(authProvider, auth)), - SNMPV3PrivacyProvider.AES256 => new AES256PrivacyProvider(new OctetString(privPlain), - GetAuthenticationProvider(authProvider, auth)), - _ => null - }; - } - - /// - /// Create the authentication provider based on the given information's. - /// - /// Authentication provider to use. - /// Authentication password. - /// Authentication provider. - private static IAuthenticationProvider GetAuthenticationProvider(SNMPV3AuthenticationProvider authProvider, - SecureString auth) - { - var authPlain = SecureStringHelper.ConvertToString(auth); - - return authProvider switch - { -#pragma warning disable CS0618 // Allow outdated algorithms. We provide the function also for old devices. The user should use newer algorithms... - SNMPV3AuthenticationProvider.MD5 => new MD5AuthenticationProvider(new OctetString(authPlain)), - SNMPV3AuthenticationProvider.SHA1 => new SHA1AuthenticationProvider(new OctetString(authPlain)), -#pragma warning restore CS0618 // Allow outdated algorithms. We provide the function also for old devices. The user should use newer algorithms... - SNMPV3AuthenticationProvider.SHA256 => new SHA256AuthenticationProvider(new OctetString(authPlain)), - SNMPV3AuthenticationProvider.SHA384 => new SHA384AuthenticationProvider(new OctetString(authPlain)), - SNMPV3AuthenticationProvider.SHA512 => new SHA512AuthenticationProvider(new OctetString(authPlain)), - _ => null - }; + return SNMPV3Helper.CreatePrivacyProvider(options.Security, options.AuthProvider, + options.Auth != null ? SecureStringHelper.ConvertToString(options.Auth) : string.Empty, + options.PrivProvider, + options.Priv != null ? SecureStringHelper.ConvertToString(options.Priv) : string.Empty); } #endregion diff --git a/Source/NETworkManager.Models/Network/SNMPV3Helper.cs b/Source/NETworkManager.Models/Network/SNMPV3Helper.cs new file mode 100644 index 0000000000..90fb4f3d88 --- /dev/null +++ b/Source/NETworkManager.Models/Network/SNMPV3Helper.cs @@ -0,0 +1,99 @@ +using System.Collections.Generic; +using Lextm.SharpSnmpLib; +using Lextm.SharpSnmpLib.Security; + +namespace NETworkManager.Models.Network; + +/// +/// Methods for SNMPv3 (SNMP tool, SNMP of the IP Scanner): the privacy provider of the credentials and the error codes +/// of a report. +/// +public static class SNMPV3Helper +{ + /// + /// Known SNMPv3 error codes (usmStats of a report) with their Object Identifier. + /// + private static readonly Dictionary ErrorOIDs = new() + { + { new ObjectIdentifier("1.3.6.1.6.3.15.1.1.3.0"), SNMPV3ErrorCode.UnknownUserName }, + { new ObjectIdentifier("1.3.6.1.6.3.15.1.1.5.0"), SNMPV3ErrorCode.AuthenticationFailed } + }; + + /// + /// Gets the SNMPv3 error code of a variable of a report (e.g. usmStatsUnknownUserNames). + /// + /// Object Identifier of the variable. + /// Error code, if unknown. + /// True if the error code is known. + public static bool TryGetErrorCode(ObjectIdentifier oid, out SNMPV3ErrorCode errorCode) + { + if (ErrorOIDs.TryGetValue(oid, out errorCode)) + return true; + + errorCode = SNMPV3ErrorCode.None; + + return false; + } + + /// + /// Creates the privacy provider (incl. the authentication provider) of SNMPv3 credentials. + /// + /// Security level. + /// Authentication provider (AuthNoPriv, AuthPriv). + /// Authentication password (AuthNoPriv, AuthPriv). + /// Privacy provider (AuthPriv). + /// Privacy password (AuthPriv). + /// Privacy provider. + public static IPrivacyProvider CreatePrivacyProvider(SNMPV3Security security, + SNMPV3AuthenticationProvider authProvider, string auth, SNMPV3PrivacyProvider privProvider, string priv) + { + return security switch + { + SNMPV3Security.AuthPriv => CreatePrivacyProvider(privProvider, priv, + CreateAuthenticationProvider(authProvider, auth)), + SNMPV3Security.AuthNoPriv => new DefaultPrivacyProvider(CreateAuthenticationProvider(authProvider, auth)), + _ => new DefaultPrivacyProvider(DefaultAuthenticationProvider.Instance) + }; + } + + /// + /// Creates the privacy provider of a privacy password. + /// + private static IPrivacyProvider CreatePrivacyProvider(SNMPV3PrivacyProvider privProvider, string priv, + IAuthenticationProvider authenticationProvider) + { + var privacyPassword = new OctetString(priv ?? string.Empty); + + return privProvider switch + { +#pragma warning disable CS0618 // Allow outdated algorithms. We provide the function also for old devices. The user should use newer algorithms... + SNMPV3PrivacyProvider.DES => new DESPrivacyProvider(privacyPassword, authenticationProvider), +#pragma warning restore CS0618 // Allow outdated algorithms. We provide the function also for old devices. The user should use newer algorithms... + SNMPV3PrivacyProvider.AES => new AESPrivacyProvider(privacyPassword, authenticationProvider), + SNMPV3PrivacyProvider.AES192 => new AES192PrivacyProvider(privacyPassword, authenticationProvider), + SNMPV3PrivacyProvider.AES256 => new AES256PrivacyProvider(privacyPassword, authenticationProvider), + _ => null + }; + } + + /// + /// Creates the authentication provider of an authentication password. + /// + private static IAuthenticationProvider CreateAuthenticationProvider(SNMPV3AuthenticationProvider authProvider, + string auth) + { + var authenticationPassword = new OctetString(auth ?? string.Empty); + + return authProvider switch + { +#pragma warning disable CS0618 // Allow outdated algorithms. We provide the function also for old devices. The user should use newer algorithms... + SNMPV3AuthenticationProvider.MD5 => new MD5AuthenticationProvider(authenticationPassword), + SNMPV3AuthenticationProvider.SHA1 => new SHA1AuthenticationProvider(authenticationPassword), +#pragma warning restore CS0618 // Allow outdated algorithms. We provide the function also for old devices. The user should use newer algorithms... + SNMPV3AuthenticationProvider.SHA256 => new SHA256AuthenticationProvider(authenticationPassword), + SNMPV3AuthenticationProvider.SHA384 => new SHA384AuthenticationProvider(authenticationPassword), + SNMPV3AuthenticationProvider.SHA512 => new SHA512AuthenticationProvider(authenticationPassword), + _ => null + }; + } +} From 16eb073145de02faf029f85a02a6e663c2605f2c Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Sun, 1 Nov 2026 15:38:29 +0100 Subject: [PATCH 121/131] IP Scanner: SNMPv3 for the SNMP system information, error of a host in the row details / export SNMPSystemInfoResolver takes the version and credentials (SNMPCredentials, without the secrets in ToString). v3: a discovery request first (any v3 agent answers it with a report, also without valid credentials, so it still finds the host), then the GET request with the user. Wrong credentials are answered with a report: the error (unknown user, authentication failed) is shown in the SNMP row details and exported as SNMPError (last column). The host duration counts both v3 requests. The settings stay v1 / v2c; the v3 credentials come from a profile / group (next steps). Co-Authored-By: Claude Opus 5.5 --- .../SNMPV3ErrorCodeToStringConverter.cs | 42 ++++ .../Export/ExportManager.IPScannerHostInfo.cs | 5 +- .../Network/IPScanner.cs | 12 +- .../Network/IPScannerHostInfo.cs | 24 +++ .../Network/IPScannerOptions.cs | 10 +- .../Network/SNMPCredentials.cs | 71 +++++++ .../Network/SNMPSystemInfo.cs | 10 +- .../Network/SNMPSystemInfoResolver.cs | 169 +++++++++++++++- .../Network/ExportManagerIPScannerTests.cs | 2 +- .../Network/SNMPSystemInfoResolverV3Tests.cs | 186 ++++++++++++++++++ .../ViewModels/IPScannerViewModel.cs | 4 +- .../NETworkManager/Views/IPScannerView.xaml | 7 + 12 files changed, 517 insertions(+), 25 deletions(-) create mode 100644 Source/NETworkManager.Converters/SNMPV3ErrorCodeToStringConverter.cs create mode 100644 Source/NETworkManager.Models/Network/SNMPCredentials.cs create mode 100644 Source/NETworkManager.Tests/Network/SNMPSystemInfoResolverV3Tests.cs diff --git a/Source/NETworkManager.Converters/SNMPV3ErrorCodeToStringConverter.cs b/Source/NETworkManager.Converters/SNMPV3ErrorCodeToStringConverter.cs new file mode 100644 index 0000000000..6325933a54 --- /dev/null +++ b/Source/NETworkManager.Converters/SNMPV3ErrorCodeToStringConverter.cs @@ -0,0 +1,42 @@ +using System; +using System.Globalization; +using System.Windows.Data; +using NETworkManager.Localization; +using NETworkManager.Models.Network; + +namespace NETworkManager.Converters; + +/// +/// Convert a to a translated (e.g. "Authentication failed"). +/// +public sealed class SNMPV3ErrorCodeToStringConverter : IValueConverter +{ + /// + /// Convert a to a translated . + /// + /// Error code as . + /// + /// + /// + /// Translated error, empty for . + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is not SNMPV3ErrorCode errorCode || errorCode == SNMPV3ErrorCode.None) + return string.Empty; + + return ResourceTranslator.Translate(ResourceIdentifier.SNMPV3ErrorCode, errorCode); + } + + /// + /// !!! Method not implemented !!! + /// + /// + /// + /// + /// + /// + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} diff --git a/Source/NETworkManager.Models/Export/ExportManager.IPScannerHostInfo.cs b/Source/NETworkManager.Models/Export/ExportManager.IPScannerHostInfo.cs index 785f8f1262..6eebe414cb 100644 --- a/Source/NETworkManager.Models/Export/ExportManager.IPScannerHostInfo.cs +++ b/Source/NETworkManager.Models/Export/ExportManager.IPScannerHostInfo.cs @@ -116,7 +116,10 @@ private static List GetFields(IPScannerHostInfo info, IPScannerResu // Change since the previous scan of the same input (empty without a previous scan / change) new(nameof(IPScannerHostInfo.Change), info.Change == IPScannerHostChange.None ? null : info.Change.ToString()), - new(nameof(IPScannerHostInfo.PreviousMACAddress), info.PreviousMACAddress) + new(nameof(IPScannerHostInfo.PreviousMACAddress), info.PreviousMACAddress), + // SNMPv3 error of the host (e.g. "AuthenticationFailed"), empty without + new(nameof(IPScannerHostInfo.SNMPError), + IfEnabled(options.SNMPEnabled, info.HasSNMPError ? info.SNMPError.ToString() : string.Empty), true) ]; } diff --git a/Source/NETworkManager.Models/Network/IPScanner.cs b/Source/NETworkManager.Models/Network/IPScanner.cs index a844f16bd2..0c0b589a30 100644 --- a/Source/NETworkManager.Models/Network/IPScanner.cs +++ b/Source/NETworkManager.Models/Network/IPScanner.cs @@ -161,7 +161,11 @@ public void ScanAsync(IEnumerable<(IPAddress ipAddress, string hostname)> hosts, (long)Math.Ceiling(options.PortScanPorts.Length / (double)options.MaxPortThreads) : 0, Math.Max(options.NetBIOSEnabled ? options.NetBIOSTimeout : 0, - options.SNMPEnabled ? options.SNMPTimeout : 0))); + // v3: discovery and request, each with the timeout + options.SNMPEnabled + ? (long)options.SNMPTimeout * + (options.SNMPCredentials.Version == SNMPVersion.V3 ? 2 : 1) + : 0))); var hostConcurrency = hosts.TryGetNonEnumeratedCount(out var hostCount) ? Math.Min(hostCount, options.MaxHostThreads) @@ -246,8 +250,8 @@ await _neighborCache.WasReachableBeforeAsync(ipAddress, cancellationToken) // A host that answers the SNMP request is up as well (null if it didn't answer) var snmpTask = options.SNMPEnabled - ? SNMPSystemInfoResolver.ResolveAsync(ipAddress, options.SNMPVersion, options.SNMPCommunity, - options.SNMPTimeout, cancellationToken) + ? SNMPSystemInfoResolver.ResolveAsync(ipAddress, options.SNMPCredentials, options.SNMPTimeout, + cancellationToken) : null; var enrichTask = Task.CompletedTask; @@ -365,6 +369,7 @@ void RaiseHostFound() x.SNMPName = value?.Name ?? string.Empty; x.SNMPDescription = value?.Description ?? string.Empty; x.SNMPLocation = value?.Location ?? string.Empty; + x.SNMPError = value?.Error ?? SNMPV3ErrorCode.None; x.IsSNMPPending = false; }; } @@ -524,6 +529,7 @@ private IPScannerHostInfo CreateHostInfo(IPAddress ipAddress, IPScannerDetection SNMPName = snmpInfo?.Name ?? string.Empty, SNMPDescription = snmpInfo?.Description ?? string.Empty, SNMPLocation = snmpInfo?.Location ?? string.Empty, + SNMPError = snmpInfo?.Error ?? SNMPV3ErrorCode.None, IsWebPagePending = isReachable && options.WebPageEnabled && (ports == null ? options.PortScanEnabled : GetWebPorts(ports).Count > 0), IsPortScanPending = ports == null, diff --git a/Source/NETworkManager.Models/Network/IPScannerHostInfo.cs b/Source/NETworkManager.Models/Network/IPScannerHostInfo.cs index 2a7e926076..e3ebaa5b0b 100644 --- a/Source/NETworkManager.Models/Network/IPScannerHostInfo.cs +++ b/Source/NETworkManager.Models/Network/IPScannerHostInfo.cs @@ -562,6 +562,29 @@ internal set } } = string.Empty; + /// + /// SNMPv3 error of the host (e.g. unknown user, wrong password: it answered, the values are empty), + /// otherwise. + /// + public SNMPV3ErrorCode SNMPError + { + get; + internal set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(HasSNMPError)); + } + } + + /// + /// The host answered the SNMPv3 request with an error (see ). + /// + public bool HasSNMPError => SNMPError != SNMPV3ErrorCode.None; + /// /// Indicates whether the DNS hostname (PTR) is still resolved ( is a placeholder). /// @@ -757,6 +780,7 @@ public void CopyFrom(IPScannerHostInfo source) SNMPName = source.SNMPName; SNMPDescription = source.SNMPDescription; SNMPLocation = source.SNMPLocation; + SNMPError = source.SNMPError; IsDNSPending = source.IsDNSPending; IsNeighborMACAddressPending = source.IsNeighborMACAddressPending; IsMDNSPending = source.IsMDNSPending; diff --git a/Source/NETworkManager.Models/Network/IPScannerOptions.cs b/Source/NETworkManager.Models/Network/IPScannerOptions.cs index 8e8cfc0546..26dc4a68f8 100644 --- a/Source/NETworkManager.Models/Network/IPScannerOptions.cs +++ b/Source/NETworkManager.Models/Network/IPScannerOptions.cs @@ -120,14 +120,10 @@ public record IPScannerOptions public required bool SNMPEnabled { get; init; } /// - /// SNMP version ( or ). + /// SNMP version and credentials (community for v1 / v2c, user for v3): of the settings, or the override of the + /// profile / group the tab was opened with. /// - public required SNMPVersion SNMPVersion { get; init; } - - /// - /// SNMP community. - /// - public required string SNMPCommunity { get; init; } + public required SNMPCredentials SNMPCredentials { get; init; } /// /// Timeout in milliseconds after which an SNMP request is considered lost. diff --git a/Source/NETworkManager.Models/Network/SNMPCredentials.cs b/Source/NETworkManager.Models/Network/SNMPCredentials.cs new file mode 100644 index 0000000000..42aeb52af1 --- /dev/null +++ b/Source/NETworkManager.Models/Network/SNMPCredentials.cs @@ -0,0 +1,71 @@ +using System.Text; + +namespace NETworkManager.Models.Network; + +/// +/// Version and credentials of an SNMP request of the IP Scanner (see ): the +/// community for v1 / v2c, the user (security level, authentication and privacy) for v3. Plain text only for the +/// duration of a scan; doesn't contain the secrets. +/// +/// SNMP version. +public sealed record SNMPCredentials(SNMPVersion Version) +{ + /// + /// Community (v1 / v2c), e.g. "public". + /// + public string Community { get; init; } = string.Empty; + + /// + /// Security level (v3). + /// + public SNMPV3Security Security { get; init; } = SNMPV3Security.AuthPriv; + + /// + /// Username (v3). + /// + public string Username { get; init; } = string.Empty; + + /// + /// Authentication provider (v3, AuthNoPriv and AuthPriv). + /// + public SNMPV3AuthenticationProvider AuthenticationProvider { get; init; } = SNMPV3AuthenticationProvider.SHA1; + + /// + /// Authentication password (v3, AuthNoPriv and AuthPriv). + /// + public string Auth { get; init; } = string.Empty; + + /// + /// Privacy provider (v3, AuthPriv). + /// + public SNMPV3PrivacyProvider PrivacyProvider { get; init; } = SNMPV3PrivacyProvider.AES; + + /// + /// Privacy password (v3, AuthPriv). + /// + public string Priv { get; init; } = string.Empty; + + /// + /// Credentials of v1 / v2c with a community. + /// + /// SNMP version ( or ). + /// Community, e.g. "public". + /// Credentials. + public static SNMPCredentials FromCommunity(SNMPVersion version, string community) + { + return new SNMPCredentials(version) { Community = community ?? string.Empty }; + } + + /// + /// Only the version and the user (e.g. in a log), never the community or the passwords. + /// + private bool PrintMembers(StringBuilder builder) + { + builder.Append($"Version = {Version}"); + + if (Version == SNMPVersion.V3) + builder.Append($", Security = {Security}, Username = {Username}"); + + return true; + } +} diff --git a/Source/NETworkManager.Models/Network/SNMPSystemInfo.cs b/Source/NETworkManager.Models/Network/SNMPSystemInfo.cs index 443980680a..2535db64b3 100644 --- a/Source/NETworkManager.Models/Network/SNMPSystemInfo.cs +++ b/Source/NETworkManager.Models/Network/SNMPSystemInfo.cs @@ -6,4 +6,12 @@ /// Name of the host (sysName). /// Description of the host, e.g. model and firmware (sysDescr). /// Location of the host (sysLocation). -public sealed record SNMPSystemInfo(string Name, string Description, string Location); +/// +/// SNMPv3 error of the report of the host (e.g. unknown user, wrong password - the host answered, the values are +/// empty), otherwise. +/// +public sealed record SNMPSystemInfo( + string Name, + string Description, + string Location, + SNMPV3ErrorCode Error = SNMPV3ErrorCode.None); diff --git a/Source/NETworkManager.Models/Network/SNMPSystemInfoResolver.cs b/Source/NETworkManager.Models/Network/SNMPSystemInfoResolver.cs index 6f5ba8fcb1..3a1e2a3779 100644 --- a/Source/NETworkManager.Models/Network/SNMPSystemInfoResolver.cs +++ b/Source/NETworkManager.Models/Network/SNMPSystemInfoResolver.cs @@ -12,7 +12,10 @@ namespace NETworkManager.Models.Network; /// /// Reads the system information (sysName, sysDescr, sysLocation of the MIB-II system group) of a host with an SNMP -/// v1 / v2c GET request to UDP port 161. A host with another community usually doesn't answer at all (timeout). +/// GET request to UDP port 161. v1 / v2c: a host with another community usually doesn't answer at all (timeout). +/// v3: a discovery request first (any SNMPv3 agent answers it with a report, also without valid credentials), then +/// the GET request with the user - wrong credentials are answered with a report (see +/// ). /// public static class SNMPSystemInfoResolver { @@ -37,19 +40,21 @@ public static class SNMPSystemInfoResolver /// Reads the system information of a host. /// /// IP address of the host. - /// SNMP version ( or ). - /// Community (e.g. "public"). - /// Timeout in milliseconds to wait for the answer. + /// Version and credentials. + /// Timeout in milliseconds to wait for an answer (v3: per request). /// Token to monitor for cancellation requests. /// System information, null if the host didn't answer or the answer is invalid. - public static async Task ResolveAsync(IPAddress ipAddress, SNMPVersion version, string community, + public static async Task ResolveAsync(IPAddress ipAddress, SNMPCredentials credentials, int timeout, CancellationToken cancellationToken) { + if (credentials.Version == SNMPVersion.V3) + return await ResolveV3Async(ipAddress, credentials, timeout, cancellationToken).ConfigureAwait(false); + try { var requestId = Messenger.NextRequestId; - var request = CreateRequest(requestId, version, community); + var request = CreateRequest(requestId, credentials.Version, credentials.Community); var response = await UdpRequest.SendAsync(ipAddress, SNMPPort, request.ToBytes(), timeout, cancellationToken).ConfigureAwait(false); @@ -62,6 +67,135 @@ public static async Task ResolveAsync(IPAddress ipAddress, SNMPV } } + /// + /// Reads the system information with SNMPv3: discovery (engine ID, boots and time of the agent), then the GET + /// request with the user. + /// + private static async Task ResolveV3Async(IPAddress ipAddress, SNMPCredentials credentials, + int timeout, CancellationToken cancellationToken) + { + try + { + var discoveryMessageId = Messenger.NextMessageId; + + var discovery = new Discovery(discoveryMessageId, Messenger.NextRequestId, Messenger.MaxMessageSize); + + var discoveryResponse = await UdpRequest.SendAsync(ipAddress, SNMPPort, discovery.ToBytes(), timeout, + cancellationToken).ConfigureAwait(false); + + if (discoveryResponse == null) + return null; + + var report = ParseDiscoveryResponse(discoveryResponse, discoveryMessageId); + + // Not an SNMPv3 agent (e.g. only v1 / v2c, or another service on the port) + if (report == null) + return null; + + var privacy = SNMPV3Helper.CreatePrivacyProvider(credentials.Security, + credentials.AuthenticationProvider, credentials.Auth, credentials.PrivacyProvider, credentials.Priv); + + var messageId = Messenger.NextMessageId; + var requestId = Messenger.NextRequestId; + + var request = CreateRequestV3(messageId, requestId, credentials.Username, privacy, report); + + var response = await UdpRequest.SendAsync(ipAddress, SNMPPort, request.ToBytes(), timeout, + cancellationToken).ConfigureAwait(false); + + // The agent answered the discovery (it is reachable), but not the request (e.g. a wrong privacy password + // can't be decrypted - some agents don't report it) + if (response == null) + return new SNMPSystemInfo(string.Empty, string.Empty, string.Empty); + + return ParseResponseV3(response, messageId, requestId, + new UserRegistry([new User(new OctetString(credentials.Username ?? string.Empty), privacy)])) ?? + new SNMPSystemInfo(string.Empty, string.Empty, string.Empty); + } + catch (Exception) + { + return null; + } + } + + /// + /// Gets the report of the answer to the discovery request (v3). + /// + /// SNMP message of the answer. + /// Message ID of the discovery request (repeated in the report). + /// Report (with the engine ID, boots and time of the agent), null if the answer is invalid. + public static ReportMessage ParseDiscoveryResponse(byte[] response, int messageId) + { + try + { + return MessageFactory.ParseMessages(response, new UserRegistry()).FirstOrDefault() is ReportMessage report && + report.MessageId() == messageId + ? report + : null; + } + catch (Exception) + { + return null; + } + } + + /// + /// Creates the SNMPv3 GET request for sysName, sysDescr and sysLocation. + /// + /// Message ID (repeated in the answer and in a report). + /// Request ID (repeated in the answer). + /// Username. + /// Privacy provider of the credentials, see . + /// Report of the discovery (engine ID, boots and time of the agent). + /// SNMP message. + public static GetRequestMessage CreateRequestV3(int messageId, int requestId, string username, + IPrivacyProvider privacy, ISnmpMessage report) + { + return new GetRequestMessage(VersionCode.V3, messageId, requestId, new OctetString(username ?? string.Empty), + OctetString.Empty, CreateVariables(), privacy, Messenger.MaxMessageSize, report); + } + + /// + /// Gets the system information from the answer to . + /// + /// SNMP message of the answer. + /// Message ID of the request. + /// Request ID of the request. + /// User of the request (authentication and decryption of the answer). + /// + /// System information; of a report (e.g. unknown user, wrong password) empty values with the error. Null if the + /// answer is invalid or for another request. + /// + public static SNMPSystemInfo ParseResponseV3(byte[] response, int messageId, int requestId, UserRegistry registry) + { + ISnmpMessage message; + + try + { + message = MessageFactory.ParseMessages(response, registry).FirstOrDefault(); + } + catch (Exception) + { + return null; + } + + if (message == null || message.MessageId() != messageId) + return null; + + // Wrong credentials: the agent answered with a report (usmStats), not with the values + if (message is ReportMessage) + { + var variables = message.Pdu().Variables; + + return new SNMPSystemInfo(string.Empty, string.Empty, string.Empty, + variables.Count > 0 && SNMPV3Helper.TryGetErrorCode(variables[0].Id, out var errorCode) + ? errorCode + : SNMPV3ErrorCode.None); + } + + return message is ResponseMessage && message.RequestId() == requestId ? GetSystemInfo(message) : null; + } + /// /// Creates the GET request for sysName, sysDescr and sysLocation. /// @@ -71,15 +205,21 @@ public static async Task ResolveAsync(IPAddress ipAddress, SNMPV /// SNMP message. public static GetRequestMessage CreateRequest(int requestId, SNMPVersion version, string community) { - List variables = + return new GetRequestMessage(requestId, version == SNMPVersion.V1 ? VersionCode.V1 : VersionCode.V2, + new OctetString(community ?? string.Empty), CreateVariables()); + } + + /// + /// Variables of the request: sysName, sysDescr and sysLocation. + /// + private static List CreateVariables() + { + return [ new(new ObjectIdentifier(SysNameOID)), new(new ObjectIdentifier(SysDescrOID)), new(new ObjectIdentifier(SysLocationOID)) ]; - - return new GetRequestMessage(requestId, version == SNMPVersion.V1 ? VersionCode.V1 : VersionCode.V2, - new OctetString(community ?? string.Empty), variables); } /// @@ -107,6 +247,15 @@ public static SNMPSystemInfo ParseResponse(byte[] response, int requestId) if (message is not ResponseMessage || message.RequestId() != requestId) return null; + return GetSystemInfo(message); + } + + /// + /// Gets the system information of a response (empty values if the host answered with an error, e.g. noSuchName + /// of v1 - it is reachable). + /// + private static SNMPSystemInfo GetSystemInfo(ISnmpMessage message) + { if (message.Pdu().ErrorStatus.ToInt32() != 0) return new SNMPSystemInfo(string.Empty, string.Empty, string.Empty); diff --git a/Source/NETworkManager.Tests/Network/ExportManagerIPScannerTests.cs b/Source/NETworkManager.Tests/Network/ExportManagerIPScannerTests.cs index 84aa86ead9..d5316b198d 100644 --- a/Source/NETworkManager.Tests/Network/ExportManagerIPScannerTests.cs +++ b/Source/NETworkManager.Tests/Network/ExportManagerIPScannerTests.cs @@ -22,7 +22,7 @@ public class ExportManagerIPScannerTests "NetBIOSIPAddress,NetBIOSComputerName,NetBIOSUserName,NetBIOSGroupName,NetBIOSMACAddress,NetBIOSVendor," + "MACAddress,Vendor,DNSHostname,DetectedBy,OSGuess,Role,MDNSHostname,WebTitle,WebURL,SNMPName,SNMPDescription," + "SNMPLocation,WebStatusCode,WebServer,WebPoweredBy,WebRealm,WebRedirectURL,WebCertificateSubject," + - "WebCertificateIssuer,WebCertificateNotAfter,WebCertificateSelfSigned,Change,PreviousMACAddress"; + "WebCertificateIssuer,WebCertificateNotAfter,WebCertificateSelfSigned,Change,PreviousMACAddress,SNMPError"; private static readonly IPScannerResultOptions AllEnabled = new(true, true, true, true, true, true, true, true, true); diff --git a/Source/NETworkManager.Tests/Network/SNMPSystemInfoResolverV3Tests.cs b/Source/NETworkManager.Tests/Network/SNMPSystemInfoResolverV3Tests.cs new file mode 100644 index 0000000000..67f33d5d30 --- /dev/null +++ b/Source/NETworkManager.Tests/Network/SNMPSystemInfoResolverV3Tests.cs @@ -0,0 +1,186 @@ +using System.Collections.Generic; +using System.Linq; +using Lextm.SharpSnmpLib; +using Lextm.SharpSnmpLib.Messaging; +using Lextm.SharpSnmpLib.Security; +using NETworkManager.Models.Network; +using Xunit; + +namespace NETworkManager.Tests.Network; + +/// +/// SNMPv3 of : discovery, request, answer and report (wrong credentials). +/// +public class SNMPSystemInfoResolverV3Tests +{ + private const int MessageId = 1234; + private const int RequestId = 4711; + + private const string UnknownUserNameOID = "1.3.6.1.6.3.15.1.1.3.0"; + private const string WrongDigestOID = "1.3.6.1.6.3.15.1.1.5.0"; + private const string UnknownEngineIdOID = "1.3.6.1.6.3.15.1.1.4.0"; + + private static readonly OctetString EngineId = new(new byte[] { 0x80, 0x00, 0x1F, 0x88, 0x80, 0x01, 0x02, 0x03 }); + + private static ReportMessage CreateReport(int messageId, string oid, string username = "") + { + return new ReportMessage(VersionCode.V3, + new Header(new Integer32(messageId), new Integer32(Messenger.MaxMessageSize), default(Levels)), + new SecurityParameters(EngineId, new Integer32(1), new Integer32(100), new OctetString(username), + OctetString.Empty, OctetString.Empty), + new Scope(EngineId, OctetString.Empty, + new ReportPdu(RequestId, ErrorCode.NoError, 0, + new List { new(new ObjectIdentifier(oid), new Counter32(1)) })), + DefaultPrivacyProvider.DefaultPair, null); + } + + private static byte[] CreateResponse(int messageId, int requestId, string username, + params (string oid, ISnmpData data)[] values) + { + var variables = values.Select(x => new Variable(new ObjectIdentifier(x.oid), x.data)).ToList(); + + return new ResponseMessage(VersionCode.V3, + new Header(new Integer32(messageId), new Integer32(Messenger.MaxMessageSize), default(Levels)), + new SecurityParameters(EngineId, new Integer32(1), new Integer32(100), new OctetString(username), + OctetString.Empty, OctetString.Empty), + new Scope(EngineId, OctetString.Empty, new ResponsePdu(requestId, ErrorCode.NoError, 0, variables)), + DefaultPrivacyProvider.DefaultPair, false, null) + .ToBytes(); + } + + private static UserRegistry CreateRegistry(string username) + { + return new UserRegistry([new User(new OctetString(username), DefaultPrivacyProvider.DefaultPair)]); + } + + [Fact] + public void ParseDiscoveryResponse() + { + var report = SNMPSystemInfoResolver.ParseDiscoveryResponse( + CreateReport(MessageId, UnknownEngineIdOID).ToBytes(), MessageId); + + Assert.NotNull(report); + Assert.Equal(EngineId, report.Parameters.EngineId); + } + + [Fact] + public void ParseDiscoveryResponse_OtherMessage_Null() + { + Assert.Null(SNMPSystemInfoResolver.ParseDiscoveryResponse( + CreateReport(MessageId + 1, UnknownEngineIdOID).ToBytes(), MessageId)); + } + + [Fact] + public void ParseDiscoveryResponse_NoReport_Null() + { + // An answer of v1 / v2c (e.g. another service on the port) is not a report + var response = new ResponseMessage(RequestId, VersionCode.V2, new OctetString("public"), ErrorCode.NoError, 0, + []).ToBytes(); + + Assert.Null(SNMPSystemInfoResolver.ParseDiscoveryResponse(response, MessageId)); + } + + [Theory] + [InlineData(SNMPV3Security.NoAuthNoPriv)] + [InlineData(SNMPV3Security.AuthNoPriv)] + [InlineData(SNMPV3Security.AuthPriv)] + public void CreateRequestV3(SNMPV3Security security) + { + var privacy = SNMPV3Helper.CreatePrivacyProvider(security, SNMPV3AuthenticationProvider.SHA256, + "authpassword", SNMPV3PrivacyProvider.AES, "privpassword"); + + var request = SNMPSystemInfoResolver.CreateRequestV3(MessageId, RequestId, "monitor", privacy, + CreateReport(MessageId, UnknownEngineIdOID)); + + Assert.Equal(VersionCode.V3, request.Version); + Assert.Equal(MessageId, request.MessageId()); + Assert.Equal("monitor", request.Parameters.UserName.ToString()); + + // The engine of the agent from the discovery + Assert.Equal(EngineId, request.Parameters.EngineId); + + // Encrypted, the variables can only be checked without privacy + if (security != SNMPV3Security.AuthPriv) + Assert.Equal( + [SNMPSystemInfoResolver.SysNameOID, SNMPSystemInfoResolver.SysDescrOID, SNMPSystemInfoResolver.SysLocationOID], + request.Variables().Select(x => x.Id.ToString())); + + // Can be sent + Assert.NotEmpty(request.ToBytes()); + } + + [Fact] + public void ParseResponseV3() + { + var response = CreateResponse(MessageId, RequestId, "monitor", + (SNMPSystemInfoResolver.SysNameOID, new OctetString("switch-01")), + (SNMPSystemInfoResolver.SysDescrOID, new OctetString("Switch")), + (SNMPSystemInfoResolver.SysLocationOID, new OctetString("Server room"))); + + Assert.Equal(new SNMPSystemInfo("switch-01", "Switch", "Server room"), + SNMPSystemInfoResolver.ParseResponseV3(response, MessageId, RequestId, CreateRegistry("monitor"))); + } + + [Fact] + public void ParseResponseV3_OtherMessage_Null() + { + var response = CreateResponse(MessageId + 1, RequestId, "monitor", + (SNMPSystemInfoResolver.SysNameOID, new OctetString("switch-01"))); + + Assert.Null(SNMPSystemInfoResolver.ParseResponseV3(response, MessageId, RequestId, CreateRegistry("monitor"))); + } + + [Theory] + [InlineData(UnknownUserNameOID, SNMPV3ErrorCode.UnknownUserName)] + [InlineData(WrongDigestOID, SNMPV3ErrorCode.AuthenticationFailed)] + [InlineData(UnknownEngineIdOID, SNMPV3ErrorCode.None)] + public void ParseResponseV3_Report_Error(string oid, SNMPV3ErrorCode expected) + { + // Wrong credentials: the host answered (it is reachable), empty values with the error + var report = CreateReport(MessageId, oid, "monitor").ToBytes(); + + Assert.Equal(new SNMPSystemInfo(string.Empty, string.Empty, string.Empty, expected), + SNMPSystemInfoResolver.ParseResponseV3(report, MessageId, RequestId, CreateRegistry("monitor"))); + } + + [Theory] + [InlineData(new byte[] { })] + [InlineData(new byte[] { 0x30, 0x03, 0x02, 0x01 })] + public void ParseResponseV3_Invalid_Null(byte[] response) + { + Assert.Null(SNMPSystemInfoResolver.ParseResponseV3(response, MessageId, RequestId, CreateRegistry("monitor"))); + Assert.Null(SNMPSystemInfoResolver.ParseDiscoveryResponse(response, MessageId)); + } + + [Theory] + [InlineData(SNMPV3Security.NoAuthNoPriv, typeof(DefaultPrivacyProvider))] + [InlineData(SNMPV3Security.AuthNoPriv, typeof(DefaultPrivacyProvider))] + [InlineData(SNMPV3Security.AuthPriv, typeof(AESPrivacyProvider))] + public void CreatePrivacyProvider(SNMPV3Security security, System.Type expected) + { + var privacy = SNMPV3Helper.CreatePrivacyProvider(security, SNMPV3AuthenticationProvider.SHA1, "authpassword", + SNMPV3PrivacyProvider.AES, "privpassword"); + + Assert.IsType(expected, privacy); + Assert.Equal(security == SNMPV3Security.NoAuthNoPriv, + privacy.AuthenticationProvider is DefaultAuthenticationProvider); + } + + [Fact] + public void Credentials_ToString_WithoutSecrets() + { + var credentials = new SNMPCredentials(SNMPVersion.V3) + { + Username = "monitor", + Auth = "secret-auth", + Priv = "secret-priv", + Community = "secret-community" + }; + + var text = credentials.ToString(); + + Assert.Contains("monitor", text); + Assert.DoesNotContain("secret", text); + Assert.DoesNotContain("secret", SNMPCredentials.FromCommunity(SNMPVersion.V2C, "secret-community").ToString()); + } +} diff --git a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs index d4b7490508..6f06c353e3 100644 --- a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs @@ -1198,8 +1198,8 @@ private static IPScannerOptions CreateScanOptions(int[] ports, int[] webPagePort WebPagePorts = webPagePorts, WebPageTimeout = SettingsManager.Current.IPScanner_WebPageTimeout, SNMPEnabled = SettingsManager.Current.IPScanner_SNMPEnabled, - SNMPVersion = SettingsManager.Current.IPScanner_SNMPVersion, - SNMPCommunity = SettingsManager.Current.IPScanner_SNMPCommunity, + SNMPCredentials = SNMPCredentials.FromCommunity(SettingsManager.Current.IPScanner_SNMPVersion, + SettingsManager.Current.IPScanner_SNMPCommunity), SNMPTimeout = SettingsManager.Current.IPScanner_SNMPTimeout, ShowAllResults = SettingsManager.Current.IPScanner_ShowAllResults, ReportAllHosts = false diff --git a/Source/NETworkManager/Views/IPScannerView.xaml b/Source/NETworkManager/Views/IPScannerView.xaml index 2980522588..800adf383e 100644 --- a/Source/NETworkManager/Views/IPScannerView.xaml +++ b/Source/NETworkManager/Views/IPScannerView.xaml @@ -35,6 +35,7 @@ + @@ -1903,6 +1904,12 @@ + + + + + + From 8f5eff4ad91d9cb15fd2bd02b2b105fb87ea7f36 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Sun, 1 Nov 2026 15:40:33 +0100 Subject: [PATCH 122/131] Profiles: SNMP override of the IP Scanner in profiles and groups New fields IPScanner_OverrideSNMP, version, community, v3 security, username, authentication / privacy provider and passwords in ProfileInfo and GroupInfo; the community and the passwords are SecureStrings and saved like the ones of the SNMP tool (encrypted profile file). Default off, so existing profile files load unchanged. Profiles.Application.IPScanner.GetSNMPCredentials: the profile before its group, otherwise null (the settings). Tests for the order, the round trip of the profile file and old profile files (the test project now references NETworkManager.Profiles). Co-Authored-By: Claude Opus 5.5 --- .../Application/IPScanner.cs | 67 +++++++++ Source/NETworkManager.Profiles/GroupInfo.cs | 32 ++++ .../GroupInfoSerializable.cs | 15 ++ .../ProfileFileData.cs | 36 +++++ Source/NETworkManager.Profiles/ProfileInfo.cs | 30 ++++ .../ProfileInfoSerializable.cs | 15 ++ .../NETworkManager.Tests.csproj | 1 + .../Profiles/IPScannerSNMPCredentialsTests.cs | 137 ++++++++++++++++++ 8 files changed, 333 insertions(+) create mode 100644 Source/NETworkManager.Profiles/Application/IPScanner.cs create mode 100644 Source/NETworkManager.Tests/Profiles/IPScannerSNMPCredentialsTests.cs diff --git a/Source/NETworkManager.Profiles/Application/IPScanner.cs b/Source/NETworkManager.Profiles/Application/IPScanner.cs new file mode 100644 index 0000000000..bf367c5f8b --- /dev/null +++ b/Source/NETworkManager.Profiles/Application/IPScanner.cs @@ -0,0 +1,67 @@ +using System.Security; +using NETworkManager.Models.Network; +using NETworkManager.Utilities; + +namespace NETworkManager.Profiles.Application; + +public static class IPScanner +{ + /// + /// Gets the SNMP version and credentials of a profile for the IP Scanner: its own (if overridden), otherwise the + /// ones of its group (if overridden), otherwise null (the settings are used). + /// + /// Profile the tab is opened with. + /// Credentials, null if neither the profile nor its group overrides them. + public static SNMPCredentials GetSNMPCredentials(ProfileInfo profile) + { + return GetSNMPCredentials(profile, ProfileManager.GetGroupByName(profile.Group)); + } + + /// + /// Gets the SNMP version and credentials of a profile and its group, see + /// . + /// + /// Profile. + /// Group of the profile, null if it can't be found. + /// Credentials, null if neither the profile nor its group overrides them. + public static SNMPCredentials GetSNMPCredentials(ProfileInfo profile, GroupInfo group) + { + if (profile.IPScanner_OverrideSNMP) + return CreateCredentials(profile.IPScanner_SNMPVersion, profile.IPScanner_SNMPCommunity, + profile.IPScanner_SNMPSecurity, profile.IPScanner_SNMPUsername, + profile.IPScanner_SNMPAuthenticationProvider, profile.IPScanner_SNMPAuth, + profile.IPScanner_SNMPPrivacyProvider, profile.IPScanner_SNMPPriv); + + if (group is { IPScanner_OverrideSNMP: true }) + return CreateCredentials(group.IPScanner_SNMPVersion, group.IPScanner_SNMPCommunity, + group.IPScanner_SNMPSecurity, group.IPScanner_SNMPUsername, + group.IPScanner_SNMPAuthenticationProvider, group.IPScanner_SNMPAuth, + group.IPScanner_SNMPPrivacyProvider, group.IPScanner_SNMPPriv); + + return null; + } + + /// + /// Creates the credentials (plain text for the scan) of the values of a profile or group. + /// + private static SNMPCredentials CreateCredentials(SNMPVersion version, SecureString community, + SNMPV3Security security, string username, SNMPV3AuthenticationProvider authenticationProvider, + SecureString auth, SNMPV3PrivacyProvider privacyProvider, SecureString priv) + { + return new SNMPCredentials(version) + { + Community = ToString(community), + Security = security, + Username = username ?? string.Empty, + AuthenticationProvider = authenticationProvider, + Auth = ToString(auth), + PrivacyProvider = privacyProvider, + Priv = ToString(priv) + }; + } + + private static string ToString(SecureString value) + { + return value == null ? string.Empty : SecureStringHelper.ConvertToString(value); + } +} diff --git a/Source/NETworkManager.Profiles/GroupInfo.cs b/Source/NETworkManager.Profiles/GroupInfo.cs index 1f9289df58..2124632e87 100644 --- a/Source/NETworkManager.Profiles/GroupInfo.cs +++ b/Source/NETworkManager.Profiles/GroupInfo.cs @@ -41,6 +41,17 @@ public GroupInfo(GroupInfo group) : this(group.Name) // Profiles Profiles = group.Profiles; + // IP Scanner + IPScanner_OverrideSNMP = group.IPScanner_OverrideSNMP; + IPScanner_SNMPVersion = group.IPScanner_SNMPVersion; + IPScanner_SNMPCommunity = group.IPScanner_SNMPCommunity; + IPScanner_SNMPSecurity = group.IPScanner_SNMPSecurity; + IPScanner_SNMPUsername = group.IPScanner_SNMPUsername; + IPScanner_SNMPAuthenticationProvider = group.IPScanner_SNMPAuthenticationProvider; + IPScanner_SNMPAuth = group.IPScanner_SNMPAuth; + IPScanner_SNMPPrivacyProvider = group.IPScanner_SNMPPrivacyProvider; + IPScanner_SNMPPriv = group.IPScanner_SNMPPriv; + // Port Scanner PortScanner_OverridePorts = group.PortScanner_OverridePorts; PortScanner_Ports = group.PortScanner_Ports; @@ -185,6 +196,27 @@ public GroupInfo(GroupInfo group) : this(group.Name) [XmlIgnore] public List Profiles { get; set; } + /// + /// Use the SNMP version and credentials below for the IP Scanner instead of the settings (a profile before its + /// group), see . + /// + public bool IPScanner_OverrideSNMP { get; set; } + + public SNMPVersion IPScanner_SNMPVersion { get; set; } = GlobalStaticConfiguration.IPScanner_SNMPVersion; + [XmlIgnore] public SecureString IPScanner_SNMPCommunity { get; set; } + public SNMPV3Security IPScanner_SNMPSecurity { get; set; } = GlobalStaticConfiguration.SNMP_Security; + public string IPScanner_SNMPUsername { get; set; } + + public SNMPV3AuthenticationProvider IPScanner_SNMPAuthenticationProvider { get; set; } = + GlobalStaticConfiguration.SNMP_AuthenticationProvider; + + [XmlIgnore] public SecureString IPScanner_SNMPAuth { get; set; } + + public SNMPV3PrivacyProvider IPScanner_SNMPPrivacyProvider { get; set; } = + GlobalStaticConfiguration.SNMP_PrivacyProvider; + + [XmlIgnore] public SecureString IPScanner_SNMPPriv { get; set; } + public bool PortScanner_OverridePorts { get; set; } /// diff --git a/Source/NETworkManager.Profiles/GroupInfoSerializable.cs b/Source/NETworkManager.Profiles/GroupInfoSerializable.cs index 01e9a03aa1..c7fc785b4e 100644 --- a/Source/NETworkManager.Profiles/GroupInfoSerializable.cs +++ b/Source/NETworkManager.Profiles/GroupInfoSerializable.cs @@ -42,4 +42,19 @@ public GroupInfoSerializable(GroupInfo profileGroup) : base(profileGroup) /// Override the default snmp priv to make it serializable. /// public new string SNMP_Priv { get; set; } + + /// + /// Override the default IP Scanner snmp community to make it serializable. + /// + public new string IPScanner_SNMPCommunity { get; set; } + + /// + /// Override the default IP Scanner snmp auth to make it serializable. + /// + public new string IPScanner_SNMPAuth { get; set; } + + /// + /// Override the default IP Scanner snmp priv to make it serializable. + /// + public new string IPScanner_SNMPPriv { get; set; } } \ No newline at end of file diff --git a/Source/NETworkManager.Profiles/ProfileFileData.cs b/Source/NETworkManager.Profiles/ProfileFileData.cs index d6b0057f02..64e2551dbd 100644 --- a/Source/NETworkManager.Profiles/ProfileFileData.cs +++ b/Source/NETworkManager.Profiles/ProfileFileData.cs @@ -118,6 +118,15 @@ public List GroupsSerializable : string.Empty, SNMP_Priv = p.SNMP_Priv != null ? Utilities.SecureStringHelper.ConvertToString(p.SNMP_Priv) + : string.Empty, + IPScanner_SNMPCommunity = p.IPScanner_SNMPCommunity != null + ? Utilities.SecureStringHelper.ConvertToString(p.IPScanner_SNMPCommunity) + : string.Empty, + IPScanner_SNMPAuth = p.IPScanner_SNMPAuth != null + ? Utilities.SecureStringHelper.ConvertToString(p.IPScanner_SNMPAuth) + : string.Empty, + IPScanner_SNMPPriv = p.IPScanner_SNMPPriv != null + ? Utilities.SecureStringHelper.ConvertToString(p.IPScanner_SNMPPriv) : string.Empty })], RemoteDesktop_Password = g.RemoteDesktop_Password != null @@ -134,6 +143,15 @@ public List GroupsSerializable : string.Empty, SNMP_Priv = g.SNMP_Priv != null ? Utilities.SecureStringHelper.ConvertToString(g.SNMP_Priv) + : string.Empty, + IPScanner_SNMPCommunity = g.IPScanner_SNMPCommunity != null + ? Utilities.SecureStringHelper.ConvertToString(g.IPScanner_SNMPCommunity) + : string.Empty, + IPScanner_SNMPAuth = g.IPScanner_SNMPAuth != null + ? Utilities.SecureStringHelper.ConvertToString(g.IPScanner_SNMPAuth) + : string.Empty, + IPScanner_SNMPPriv = g.IPScanner_SNMPPriv != null + ? Utilities.SecureStringHelper.ConvertToString(g.IPScanner_SNMPPriv) : string.Empty }).Where(g => !g.IsDynamic)]; @@ -163,6 +181,15 @@ public List GroupsSerializable : null, SNMP_Priv = !string.IsNullOrEmpty(ps.SNMP_Priv) ? Utilities.SecureStringHelper.ConvertToSecureString(ps.SNMP_Priv) + : null, + IPScanner_SNMPCommunity = !string.IsNullOrEmpty(ps.IPScanner_SNMPCommunity) + ? Utilities.SecureStringHelper.ConvertToSecureString(ps.IPScanner_SNMPCommunity) + : null, + IPScanner_SNMPAuth = !string.IsNullOrEmpty(ps.IPScanner_SNMPAuth) + ? Utilities.SecureStringHelper.ConvertToSecureString(ps.IPScanner_SNMPAuth) + : null, + IPScanner_SNMPPriv = !string.IsNullOrEmpty(ps.IPScanner_SNMPPriv) + ? Utilities.SecureStringHelper.ConvertToSecureString(ps.IPScanner_SNMPPriv) : null })], RemoteDesktop_Password = !string.IsNullOrEmpty(gs.RemoteDesktop_Password) @@ -179,6 +206,15 @@ public List GroupsSerializable : null, SNMP_Priv = !string.IsNullOrEmpty(gs.SNMP_Priv) ? Utilities.SecureStringHelper.ConvertToSecureString(gs.SNMP_Priv) + : null, + IPScanner_SNMPCommunity = !string.IsNullOrEmpty(gs.IPScanner_SNMPCommunity) + ? Utilities.SecureStringHelper.ConvertToSecureString(gs.IPScanner_SNMPCommunity) + : null, + IPScanner_SNMPAuth = !string.IsNullOrEmpty(gs.IPScanner_SNMPAuth) + ? Utilities.SecureStringHelper.ConvertToSecureString(gs.IPScanner_SNMPAuth) + : null, + IPScanner_SNMPPriv = !string.IsNullOrEmpty(gs.IPScanner_SNMPPriv) + ? Utilities.SecureStringHelper.ConvertToSecureString(gs.IPScanner_SNMPPriv) : null }).ToList() ?? []; } diff --git a/Source/NETworkManager.Profiles/ProfileInfo.cs b/Source/NETworkManager.Profiles/ProfileInfo.cs index 2a7a123263..1c2e463fd2 100644 --- a/Source/NETworkManager.Profiles/ProfileInfo.cs +++ b/Source/NETworkManager.Profiles/ProfileInfo.cs @@ -58,6 +58,15 @@ public ProfileInfo(ProfileInfo profile) IPScanner_Enabled = profile.IPScanner_Enabled; IPScanner_InheritHost = profile.IPScanner_InheritHost; IPScanner_HostOrIPRange = profile.IPScanner_HostOrIPRange; + IPScanner_OverrideSNMP = profile.IPScanner_OverrideSNMP; + IPScanner_SNMPVersion = profile.IPScanner_SNMPVersion; + IPScanner_SNMPCommunity = profile.IPScanner_SNMPCommunity; + IPScanner_SNMPSecurity = profile.IPScanner_SNMPSecurity; + IPScanner_SNMPUsername = profile.IPScanner_SNMPUsername; + IPScanner_SNMPAuthenticationProvider = profile.IPScanner_SNMPAuthenticationProvider; + IPScanner_SNMPAuth = profile.IPScanner_SNMPAuth; + IPScanner_SNMPPrivacyProvider = profile.IPScanner_SNMPPrivacyProvider; + IPScanner_SNMPPriv = profile.IPScanner_SNMPPriv; // Port Scanner PortScanner_Enabled = profile.PortScanner_Enabled; @@ -315,6 +324,27 @@ public ProfileInfo(ProfileInfo profile) public bool IPScanner_InheritHost { get; set; } = true; public string IPScanner_HostOrIPRange { get; set; } + /// + /// Use the SNMP version and credentials below for the IP Scanner instead of the settings (a profile before its + /// group), see . + /// + public bool IPScanner_OverrideSNMP { get; set; } + + public SNMPVersion IPScanner_SNMPVersion { get; set; } = GlobalStaticConfiguration.IPScanner_SNMPVersion; + [XmlIgnore] public SecureString IPScanner_SNMPCommunity { get; set; } + public SNMPV3Security IPScanner_SNMPSecurity { get; set; } = GlobalStaticConfiguration.SNMP_Security; + public string IPScanner_SNMPUsername { get; set; } + + public SNMPV3AuthenticationProvider IPScanner_SNMPAuthenticationProvider { get; set; } = + GlobalStaticConfiguration.SNMP_AuthenticationProvider; + + [XmlIgnore] public SecureString IPScanner_SNMPAuth { get; set; } + + public SNMPV3PrivacyProvider IPScanner_SNMPPrivacyProvider { get; set; } = + GlobalStaticConfiguration.SNMP_PrivacyProvider; + + [XmlIgnore] public SecureString IPScanner_SNMPPriv { get; set; } + public bool PortScanner_Enabled { get; set; } public bool PortScanner_InheritHost { get; set; } = true; public string PortScanner_Host { get; set; } diff --git a/Source/NETworkManager.Profiles/ProfileInfoSerializable.cs b/Source/NETworkManager.Profiles/ProfileInfoSerializable.cs index 96e14fbe0f..67e5c1c924 100644 --- a/Source/NETworkManager.Profiles/ProfileInfoSerializable.cs +++ b/Source/NETworkManager.Profiles/ProfileInfoSerializable.cs @@ -43,4 +43,19 @@ public ProfileInfoSerializable(ProfileInfo profile) : base(profile) /// Override the default snmp priv to make it serializable. /// public new string SNMP_Priv { get; set; } + + /// + /// Override the default IP Scanner snmp community to make it serializable. + /// + public new string IPScanner_SNMPCommunity { get; set; } + + /// + /// Override the default IP Scanner snmp auth to make it serializable. + /// + public new string IPScanner_SNMPAuth { get; set; } + + /// + /// Override the default IP Scanner snmp priv to make it serializable. + /// + public new string IPScanner_SNMPPriv { get; set; } } \ No newline at end of file diff --git a/Source/NETworkManager.Tests/NETworkManager.Tests.csproj b/Source/NETworkManager.Tests/NETworkManager.Tests.csproj index bc61ad736b..897095982a 100644 --- a/Source/NETworkManager.Tests/NETworkManager.Tests.csproj +++ b/Source/NETworkManager.Tests/NETworkManager.Tests.csproj @@ -18,5 +18,6 @@ + diff --git a/Source/NETworkManager.Tests/Profiles/IPScannerSNMPCredentialsTests.cs b/Source/NETworkManager.Tests/Profiles/IPScannerSNMPCredentialsTests.cs new file mode 100644 index 0000000000..b7f10e1158 --- /dev/null +++ b/Source/NETworkManager.Tests/Profiles/IPScannerSNMPCredentialsTests.cs @@ -0,0 +1,137 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; +using NETworkManager.Models.Network; +using NETworkManager.Profiles; +using NETworkManager.Utilities; +using Xunit; +using IPScannerProfile = NETworkManager.Profiles.Application.IPScanner; + +namespace NETworkManager.Tests.Profiles; + +/// +/// SNMP override of the IP Scanner in profiles and groups: the profile before its group, the group before the +/// settings, and the secrets in the profile file. +/// +public class IPScannerSNMPCredentialsTests +{ + // Same options as ProfileManager + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.Never, + Converters = { new JsonStringEnumConverter() } + }; + + private static ProfileInfo CreateProfile(bool overrideSNMP) + { + return new ProfileInfo + { + Name = "Switches", + Group = "Site A", + IPScanner_OverrideSNMP = overrideSNMP, + IPScanner_SNMPVersion = SNMPVersion.V2C, + IPScanner_SNMPCommunity = SecureStringHelper.ConvertToSecureString("profile-community") + }; + } + + private static GroupInfo CreateGroup(bool overrideSNMP) + { + return new GroupInfo + { + Name = "Site A", + Profiles = [], + IPScanner_OverrideSNMP = overrideSNMP, + IPScanner_SNMPVersion = SNMPVersion.V3, + IPScanner_SNMPSecurity = SNMPV3Security.AuthPriv, + IPScanner_SNMPUsername = "monitor", + IPScanner_SNMPAuthenticationProvider = SNMPV3AuthenticationProvider.SHA256, + IPScanner_SNMPAuth = SecureStringHelper.ConvertToSecureString("group-auth"), + IPScanner_SNMPPrivacyProvider = SNMPV3PrivacyProvider.AES256, + IPScanner_SNMPPriv = SecureStringHelper.ConvertToSecureString("group-priv") + }; + } + + [Fact] + public void Profile_BeforeGroup() + { + var credentials = IPScannerProfile.GetSNMPCredentials(CreateProfile(true), CreateGroup(true)); + + Assert.Equal(SNMPVersion.V2C, credentials.Version); + Assert.Equal("profile-community", credentials.Community); + } + + [Fact] + public void Group_IfProfileDoesNotOverride() + { + var credentials = IPScannerProfile.GetSNMPCredentials(CreateProfile(false), CreateGroup(true)); + + Assert.Equal(SNMPVersion.V3, credentials.Version); + Assert.Equal(SNMPV3Security.AuthPriv, credentials.Security); + Assert.Equal("monitor", credentials.Username); + Assert.Equal(SNMPV3AuthenticationProvider.SHA256, credentials.AuthenticationProvider); + Assert.Equal("group-auth", credentials.Auth); + Assert.Equal(SNMPV3PrivacyProvider.AES256, credentials.PrivacyProvider); + Assert.Equal("group-priv", credentials.Priv); + } + + [Fact] + public void Settings_IfNeitherOverrides() + { + Assert.Null(IPScannerProfile.GetSNMPCredentials(CreateProfile(false), CreateGroup(false))); + Assert.Null(IPScannerProfile.GetSNMPCredentials(CreateProfile(false), null)); + } + + [Fact] + public void Secrets_SavedAndLoaded() + { + var group = CreateGroup(true); + group.Profiles = [CreateProfile(true)]; + + var json = JsonSerializer.Serialize(new ProfileFileData { Groups = [group] }, JsonOptions); + var loaded = JsonSerializer.Deserialize(json, JsonOptions); + + var loadedGroup = loaded.Groups.Single(); + var loadedProfile = loadedGroup.Profiles.Single(); + + Assert.True(loadedGroup.IPScanner_OverrideSNMP); + Assert.Equal("monitor", loadedGroup.IPScanner_SNMPUsername); + Assert.Equal("group-auth", SecureStringHelper.ConvertToString(loadedGroup.IPScanner_SNMPAuth)); + Assert.Equal("group-priv", SecureStringHelper.ConvertToString(loadedGroup.IPScanner_SNMPPriv)); + + Assert.True(loadedProfile.IPScanner_OverrideSNMP); + Assert.Equal("profile-community", SecureStringHelper.ConvertToString(loadedProfile.IPScanner_SNMPCommunity)); + + // Not set: not saved as a secret + Assert.Null(loadedProfile.IPScanner_SNMPAuth); + } + + [Fact] + public void CopyConstructors_KeepTheOverride() + { + var profile = new ProfileInfo(CreateProfile(true)); + var group = new GroupInfo(CreateGroup(true)); + + Assert.True(profile.IPScanner_OverrideSNMP); + Assert.Equal("profile-community", SecureStringHelper.ConvertToString(profile.IPScanner_SNMPCommunity)); + Assert.True(group.IPScanner_OverrideSNMP); + Assert.Equal("group-priv", SecureStringHelper.ConvertToString(group.IPScanner_SNMPPriv)); + } + + [Fact] + public void OldProfileFile_WithoutOverride() + { + // A profile file of an older version has no IP Scanner SNMP values + const string json = """{ "Groups": [ { "Name": "Site A", "Profiles": [ { "Name": "Switches" } ] } ] }"""; + + var loaded = JsonSerializer.Deserialize(json, JsonOptions); + + var profile = loaded.Groups.Single().Profiles.Single(); + + Assert.False(profile.IPScanner_OverrideSNMP); + Assert.Null(IPScannerProfile.GetSNMPCredentials(profile, loaded.Groups.Single())); + Assert.Equal(new List(), [.. loaded.Groups.Where(x => x.IPScanner_OverrideSNMP).Select(x => x.Name)]); + } +} From 76400964c2f9ae2ecd8a0f16edd13b64462c3bb2 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Sun, 1 Nov 2026 15:45:08 +0100 Subject: [PATCH 123/131] Profiles: SNMP override of the IP Scanner in the profile and group dialog IP Scanner section of the profile dialog and a new IP Scanner tab in the group dialog: "Version" overrides the SNMP version and credentials (v1 / v2c with a community, v3 with security level, user, authentication and privacy). One control (SNMPCredentialsControl) with its view model for both dialogs; can't be saved with a missing community, username or password (error icon of the tab). Passwords are only saved for the selected version / security level and stay unchanged unless a new one is entered; warning if the profile file is not encrypted. Co-Authored-By: Claude Opus 5.5 --- .../GroupViewManager.cs | 1 + .../NETworkManager.Profiles/GroupViewName.cs | 1 + .../Controls/SNMPCredentialsControl.xaml | 208 ++++++++++++++ .../Controls/SNMPCredentialsControl.xaml.cs | 13 + Source/NETworkManager/ProfileDialogManager.cs | 20 ++ .../ViewModels/GroupViewModel.cs | 15 + .../ViewModels/ProfileViewModel.cs | 10 + .../ViewModels/SNMPCredentialsViewModel.cs | 262 ++++++++++++++++++ .../Views/GroupChildWindow.xaml | 33 +++ .../Views/ProfileChildWindow.xaml | 33 +++ 10 files changed, 596 insertions(+) create mode 100644 Source/NETworkManager/Controls/SNMPCredentialsControl.xaml create mode 100644 Source/NETworkManager/Controls/SNMPCredentialsControl.xaml.cs create mode 100644 Source/NETworkManager/ViewModels/SNMPCredentialsViewModel.cs diff --git a/Source/NETworkManager.Profiles/GroupViewManager.cs b/Source/NETworkManager.Profiles/GroupViewManager.cs index 7758b9c6f6..f45115dd3b 100644 --- a/Source/NETworkManager.Profiles/GroupViewManager.cs +++ b/Source/NETworkManager.Profiles/GroupViewManager.cs @@ -13,6 +13,7 @@ public static class GroupViewManager new GroupViewInfo(GroupViewName.General, new PackIconModern { Kind = PackIconModernKind.Box }), // Applications + new GroupViewInfo(GroupViewName.IPScanner, ApplicationManager.GetIcon(ApplicationName.IPScanner)), new GroupViewInfo(GroupViewName.PortScanner, ApplicationManager.GetIcon(ApplicationName.PortScanner)), new GroupViewInfo(GroupViewName.DNSLookup, ApplicationManager.GetIcon(ApplicationName.DNSLookup)), new GroupViewInfo(GroupViewName.RemoteDesktop, ApplicationManager.GetIcon(ApplicationName.RemoteDesktop)), diff --git a/Source/NETworkManager.Profiles/GroupViewName.cs b/Source/NETworkManager.Profiles/GroupViewName.cs index 153ef3d5cf..5f0de1cad6 100644 --- a/Source/NETworkManager.Profiles/GroupViewName.cs +++ b/Source/NETworkManager.Profiles/GroupViewName.cs @@ -3,6 +3,7 @@ public enum GroupViewName { General, + IPScanner, PortScanner, DNSLookup, RemoteDesktop, diff --git a/Source/NETworkManager/Controls/SNMPCredentialsControl.xaml b/Source/NETworkManager/Controls/SNMPCredentialsControl.xaml new file mode 100644 index 0000000000..b18a45b371 --- /dev/null +++ b/Source/NETworkManager/Controls/SNMPCredentialsControl.xaml @@ -0,0 +1,208 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -