From a48eb0b8d961cff155e993b3393807333d4e2ed4 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Sat, 24 Oct 2026 12:54:45 +0200 Subject: [PATCH 001/146] Refactor: IP Scanner / Port Scanner options with init properties (R1) Replace the positional constructors (14 / 6 parameters) of IPScannerOptions and PortScannerOptions with init-only properties set via object initializer. No behaviour change. Co-Authored-By: Claude Opus 5.5 --- .../Network/IPScannerOptions.cs | 69 +++++-------------- .../Network/PortScannerOptions.cs | 38 +++------- .../ViewModels/IPScannerViewModel.cs | 33 ++++----- .../ViewModels/PortScannerViewModel.cs | 17 ++--- 4 files changed, 51 insertions(+), 106 deletions(-) diff --git a/Source/NETworkManager.Models/Network/IPScannerOptions.cs b/Source/NETworkManager.Models/Network/IPScannerOptions.cs index b7c4fdcb3a..7a94646369 100644 --- a/Source/NETworkManager.Models/Network/IPScannerOptions.cs +++ b/Source/NETworkManager.Models/Network/IPScannerOptions.cs @@ -1,114 +1,77 @@ namespace NETworkManager.Models.Network; /// -/// Class contains the options for the traceroute. +/// Class contains the options for the IP scanner. /// public class IPScannerOptions { - /// - /// Create an instance of with parameters. - /// - /// Max concurrent threads for the host scan. - /// Max concurrent threads for the port scan of each host. - /// Number of attempts to ping a host. - /// Timeout in milliseconds after which a ping is considered lost. - /// Size of the buffer used in the ping in bytes. - /// Scan the ports of the host. - /// List of ports to scan. - /// Timeout in milliseconds after which a port is considered closed. - /// Number of attempts to connect to a port if there was no answer. - /// Resolve the hostname for an IP address. - /// Resolve the NetBIOS name for an IP address. - /// Timeout in milliseconds after which a NetBIOS request is considered lost. - /// Resolve the MAC address for the host from ARP. - /// Include unreachable IP addresses in the result. - public IPScannerOptions(int maxHostThreads, int maxPortThreads, int icmpAttempts, int icmpTimeout, - byte[] icmpBuffer, bool resolveHostname, bool portScanEnabled, int[] portScanPorts, int portScanTimeout, - int portScanAttempts, bool netBIOSEnabled, int netBIOSTimeout, bool resolveMACAddress, bool showAllResults) - { - MaxHostThreads = maxHostThreads; - MaxPortThreads = maxPortThreads; - ICMPAttempts = icmpAttempts; - ICMPTimeout = icmpTimeout; - ICMPBuffer = icmpBuffer; - ResolveHostname = resolveHostname; - PortScanEnabled = portScanEnabled; - PortScanPorts = portScanPorts; - PortScanTimeout = portScanTimeout; - PortScanAttempts = portScanAttempts; - NetBIOSEnabled = netBIOSEnabled; - NetBIOSTimeout = netBIOSTimeout; - ResolveMACAddress = resolveMACAddress; - ShowAllResults = showAllResults; - } - /// /// Max concurrent threads for the host scan. /// - public int MaxHostThreads { get; } + public int MaxHostThreads { get; init; } /// /// Max concurrent threads for the port scan of each host. /// - public int MaxPortThreads { get; } + public int MaxPortThreads { get; init; } /// /// Number of attempts to ping a host. /// - public int ICMPAttempts { get; } + public int ICMPAttempts { get; init; } /// /// Timeout in milliseconds after which a ping is considered lost. /// - public int ICMPTimeout { get; } + public int ICMPTimeout { get; init; } /// /// Size of the buffer used in the ping in bytes. /// - public byte[] ICMPBuffer { get; } + public byte[] ICMPBuffer { get; init; } = []; /// /// Resolve the hostname for an IP address. /// - public bool ResolveHostname { get; } + public bool ResolveHostname { get; init; } /// /// Scan the ports of the host. /// - public bool PortScanEnabled { get; } + public bool PortScanEnabled { get; init; } /// /// Ports to scan. /// - public int[] PortScanPorts { get; } + public int[] PortScanPorts { get; init; } = []; /// /// Timeout in milliseconds after which a port is considered closed. /// - public int PortScanTimeout { get; } + public int PortScanTimeout { get; init; } /// /// Number of attempts to connect to a port if there was no answer (timed out). /// - public int PortScanAttempts { get; } + public int PortScanAttempts { get; init; } /// /// Resolve the NetBIOS name for an IP address. /// - public bool NetBIOSEnabled { get; } + public bool NetBIOSEnabled { get; init; } /// /// Timeout in milliseconds after which a NetBIOS request is considered lost. /// - public int NetBIOSTimeout { get; } + public int NetBIOSTimeout { get; init; } /// /// Resolve the MAC address for the host from ARP. /// - public bool ResolveMACAddress { get; } + public bool ResolveMACAddress { get; init; } /// /// Include unreachable IP addresses in the result. /// - public bool ShowAllResults { get; } -} \ No newline at end of file + public bool ShowAllResults { get; init; } +} diff --git a/Source/NETworkManager.Models/Network/PortScannerOptions.cs b/Source/NETworkManager.Models/Network/PortScannerOptions.cs index ef32159425..a022546f0f 100644 --- a/Source/NETworkManager.Models/Network/PortScannerOptions.cs +++ b/Source/NETworkManager.Models/Network/PortScannerOptions.cs @@ -6,52 +6,32 @@ public class PortScannerOptions { /// - /// Create an instance of with parameters. + /// Max concurrent threads for the host scan. /// - /// Max cuncurrent threads for the host scan. - /// Max cuncurrent threads for the port scan of each host. - /// Timeout in ms after which the port is considered closed. - /// Number of attempts to connect to a port if there was no answer. - /// Resolve the hostname for an IP address. - /// Include closed ports in the result. - public PortScannerOptions(int maxHostThreads, int maxPortThreads, int timeout, int attempts, - bool resolveHostname, bool showAllResults) - { - MaxHostThreads = maxHostThreads; - MaxPortThreads = maxPortThreads; - Timeout = timeout; - Attempts = attempts; - ResolveHostname = resolveHostname; - ShowAllResults = showAllResults; - } + public int MaxHostThreads { get; init; } /// - /// Max cuncurrent threads for the host scan. + /// Max concurrent threads for the port scan of each host. /// - public int MaxHostThreads { get; set; } - - /// - /// Max cuncurrent threads for the port scan of each host. - /// - public int MaxPortThreads { get; set; } + public int MaxPortThreads { get; init; } /// /// Timeout in ms after which the port is considered closed. /// - public int Timeout { get; set; } + public int Timeout { get; init; } /// /// Number of attempts to connect to a port if there was no answer (timed out). /// - public int Attempts { get; set; } + public int Attempts { get; init; } /// /// Resolve the hostname for an IP address. /// - public bool ResolveHostname { get; set; } + public bool ResolveHostname { get; init; } /// /// Include closed ports in the result. /// - public bool ShowAllResults { get; set; } -} \ No newline at end of file + public bool ShowAllResults { get; init; } +} diff --git a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs index c36ae8b305..3e8a7e585a 100644 --- a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs @@ -610,22 +610,23 @@ private async Task Start() ListHelper.Modify(SettingsManager.Current.IPScanner_HostHistory, Host, SettingsManager.Current.General_HistoryListEntries); - var ipScanner = new IPScanner(new IPScannerOptions( - SettingsManager.Current.IPScanner_MaxHostThreads, - SettingsManager.Current.IPScanner_MaxPortThreads, - SettingsManager.Current.IPScanner_ICMPAttempts, - SettingsManager.Current.IPScanner_ICMPTimeout, - new byte[SettingsManager.Current.IPScanner_ICMPBuffer], - SettingsManager.Current.IPScanner_ResolveHostname, - SettingsManager.Current.IPScanner_PortScanEnabled, - ports, - SettingsManager.Current.IPScanner_PortScanTimeout, - SettingsManager.Current.IPScanner_PortScanAttempts, - SettingsManager.Current.IPScanner_NetBIOSEnabled, - SettingsManager.Current.IPScanner_NetBIOSTimeout, - SettingsManager.Current.IPScanner_ResolveMACAddress, - SettingsManager.Current.IPScanner_ShowAllResults - )); + var ipScanner = new IPScanner(new IPScannerOptions + { + MaxHostThreads = SettingsManager.Current.IPScanner_MaxHostThreads, + MaxPortThreads = SettingsManager.Current.IPScanner_MaxPortThreads, + ICMPAttempts = SettingsManager.Current.IPScanner_ICMPAttempts, + ICMPTimeout = SettingsManager.Current.IPScanner_ICMPTimeout, + ICMPBuffer = new byte[SettingsManager.Current.IPScanner_ICMPBuffer], + ResolveHostname = SettingsManager.Current.IPScanner_ResolveHostname, + PortScanEnabled = SettingsManager.Current.IPScanner_PortScanEnabled, + PortScanPorts = ports, + PortScanTimeout = SettingsManager.Current.IPScanner_PortScanTimeout, + PortScanAttempts = SettingsManager.Current.IPScanner_PortScanAttempts, + NetBIOSEnabled = SettingsManager.Current.IPScanner_NetBIOSEnabled, + NetBIOSTimeout = SettingsManager.Current.IPScanner_NetBIOSTimeout, + ResolveMACAddress = SettingsManager.Current.IPScanner_ResolveMACAddress, + ShowAllResults = SettingsManager.Current.IPScanner_ShowAllResults + }); ipScanner.HostScanned += HostScanned; ipScanner.ScanComplete += ScanComplete; diff --git a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs index f618cc7747..28f89296c7 100644 --- a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs @@ -557,14 +557,15 @@ private async Task Start() ListHelper.Modify(SettingsManager.Current.PortScanner_PortHistory, Ports, SettingsManager.Current.General_HistoryListEntries); - var portScanner = new PortScanner(new PortScannerOptions( - SettingsManager.Current.PortScanner_MaxHostThreads, - SettingsManager.Current.PortScanner_MaxPortThreads, - SettingsManager.Current.PortScanner_Timeout, - SettingsManager.Current.PortScanner_Attempts, - SettingsManager.Current.PortScanner_ResolveHostname, - SettingsManager.Current.PortScanner_ShowAllResults - )); + var portScanner = new PortScanner(new PortScannerOptions + { + MaxHostThreads = SettingsManager.Current.PortScanner_MaxHostThreads, + MaxPortThreads = SettingsManager.Current.PortScanner_MaxPortThreads, + Timeout = SettingsManager.Current.PortScanner_Timeout, + Attempts = SettingsManager.Current.PortScanner_Attempts, + ResolveHostname = SettingsManager.Current.PortScanner_ResolveHostname, + ShowAllResults = SettingsManager.Current.PortScanner_ShowAllResults + }); portScanner.PortScanned += PortScanned; portScanner.ScanComplete += ScanComplete; From a1fa49f7d152f9eeb49db7f4d07df2aca242d359 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Sat, 24 Oct 2026 12:56:17 +0200 Subject: [PATCH 002/146] Refactor: Split the IP Scanner host scan into discover / enrich (R2) The per-host lambda in ScanAsync is split into DiscoverAsync (ping, port scan and NetBIOS concurrently -> reachable) and EnrichAsync (DNS PTR, MAC address and vendor -> result), with ResolveHostnameAsync / ResolveMACAddressAsync as own methods. Prepares progressive results (enrich as soon as a host answers). No behaviour change. Port Scanner: nothing to split (per port probe -> result, PTR already runs concurrently). Co-Authored-By: Claude Opus 5.5 --- .../Network/IPScanner.cs | 268 +++++++++++------- 1 file changed, 162 insertions(+), 106 deletions(-) diff --git a/Source/NETworkManager.Models/Network/IPScanner.cs b/Source/NETworkManager.Models/Network/IPScanner.cs index 7e1456f437..b971698f6a 100644 --- a/Source/NETworkManager.Models/Network/IPScanner.cs +++ b/Source/NETworkManager.Models/Network/IPScanner.cs @@ -145,123 +145,23 @@ await Parallel.ForEachAsync(hosts, hostParallelOptions, async (host, ct) => // Spread the start of the hosts over time instead of starting all at once await hostStartScheduler.WaitAsync(ct).ConfigureAwait(false); - // Start ping, port scan and netbios lookup concurrently - none of these block a thread anymore - var pingTask = PingAsync(host.ipAddress, ct); - - var portScanTask = options.PortScanEnabled - ? PortScanAsync(host.ipAddress, portScanParallelOptions, ct) - : Task.FromResult(new List()); - - var netbiosTask = options.NetBIOSEnabled - ? NetBIOSResolver.ResolveAsync(host.ipAddress, options.NetBIOSTimeout, ct) - : Task.FromResult(new NetBIOSInfo(host.ipAddress)); - - await Task.WhenAll(pingTask, portScanTask, netbiosTask).ConfigureAwait(false); - - // A reachable host is in the neighbor table (ARP/NDP) from now on (before it can answer, the - // local system has to resolve its MAC address) - var discoveredAt = DateTime.UtcNow; - - var pingInfo = pingTask.Result; - var portScanResults = portScanTask.Result; - var netBIOSInfo = netbiosTask.Result; + var discovery = await DiscoverAsync(host.ipAddress, portScanParallelOptions, ct) + .ConfigureAwait(false); // Cancel if the user has canceled ct.ThrowIfCancellationRequested(); - // Check if host is up - var isAnyPortOpen = portScanResults.Any(x => x.State == PortState.Open); - var isReachable = pingInfo.Status == IPStatus.Success || // ICMP response - isAnyPortOpen || // Any port is open - portScanResults.Any(x => x.State == PortState.Closed) || // Connection refused (TCP reset) - netBIOSInfo.IsReachable; // NetBIOS response - // Count reachable/unreachable hosts unconditionally, since ShowAllResults // (below) may prevent unreachable hosts from ever reaching HostScanned - if (isReachable) + if (discovery.IsReachable) _hostsUp.Increment(); else _hostsDown.Increment(); - // DNS & ARP - if (isReachable || options.ShowAllResults) - { - // DNS - var dnsHostname = string.Empty; - - if (options.ResolveHostname) - { - var dnsResult = await DNSClient.GetInstance().ResolvePtrAsync(host.ipAddress) - .WaitAsync(ct).ConfigureAwait(false); - - if (!dnsResult.HasError) - dnsHostname = dnsResult.Value; - } - - // ARP - var arpMACAddress = string.Empty; - var arpVendor = string.Empty; - - if (options.ResolveMACAddress) - { - // 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 never needs a new snapshot. - if (IsInLocalSubnet(host.ipAddress, networkInterfaces)) - arpMACAddress = await GetMACAddressFromNeighborTableAsync(host.ipAddress, - isReachable ? discoveredAt : DateTime.MinValue, ct).ConfigureAwait(false); - - // Check if it is the local mac - if (string.IsNullOrEmpty(arpMACAddress)) - { - var ipAddress = IPAddressHelper.WithoutScopeId(host.ipAddress); - - var networkInterfaceInfo = networkInterfaces.FirstOrDefault(p => - p.IPv4Address.Any(x => x.Item1.Equals(ipAddress)) || - p.IPv6Address.Concat(p.IPv6AddressLinkLocal) - .Any(x => IPAddressHelper.WithoutScopeId(x).Equals(ipAddress))); - - if (networkInterfaceInfo != null) - arpMACAddress = networkInterfaceInfo.PhysicalAddress.ToString(); - } - - // Vendor lookup & default format - if (!string.IsNullOrEmpty(arpMACAddress)) - { - var info = OUILookup.LookupByMacAddress(arpMACAddress).FirstOrDefault(); - - if (info != null) - arpVendor = info.Vendor; - - // Apply default format - arpMACAddress = MACAddressHelper.GetDefaultFormat(arpMACAddress); - } - } - + if (discovery.IsReachable || options.ShowAllResults) OnHostScanned(new IPScannerHostScannedArgs( - new IPScannerHostInfo( - isReachable, - pingInfo, - // DNS is default, fallback to netbios - !string.IsNullOrEmpty(dnsHostname) - ? dnsHostname - : netBIOSInfo?.ComputerName ?? string.Empty, - dnsHostname, - isAnyPortOpen, - portScanResults - .Where(x => x.State == PortState.Open || options.ShowAllResults) - .OrderBy(x => x.Port).ToList(), - netBIOSInfo, - // ARP/NDP is preferred, fallback to NetBIOS - !string.IsNullOrEmpty(arpMACAddress) - ? arpMACAddress - : netBIOSInfo?.MACAddress ?? string.Empty, - !string.IsNullOrEmpty(arpMACAddress) - ? arpVendor - : netBIOSInfo?.Vendor ?? string.Empty - ) - ) - ); - } + await EnrichAsync(host.ipAddress, discovery, networkInterfaces, ct) + .ConfigureAwait(false))); IncreaseProgress(); }).ConfigureAwait(false); @@ -281,6 +181,162 @@ await Parallel.ForEachAsync(hosts, hostParallelOptions, async (host, ct) => }); } + /// + /// Result of : the probes that decide if a host is reachable. + /// + /// Result of the ping (last attempt). + /// All probed ports (also closed / timed out ones). + /// Result of the NetBIOS request. + /// Time all probes were finished (UTC). + private sealed record HostDiscovery( + PingInfo PingInfo, + List Ports, + NetBIOSInfo NetBIOSInfo, + DateTime DiscoveredAt) + { + /// + /// Indicates whether any port is open. + /// + public bool IsAnyPortOpen { get; } = Ports.Any(x => x.State == PortState.Open); + + /// + /// Indicates whether the host answered any probe. + /// + public bool IsReachable => PingInfo.Status == IPStatus.Success || // ICMP response + IsAnyPortOpen || // Any port is open + Ports.Any(x => x.State == PortState.Closed) || // Connection refused (TCP reset) + NetBIOSInfo.IsReachable; // NetBIOS response + } + + /// + /// Discovers a host: ping, port scan and NetBIOS request run concurrently (none of these block a thread). + /// + /// IP address of the host. + /// Options for the concurrent port probes of the host. + /// Token to monitor for cancellation requests. + /// Results of all probes. + private async Task DiscoverAsync(IPAddress ipAddress, ParallelOptions portScanParallelOptions, + CancellationToken cancellationToken) + { + var pingTask = PingAsync(ipAddress, cancellationToken); + + var portScanTask = options.PortScanEnabled + ? PortScanAsync(ipAddress, portScanParallelOptions, cancellationToken) + : Task.FromResult(new List()); + + var netbiosTask = options.NetBIOSEnabled + ? NetBIOSResolver.ResolveAsync(ipAddress, options.NetBIOSTimeout, cancellationToken) + : Task.FromResult(new NetBIOSInfo(ipAddress)); + + await Task.WhenAll(pingTask, portScanTask, netbiosTask).ConfigureAwait(false); + + // A reachable host is in the neighbor table (ARP/NDP) from now on (before it can answer, the + // local system has to resolve its MAC address) + return new HostDiscovery(pingTask.Result, portScanTask.Result, netbiosTask.Result, DateTime.UtcNow); + } + + /// + /// Enriches a discovered host with the DNS hostname (PTR), MAC address and vendor and creates the result. + /// + /// IP address of the host. + /// Result of . + /// Local network interfaces (for the MAC address lookup). + /// Token to monitor for cancellation requests. + /// Result of the host. + private async Task EnrichAsync(IPAddress ipAddress, HostDiscovery discovery, + List networkInterfaces, CancellationToken cancellationToken) + { + var dnsHostname = options.ResolveHostname + ? await ResolveHostnameAsync(ipAddress, cancellationToken).ConfigureAwait(false) + : string.Empty; + + var (arpMACAddress, arpVendor) = options.ResolveMACAddress + ? await ResolveMACAddressAsync(ipAddress, discovery.IsReachable ? discovery.DiscoveredAt : DateTime.MinValue, + networkInterfaces, cancellationToken).ConfigureAwait(false) + : (string.Empty, string.Empty); + + var netBIOSInfo = discovery.NetBIOSInfo; + + return new IPScannerHostInfo( + discovery.IsReachable, + discovery.PingInfo, + // DNS is default, fallback to netbios + !string.IsNullOrEmpty(dnsHostname) + ? dnsHostname + : netBIOSInfo?.ComputerName ?? string.Empty, + dnsHostname, + discovery.IsAnyPortOpen, + discovery.Ports + .Where(x => x.State == PortState.Open || options.ShowAllResults) + .OrderBy(x => x.Port).ToList(), + netBIOSInfo, + // ARP/NDP is preferred, fallback to NetBIOS + !string.IsNullOrEmpty(arpMACAddress) + ? arpMACAddress + : netBIOSInfo?.MACAddress ?? string.Empty, + !string.IsNullOrEmpty(arpMACAddress) + ? arpVendor + : netBIOSInfo?.Vendor ?? string.Empty + ); + } + + /// + /// Resolves the DNS hostname (PTR record) of a host. + /// + /// IP address of the host. + /// Token to monitor for cancellation requests. + /// Hostname or an empty string. + private static async Task ResolveHostnameAsync(IPAddress ipAddress, CancellationToken cancellationToken) + { + var dnsResult = await DNSClient.GetInstance().ResolvePtrAsync(ipAddress) + .WaitAsync(cancellationToken).ConfigureAwait(false); + + return dnsResult.HasError ? string.Empty : dnsResult.Value; + } + + /// + /// Resolves the MAC address of a host from the neighbor table (ARP/NDP) or the local network interfaces, + /// and its vendor. + /// + /// IP address of the host. + /// Time the host answered, if it is unreachable. + /// Local network interfaces. + /// Token to monitor for cancellation requests. + /// MAC address (default format) and vendor, empty strings if not found. + private async Task<(string macAddress, string vendor)> ResolveMACAddressAsync(IPAddress ipAddress, + DateTime discoveredAt, List networkInterfaces, CancellationToken cancellationToken) + { + var macAddress = string.Empty; + + // 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 never needs a new snapshot. + if (IsInLocalSubnet(ipAddress, networkInterfaces)) + macAddress = await GetMACAddressFromNeighborTableAsync(ipAddress, discoveredAt, cancellationToken) + .ConfigureAwait(false); + + // Check if it is the local mac + if (string.IsNullOrEmpty(macAddress)) + { + var ipAddressWithoutScopeId = IPAddressHelper.WithoutScopeId(ipAddress); + + var networkInterfaceInfo = networkInterfaces.FirstOrDefault(p => + p.IPv4Address.Any(x => x.Item1.Equals(ipAddressWithoutScopeId)) || + p.IPv6Address.Concat(p.IPv6AddressLinkLocal) + .Any(x => IPAddressHelper.WithoutScopeId(x).Equals(ipAddressWithoutScopeId))); + + if (networkInterfaceInfo != null) + macAddress = networkInterfaceInfo.PhysicalAddress.ToString(); + } + + if (string.IsNullOrEmpty(macAddress)) + return (string.Empty, string.Empty); + + // Vendor lookup & default format + var vendor = OUILookup.LookupByMacAddress(macAddress).FirstOrDefault()?.Vendor ?? string.Empty; + + return (MACAddressHelper.GetDefaultFormat(macAddress), vendor); + } + /// /// Gets the MAC address 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 From 895ddcb6e670137cbd50a664ff5fd593f99c3c81 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Sat, 24 Oct 2026 12:57:08 +0200 Subject: [PATCH 003/146] Fix: Trim the trailing dot of DNS hostnames once (C2) The PTR record is fully qualified (e.g. "server.example.com."). The IP Scanner only trimmed it for custom commands and "Add profile", so the grid, the export and other features (DNS Lookup, Remote Desktop, ...) got the hostname with the trailing dot. It is now trimmed once when the result is created, in the IP Scanner and the Port Scanner. Co-Authored-By: Claude Opus 5.5 --- Source/NETworkManager.Models/Network/IPScanner.cs | 5 +++-- Source/NETworkManager.Models/Network/PortScanner.cs | 3 ++- Source/NETworkManager/ViewModels/IPScannerViewModel.cs | 4 ++-- Website/docs/changelog/next-release.md | 1 + 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/Source/NETworkManager.Models/Network/IPScanner.cs b/Source/NETworkManager.Models/Network/IPScanner.cs index b971698f6a..721c2b7b10 100644 --- a/Source/NETworkManager.Models/Network/IPScanner.cs +++ b/Source/NETworkManager.Models/Network/IPScanner.cs @@ -285,13 +285,14 @@ private async Task EnrichAsync(IPAddress ipAddress, HostDisco /// /// IP address of the host. /// Token to monitor for cancellation requests. - /// Hostname or an empty string. + /// Hostname without the trailing dot or an empty string. private static async Task ResolveHostnameAsync(IPAddress ipAddress, CancellationToken cancellationToken) { var dnsResult = await DNSClient.GetInstance().ResolvePtrAsync(ipAddress) .WaitAsync(cancellationToken).ConfigureAwait(false); - return dnsResult.HasError ? string.Empty : dnsResult.Value; + // The PTR record is fully qualified (trailing dot) - trim it once for the grid, export and other tools + return dnsResult.HasError ? string.Empty : dnsResult.Value.TrimEnd('.'); } /// diff --git a/Source/NETworkManager.Models/Network/PortScanner.cs b/Source/NETworkManager.Models/Network/PortScanner.cs index 2716b0e342..c7d91b69e5 100644 --- a/Source/NETworkManager.Models/Network/PortScanner.cs +++ b/Source/NETworkManager.Models/Network/PortScanner.cs @@ -177,7 +177,8 @@ private static async Task ResolveHostnameAsync(IPAddress ipAddress, Canc var dnsResult = await DNSClient.GetInstance().ResolvePtrAsync(ipAddress) .WaitAsync(cancellationToken).ConfigureAwait(false); - return dnsResult.HasError ? string.Empty : dnsResult.Value; + // The PTR record is fully qualified (trailing dot) - trim it once for the grid, export and other tools + return dnsResult.HasError ? string.Empty : dnsResult.Value.TrimEnd('.'); } private void IncreaseProgress() diff --git a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs index 3e8a7e585a..c0c668194b 100644 --- a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs @@ -451,7 +451,7 @@ private async void AddProfileSelectedHostAction() { Name = string.IsNullOrEmpty(SelectedResult.Hostname) ? SelectedResult.PingInfo.IPAddress.ToString() - : SelectedResult.Hostname.TrimEnd('.'), + : SelectedResult.Hostname, Host = SelectedResult.PingInfo.IPAddress.ToString(), // Additional data @@ -743,7 +743,7 @@ private async Task CustomCommand(object guid) } // Replace vars - var hostname = !string.IsNullOrEmpty(SelectedResult.Hostname) ? SelectedResult.Hostname.TrimEnd('.') : ""; + var hostname = SelectedResult.Hostname ?? ""; // The hostname comes from the network (PTR record or NetBIOS name) and could contain characters // like "&" that would run additional commands (e.g. "cmd.exe /c ... {{Hostname}}") - only use valid ones diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index 6964fe76b5..3986ee8f3b 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -205,6 +205,7 @@ Release date: **xx.xx.2026** - **IP Scanner**: Fixed **Copy** > **Hostname** / **MAC address** / **Vendor** being hidden in the context menu while the column was shown (resolved via NetBIOS only), and **Copy** > **Status** copying `True` / `False` instead of `Up` / `Down`. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) - **IP Scanner**: Fixed a hostname received from the network (DNS PTR record or NetBIOS name) with special characters like `&` being able to run additional commands through a custom command with `{{Hostname}}` (e.g. `cmd.exe /c ...`). Such a hostname is now replaced with an empty value. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) - **IP Scanner**: Fixed **Edit** (`F2`) and **Delete** (`Del`) of a custom command in the settings being available without a selected command. The delete confirmation shows the name of the command. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) +- Fixed hostnames resolved via DNS (`PTR`) being shown with a trailing dot (e.g. `server.example.com.`), also in the export and when opened in another feature (e.g. **DNS Lookup**, **Remote Desktop**). [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) **IP Geolocation** From 441cf296df6ad691d85ad3ec8a0cf2c385eb1a27 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Sat, 24 Oct 2026 12:57:35 +0200 Subject: [PATCH 004/146] Improvement: IP Scanner export DNSHostname to CSV and XML (C4) The DNS hostname (without the NetBIOS fallback of Hostname) was only exported to JSON. Add it to CSV and XML, right after Hostname (JSON moved to the same position). Port Scanner: no separate DNS hostname, nothing to change. Co-Authored-By: Claude Opus 5.5 --- .../Export/ExportManager.IPScannerHostInfo.cs | 5 ++++- Website/docs/changelog/next-release.md | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/Source/NETworkManager.Models/Export/ExportManager.IPScannerHostInfo.cs b/Source/NETworkManager.Models/Export/ExportManager.IPScannerHostInfo.cs index 07ab949f82..7d253dd848 100644 --- a/Source/NETworkManager.Models/Export/ExportManager.IPScannerHostInfo.cs +++ b/Source/NETworkManager.Models/Export/ExportManager.IPScannerHostInfo.cs @@ -51,6 +51,7 @@ private static void CreateCsv(IEnumerable collection, string $"{nameof(IPScannerHostInfo.IsReachable)}," + $"{nameof(PingInfo.IPAddress)}," + $"{nameof(IPScannerHostInfo.Hostname)}," + + $"{nameof(IPScannerHostInfo.DNSHostname)}," + $"PingStatus," + $"{nameof(PingInfo.Timestamp)}," + $"{nameof(PingInfo.Time)}," + @@ -88,6 +89,7 @@ private static void CreateCsv(IEnumerable collection, string $"{info.IsReachable}," + $"{info.PingInfo.IPAddress}," + $"{CsvHelper.QuoteString(info.Hostname)}," + + $"{CsvHelper.QuoteString(info.DNSHostname)}," + $"{info.PingInfo.Status}," + $"{DateTimeHelper.DateTimeToFullDateTimeString(info.PingInfo.Timestamp)}," + $"{Ping.TimeToString(info.PingInfo.Status, info.PingInfo.Time, true)}," + @@ -127,6 +129,7 @@ from info in collection new XElement(nameof(IPScannerHostInfo.IsReachable), info.IsReachable), new XElement(nameof(PingInfo.IPAddress), info.PingInfo.IPAddress), new XElement(nameof(IPScannerHostInfo.Hostname), info.Hostname), + new XElement(nameof(IPScannerHostInfo.DNSHostname), info.DNSHostname), new XElement("PingStatus", info.PingInfo.Status), new XElement(nameof(PingInfo.Timestamp), DateTimeHelper.DateTimeToFullDateTimeString(info.PingInfo.Timestamp)), @@ -199,12 +202,12 @@ private static void CreateJson(IReadOnlyList collection, stri info.IsReachable, IPAddress = info.PingInfo.IPAddress.ToString(), info.Hostname, + info.DNSHostname, PingStatus = info.PingInfo.Status.ToString(), Timestamp = DateTimeHelper.DateTimeToFullDateTimeString(info.PingInfo.Timestamp), Time = Ping.TimeToString(info.PingInfo.Status, info.PingInfo.Time, true), info.PingInfo.TTL, info.PingInfo.Bytes, - info.DNSHostname, PortStatus = info.IsAnyPortOpen ? PortState.Open.ToString() : PortState.Closed.ToString(), Ports = jsonDataPorts, NetBIOSIsReachable = info.NetBIOSInfo?.IsReachable, diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index 3986ee8f3b..e1684451a1 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -70,6 +70,7 @@ Release date: **xx.xx.2026** - The neighbor table (ARP/NDP) is read once and cached during a scan instead of once per reachable host to resolve the MAC address. The MAC address of the own IPv6 addresses and of IPv6 link-local neighbors is resolved as well. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) - **Resolve MAC address and vendor** is enabled by default for new installations (as documented). [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) - 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 hosts were scanned (also in the **Port Scanner**). [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) +- The CSV and XML export contain the DNS hostname (`DNSHostname`) in addition to the hostname (DNS, or NetBIOS as fallback), like the JSON export. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) - Hosts are started one after another (every 10 ms, shorter with short timeouts and many threads) instead of all at the same moment, the **Port Scanner** does the same with ports (every 1 ms). This avoids bursts of more than a thousand connection requests within a few milliseconds and makes the progress smoother. [#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) From 54c80d5d81c6ba9a45715dc793a39d41ca144fe7 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Sat, 24 Oct 2026 12:59:22 +0200 Subject: [PATCH 005/146] Fix: IP Scanner / Port Scanner columns follow the options of the shown scan (C5) Columns, Copy menu entries and the row details (IP Scanner) were bound to the current settings. After changing a setting the shown results got columns without data (e.g. MAC address enabled after the scan) or lost columns with data, the row details showed "Port scan is disabled" for scanned ports. The view models keep the options of the shown scan (ScanOptions, until the first scan the settings when the tab was opened) and the views bind to them via the BindingProxy (DataGrid columns are not in the visual tree). Port Scanner: Hostname column and Copy > Hostname. Co-Authored-By: Claude Opus 5.5 --- .../ViewModels/IPScannerViewModel.cs | 67 ++++++++++++++----- .../ViewModels/PortScannerViewModel.cs | 50 +++++++++++--- .../NETworkManager/Views/IPScannerView.xaml | 63 +++++++++-------- .../NETworkManager/Views/PortScannerView.xaml | 6 +- Website/docs/application/ip-scanner.md | 2 + Website/docs/application/port-scanner.md | 2 +- Website/docs/changelog/next-release.md | 1 + 7 files changed, 129 insertions(+), 62 deletions(-) diff --git a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs index c0c668194b..d7d863fcc4 100644 --- a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs @@ -290,6 +290,24 @@ private set } } + /// + /// Gets the options of the scan whose results are shown. Columns, context menu and row details follow them + /// instead of the current settings, which may have been changed since. Before the first scan: the settings + /// when the tab was opened. + /// + public IPScannerOptions ScanOptions + { + get; + private set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } + /// /// Gets the available custom commands for the IP Scanner. /// @@ -317,6 +335,9 @@ public IPScannerViewModel(Guid tabId, string hostOrIPRange, bool start = true) // Host history HostHistoryView = CollectionViewSource.GetDefaultView(SettingsManager.Current.IPScanner_HostHistory); + // Columns etc. until the first scan + ScanOptions = CreateScanOptions([]); + // Result view ResultsView = CollectionViewSource.GetDefaultView(Results); @@ -610,23 +631,9 @@ private async Task Start() ListHelper.Modify(SettingsManager.Current.IPScanner_HostHistory, Host, SettingsManager.Current.General_HistoryListEntries); - var ipScanner = new IPScanner(new IPScannerOptions - { - MaxHostThreads = SettingsManager.Current.IPScanner_MaxHostThreads, - MaxPortThreads = SettingsManager.Current.IPScanner_MaxPortThreads, - ICMPAttempts = SettingsManager.Current.IPScanner_ICMPAttempts, - ICMPTimeout = SettingsManager.Current.IPScanner_ICMPTimeout, - ICMPBuffer = new byte[SettingsManager.Current.IPScanner_ICMPBuffer], - ResolveHostname = SettingsManager.Current.IPScanner_ResolveHostname, - PortScanEnabled = SettingsManager.Current.IPScanner_PortScanEnabled, - PortScanPorts = ports, - PortScanTimeout = SettingsManager.Current.IPScanner_PortScanTimeout, - PortScanAttempts = SettingsManager.Current.IPScanner_PortScanAttempts, - NetBIOSEnabled = SettingsManager.Current.IPScanner_NetBIOSEnabled, - NetBIOSTimeout = SettingsManager.Current.IPScanner_NetBIOSTimeout, - ResolveMACAddress = SettingsManager.Current.IPScanner_ResolveMACAddress, - ShowAllResults = SettingsManager.Current.IPScanner_ShowAllResults - }); + ScanOptions = CreateScanOptions(ports); + + var ipScanner = new IPScanner(ScanOptions); ipScanner.HostScanned += HostScanned; ipScanner.ScanComplete += ScanComplete; @@ -646,6 +653,32 @@ private async Task Start() ipScanner.ScanAsync(hosts.hosts, _cancellationTokenSource.Token); } + /// + /// Creates the scan options from the current settings. + /// + /// Ports to scan. + /// Options for the . + private static IPScannerOptions CreateScanOptions(int[] ports) + { + return new IPScannerOptions + { + MaxHostThreads = SettingsManager.Current.IPScanner_MaxHostThreads, + MaxPortThreads = SettingsManager.Current.IPScanner_MaxPortThreads, + ICMPAttempts = SettingsManager.Current.IPScanner_ICMPAttempts, + ICMPTimeout = SettingsManager.Current.IPScanner_ICMPTimeout, + ICMPBuffer = new byte[SettingsManager.Current.IPScanner_ICMPBuffer], + ResolveHostname = SettingsManager.Current.IPScanner_ResolveHostname, + PortScanEnabled = SettingsManager.Current.IPScanner_PortScanEnabled, + PortScanPorts = ports, + PortScanTimeout = SettingsManager.Current.IPScanner_PortScanTimeout, + PortScanAttempts = SettingsManager.Current.IPScanner_PortScanAttempts, + NetBIOSEnabled = SettingsManager.Current.IPScanner_NetBIOSEnabled, + NetBIOSTimeout = SettingsManager.Current.IPScanner_NetBIOSTimeout, + ResolveMACAddress = SettingsManager.Current.IPScanner_ResolveMACAddress, + ShowAllResults = SettingsManager.Current.IPScanner_ShowAllResults + }; + } + /// /// Stops the IP scan. /// diff --git a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs index 28f89296c7..4c83dca008 100644 --- a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs @@ -291,6 +291,24 @@ private set } } + /// + /// Gets the options of the scan whose results are shown. Columns and context menu follow them instead of + /// the current settings, which may have been changed since. Before the first scan: the settings when the + /// tab was opened. + /// + public PortScannerOptions ScanOptions + { + get; + private set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } + #endregion #region Constructor, load settings, shutdown @@ -316,6 +334,9 @@ public PortScannerViewModel(Guid tabId, string host, string port, bool start = t HostHistoryView = CollectionViewSource.GetDefaultView(SettingsManager.Current.PortScanner_HostHistory); PortsHistoryView = CollectionViewSource.GetDefaultView(SettingsManager.Current.PortScanner_PortHistory); + // Columns etc. until the first scan + ScanOptions = CreateScanOptions(); + // Result view ResultsView = CollectionViewSource.GetDefaultView(Results); ResultsView.GroupDescriptions.Add(new PropertyGroupDescription(nameof(PortScannerPortInfo.HostAsString))); @@ -557,15 +578,9 @@ private async Task Start() ListHelper.Modify(SettingsManager.Current.PortScanner_PortHistory, Ports, SettingsManager.Current.General_HistoryListEntries); - var portScanner = new PortScanner(new PortScannerOptions - { - MaxHostThreads = SettingsManager.Current.PortScanner_MaxHostThreads, - MaxPortThreads = SettingsManager.Current.PortScanner_MaxPortThreads, - Timeout = SettingsManager.Current.PortScanner_Timeout, - Attempts = SettingsManager.Current.PortScanner_Attempts, - ResolveHostname = SettingsManager.Current.PortScanner_ResolveHostname, - ShowAllResults = SettingsManager.Current.PortScanner_ShowAllResults - }); + ScanOptions = CreateScanOptions(); + + var portScanner = new PortScanner(ScanOptions); portScanner.PortScanned += PortScanned; portScanner.ScanComplete += ScanComplete; @@ -585,6 +600,23 @@ private async Task Start() portScanner.ScanAsync(hosts.hosts, ports, _cancellationTokenSource.Token); } + /// + /// Creates the scan options from the current settings. + /// + /// Options for the . + private static PortScannerOptions CreateScanOptions() + { + return new PortScannerOptions + { + MaxHostThreads = SettingsManager.Current.PortScanner_MaxHostThreads, + MaxPortThreads = SettingsManager.Current.PortScanner_MaxPortThreads, + Timeout = SettingsManager.Current.PortScanner_Timeout, + Attempts = SettingsManager.Current.PortScanner_Attempts, + ResolveHostname = SettingsManager.Current.PortScanner_ResolveHostname, + ShowAllResults = SettingsManager.Current.PortScanner_ShowAllResults + }; + } + private void Stop() { IsCanceling = true; diff --git a/Source/NETworkManager/Views/IPScannerView.xaml b/Source/NETworkManager/Views/IPScannerView.xaml index d7eeaffe48..3427047e33 100644 --- a/Source/NETworkManager/Views/IPScannerView.xaml +++ b/Source/NETworkManager/Views/IPScannerView.xaml @@ -12,7 +12,6 @@ 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:settings="clr-namespace:NETworkManager.Settings;assembly=NETworkManager.Settings" xmlns:controls="clr-namespace:NETworkManager.Controls;assembly=NETworkManager.Controls" xmlns:models="clr-namespace:NETworkManager.Models;assembly=NETworkManager.Models" Loaded="UserControl_Loaded" @@ -459,17 +458,17 @@ Style="{StaticResource ResourceKey=CopyMenuItem}"> - - + + - - + + @@ -494,10 +493,10 @@ Style="{StaticResource ResourceKey=CopyMenuItem}"> - - + + @@ -607,10 +606,10 @@ MinWidth="150"> - - + + @@ -621,12 +620,12 @@ - - + + @@ -648,10 +647,10 @@ Width="*"> - - + + @@ -789,7 +788,7 @@ + Visibility="{Binding Path=Data.(viewModels:IPScannerViewModel.ScanOptions).PortScanEnabled, Source={StaticResource BindingProxy}, Converter={StaticResource ResourceKey=BooleanReverseToVisibilityCollapsedConverter}}"> + Visibility="{Binding Path=Data.(viewModels:IPScannerViewModel.ScanOptions).PortScanEnabled, Source={StaticResource BindingProxy}, Converter={StaticResource ResourceKey=BooleanToVisibilityCollapsedConverter}}"> + Visibility="{Binding Path=Data.(viewModels:IPScannerViewModel.ScanOptions).NetBIOSEnabled, Source={StaticResource BindingProxy}, Converter={StaticResource ResourceKey=BooleanReverseToVisibilityCollapsedConverter}}"> + Visibility="{Binding Path=Data.(viewModels:IPScannerViewModel.ScanOptions).NetBIOSEnabled, Source={StaticResource BindingProxy}, Converter={StaticResource ResourceKey=BooleanToVisibilityCollapsedConverter}}"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From e0ec581a8fd08a14c6a52be3be039050b92c74d1 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Tue, 27 Oct 2026 14:48:02 +0200 Subject: [PATCH 059/146] Fix: IP Scanner Copy > Ports hidden with the port scan disabled (review 5, #6) Copy > Port status already follows the port scan option of the shown scan, Copy > Ports was always shown and copied an empty text without a port scan. Co-Authored-By: Claude Opus 5.5 --- Source/NETworkManager/Views/IPScannerView.xaml | 1 + Website/docs/changelog/next-release.md | 1 + 2 files changed, 2 insertions(+) diff --git a/Source/NETworkManager/Views/IPScannerView.xaml b/Source/NETworkManager/Views/IPScannerView.xaml index a52bb39a09..10f89d3679 100644 --- a/Source/NETworkManager/Views/IPScannerView.xaml +++ b/Source/NETworkManager/Views/IPScannerView.xaml @@ -539,6 +539,7 @@ **Ports** being shown in the context menu with the port scan disabled (it copied nothing). [#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) **Port Scanner** From 45a12d4e787d6d92d05aff5710be3f6c6eb98e64 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:24:43 +0200 Subject: [PATCH 060/146] Fix: IP Scanner down counter negative after a rescan of a canceled scan (review 6, #1) During a rescan the down counter was the scanned hosts of the last scan minus the reachable rows. A host is counted as up when it is found, but as scanned only when DNS / MAC are finished - after a canceled scan there can be more reachable rows than scanned hosts (e.g. "-7 Down"), and the value stayed after the rescan. Use the hosts counted as up or down in the last scan instead, never below 0. Co-Authored-By: Claude Opus 5.5 --- .../NETworkManager/ViewModels/IPScannerViewModel.cs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs index ed06a36d1d..8c270d23e6 100644 --- a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs @@ -68,6 +68,10 @@ public class IPScannerViewModel : ViewModelBase, IProfileManagerMinimal private int _rescanHostsToScan; private int _rescanHostsScanned; + // Hosts of the last scan counted as up or down (a host is up when it was found, down when it was finished), + // to recompute the down counter during a rescan + private int _rescanHostsCounted; + // Same reasoning as the results buffer above - ProgressChanged fires once per host // (unconditionally, unlike HostFound), so it's flushed to the bound property on the same // timer instead of updating it directly from the background thread on every event. @@ -821,6 +825,7 @@ private void RescanSelectedHosts() _rescanHostsToScan = HostsToScan; _rescanHostsScanned = HostsScanned; + _rescanHostsCounted = HostsUp + HostsDown; IsStatusMessageDisplayed = false; IsRunning = true; @@ -1356,11 +1361,13 @@ private void FlushProgress() HostsScanned = Volatile.Read(ref _latestHostsScanned); // Rescan: the counters of the scanner only cover the rescanned hosts - count the shown rows instead (every - // reachable host is shown), unreachable = all scanned hosts of the last scan minus the reachable ones + // reachable host is shown), unreachable = all counted hosts of the last scan minus the reachable ones. Not the + // scanned hosts: a found host is counted as up before it is finished, so after a canceled scan there can be + // more reachable rows than scanned hosts. if (_rescanRows != null) { HostsUp = Results.Count(x => x.IsReachable); - HostsDown = _rescanHostsScanned - HostsUp; + HostsDown = Math.Max(0, _rescanHostsCounted - HostsUp); return; } From d068ef5d93e5a1d6b76460853b04e9677a992077 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:25:39 +0200 Subject: [PATCH 061/146] Fix: IP Scanner / Port Scanner canceled rescan keeps the previous results (review 6, #2) IP Scanner: a rescanned host is copied into its rows as soon as it answers, with placeholders for the probes that are still running (no ports, empty hostname / MAC address, "..."). Canceled before they finished, the rows lost their complete results for good (also for Copy, Export, Web Console). The rows are copied before the rescan and restored if it is canceled or fails. Port Scanner: the rows of the rescanned hosts are removed when the rescan starts - canceled, only the ports scanned so far were left. They are restored as well, with the counters of the last scan. Co-Authored-By: Claude Opus 5.5 --- .../ViewModels/IPScannerViewModel.cs | 15 +++++++++++ .../ViewModels/PortScannerViewModel.cs | 25 +++++++++++++++++++ Website/docs/application/ip-scanner.md | 2 +- Website/docs/application/port-scanner.md | 2 +- 4 files changed, 42 insertions(+), 2 deletions(-) diff --git a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs index 8c270d23e6..0b66148f26 100644 --- a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs @@ -64,6 +64,10 @@ public class IPScannerViewModel : ViewModelBase, IProfileManagerMinimal private Dictionary> _rescanRows; private readonly Dictionary> _rescanResults = []; + // Values of the rescanned rows before the rescan (row -> copy), restored if the rescan is canceled or fails: a + // found host is copied into its rows with placeholders for the running probes, which would stay otherwise + private Dictionary _rescanSnapshots; + // Counters of the last scan, restored after a rescan (the rescan shows its own progress) private int _rescanHostsToScan; private int _rescanHostsScanned; @@ -823,6 +827,8 @@ private void RescanSelectedHosts() _rescanResults.Clear(); + _rescanSnapshots = _rescanRows.Values.SelectMany(x => x).ToDictionary(x => x, x => x.Clone()); + _rescanHostsToScan = HostsToScan; _rescanHostsScanned = HostsScanned; _rescanHostsCounted = HostsUp + HostsDown; @@ -1395,6 +1401,14 @@ private void ScanComplete(object sender, ScanCompleteArgs e) if (sender is IPScanner finishedScanner) StoreProgress(finishedScanner); + // Canceled / failed rescan: keep the previous results of the rows (before the counters are computed + // from them) + if (_rescanRows != null && (e.IsCanceled || e.Exception != null)) + { + foreach (var (row, snapshot) in _rescanSnapshots) + row.CopyFrom(snapshot); + } + FlushProgress(); // Rescan: back to the progress of the last scan @@ -1402,6 +1416,7 @@ private void ScanComplete(object sender, ScanCompleteArgs e) { _rescanRows = null; _rescanResults.Clear(); + _rescanSnapshots = null; HostsToScan = _rescanHostsToScan; HostsScanned = _rescanHostsScanned; diff --git a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs index 38b32cd7bb..34cc5776a1 100644 --- a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs @@ -65,6 +65,12 @@ public class PortScannerViewModel : ViewModelBase private int _rescanPortsToScan; private int _rescanPortsScanned; + // Rows and counters of the rescanned hosts before the rescan, restored if the rescan is canceled or fails + private HashSet _rescanIPAddresses; + private List _rescanRemovedRows; + private int _rescanPortsOpen; + private int _rescanPortsClosed; + /// /// Gets or sets the host to scan. /// @@ -736,6 +742,11 @@ private void RescanSelectedHosts() var rescannedIPAddresses = ipAddresses.ToHashSet(); + _rescanIPAddresses = rescannedIPAddresses; + _rescanRemovedRows = [.. Results.Where(x => rescannedIPAddresses.Contains(x.IPAddress))]; + _rescanPortsOpen = PortsOpen; + _rescanPortsClosed = PortsClosed; + // One reset instead of removing the rows one by one - every removal searches the collection and the sorted, // grouped view (e.g. a host with 1-65535 and closed ports shown would freeze the window for minutes) Results.ReplaceAll(Results.Where(x => !rescannedIPAddresses.Contains(x.IPAddress))); @@ -1041,6 +1052,20 @@ private void ScanComplete(object sender, ScanCompleteArgs e) { _isRescan = false; + // Canceled / failed rescan: keep the previous results of the hosts (their rows were removed when the + // rescan started, only some ports were scanned again) + if (e.IsCanceled || e.Exception != null) + { + Results.ReplaceAll(Results.Where(x => !_rescanIPAddresses.Contains(x.IPAddress)) + .Concat(_rescanRemovedRows)); + + PortsOpen = _rescanPortsOpen; + PortsClosed = _rescanPortsClosed; + } + + _rescanIPAddresses = null; + _rescanRemovedRows = null; + PortsToScan = _rescanPortsToScan; PortsScanned = _rescanPortsScanned; } diff --git a/Website/docs/application/ip-scanner.md b/Website/docs/application/ip-scanner.md index 86e2c98af9..07220feb68 100644 --- a/Website/docs/application/ip-scanner.md +++ b/Website/docs/application/ip-scanner.md @@ -65,7 +65,7 @@ Right-clicking a result allows forwarding device information to another tool (e. | Action | Description | | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | -| **Rescan** | Scans the selected host(s) again with the settings of the shown scan and updates their rows (a host that is unreachable now is shown as down). Not available while a scan is running | +| **Rescan** | Scans the selected host(s) again with the settings of the shown scan and updates their rows (a host that is unreachable now is shown as down). A canceled rescan keeps the previous results. Not available while a scan is running | | **Port Scanner** / **Ping Monitor** / **Traceroute** | Opens the host (DNS hostname if resolved, otherwise the IP address) in the tool | | **DNS lookup...** > **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) | diff --git a/Website/docs/application/port-scanner.md b/Website/docs/application/port-scanner.md index 0c0d787b41..218c6c3e3b 100644 --- a/Website/docs/application/port-scanner.md +++ b/Website/docs/application/port-scanner.md @@ -66,7 +66,7 @@ A scan of more than `65,536` ports (hosts × ports, e.g. 64 hosts with `1-1024`) | Action | Description | | --------------- | -------------------------------------------------------------------------------------------------------------- | -| **Rescan** | Scans the selected host(s) again with the ports and settings of the shown scan and replaces their results. Not available while a scan is running | +| **Rescan** | Scans the selected host(s) again with the ports and settings of the shown scan and replaces their results. A canceled rescan keeps the previous results. Not available while a scan is running | | **Web Console** | Opens the open web port (`80`, `443`, `8080`, `8443`) in the [Web Console](./web-console) (https for 443 / 8443) | | **Copy** | Copies the selected information to the clipboard | | **Export...** | Exports the selected or all results to a file | From 9f45080a3775c4b3fa678b8c27de177b2f390b44 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:26:35 +0200 Subject: [PATCH 062/146] Refactor: One entry per IP address from HostRangeHelper for the scanners (review 6, #3) The de-duplication by IP address of review 5 #2 ran DistinctBy on the UI thread after every resolution - also for a single range that is already distinct (fast path of SortAndRemoveDuplicates). For a confirmed /8 that is 16.7 M hashed IP addresses, a second list and a large HashSet before the scan starts. ResolveAsync has a keepAllHostnames parameter instead: the merge of the entries of one IP address keeps the first hostname only (IP Scanner, Port Scanner) or all of them (Ping Monitor). The fast path stays free, the code duplicated in both view models is gone. Co-Authored-By: Claude Opus 5.5 --- .../Network/HostRangeHelper.cs | 18 ++++++++++++------ .../ViewModels/IPScannerViewModel.cs | 8 ++------ .../ViewModels/PingMonitorHostViewModel.cs | 3 ++- .../ViewModels/PortScannerViewModel.cs | 8 ++------ 4 files changed, 18 insertions(+), 19 deletions(-) diff --git a/Source/NETworkManager.Models/Network/HostRangeHelper.cs b/Source/NETworkManager.Models/Network/HostRangeHelper.cs index fe1889fc9a..d32413b5bc 100644 --- a/Source/NETworkManager.Models/Network/HostRangeHelper.cs +++ b/Source/NETworkManager.Models/Network/HostRangeHelper.cs @@ -198,10 +198,16 @@ private static long GetIPv4RangeCount(IPAddress start, IPAddress end) /// /// Inputs like "10.0.0.1", "example.com", "10.0.0.0/24" (see ). /// Prefer the A record over the AAAA record for hostnames. + /// + /// Keep one entry per hostname for an IP address entered with different hostnames (e.g. the Ping Monitor shows + /// the hostname of the input). Otherwise every IP address is returned once, with the first hostname (e.g. the + /// IP Scanner and the Port Scanner resolve the hostname themselves, a second entry would be scanned again). + /// /// Token to monitor for cancellation requests. /// Sorted, distinct list of hosts and the hostnames that could not be resolved. public static async Task<(List<(IPAddress ipAddress, string hostname)> hosts, List hostnamesNotResolved)> - ResolveAsync(IEnumerable hosts, bool dnsResolveHostnamePreferIPv4, CancellationToken cancellationToken) + ResolveAsync(IEnumerable hosts, bool dnsResolveHostnamePreferIPv4, bool keepAllHostnames, + CancellationToken cancellationToken) { var inputs = hosts.ToList(); @@ -300,20 +306,20 @@ await Parallel.ForEachAsync(Enumerable.Range(0, inputs.Count), } }).ConfigureAwait(false); - return (SortAndRemoveDuplicates(hostsPerInput), + return (SortAndRemoveDuplicates(hostsPerInput, keepAllHostnames), [.. hostnameNotResolvedPerInput.Where(x => x != null)]); } /// /// Merges the hosts of all inputs into one list sorted by IP address, with every IP address only once /// (e.g. overlapping ranges). If the same IP address was entered with different hostnames (e.g. two names of - /// one server), every hostname is kept once; an entry without hostname is only kept if there is no entry - /// with a hostname. Entries with the same IP address keep the input order (stable sort), so the result is + /// one server), every hostname is kept once with , otherwise only the + /// first one; an entry without hostname is only kept if there is no entry with a hostname. Entries with the same IP address keep the input order (stable sort), so the result is /// the same on every run. IPv6 addresses with a different scope id (e.g. "fe80::1%3" and "fe80::1%7", /// two devices on different interfaces) are different hosts. /// private static List<(IPAddress ipAddress, string hostname)> SortAndRemoveDuplicates( - List<(IPAddress ipAddress, string hostname)>[] hostsPerInput) + List<(IPAddress ipAddress, string hostname)>[] hostsPerInput, bool keepAllHostnames) { var hosts = hostsPerInput.Length == 1 ? hostsPerInput[0] : [.. hostsPerInput.SelectMany(x => x)]; @@ -353,7 +359,7 @@ await Parallel.ForEachAsync(Enumerable.Range(0, inputs.Count), .ToList(); if (entriesWithHostname.Count > 0) - distinctHosts.AddRange(entriesWithHostname); + distinctHosts.AddRange(keepAllHostnames ? entriesWithHostname : [entriesWithHostname[0]]); else distinctHosts.Add(sortedHosts[start]); diff --git a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs index 0b66148f26..9066f615ea 100644 --- a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs @@ -761,12 +761,8 @@ private async Task Start() try { hosts = await HostRangeHelper.ResolveAsync(HostRangeHelper.CreateListFromInput(Host), - SettingsManager.Current.Network_ResolveHostnamePreferIPv4, _cancellationTokenSource.Token); - - // The scanner doesn't use the hostname of the input (it resolves the hostname via PTR) - an IP address - // entered with different hostnames (e.g. two names of one server) would be scanned once per hostname, - // with the same result - hosts.hosts = [.. hosts.hosts.DistinctBy(x => x.ipAddress)]; + SettingsManager.Current.Network_ResolveHostnamePreferIPv4, keepAllHostnames: false, + _cancellationTokenSource.Token); ports = await PortRangeHelper.ConvertPortRangeToIntArrayAsync( SettingsManager.Current.IPScanner_PortScanPorts); diff --git a/Source/NETworkManager/ViewModels/PingMonitorHostViewModel.cs b/Source/NETworkManager/ViewModels/PingMonitorHostViewModel.cs index 7e3b50c6d3..427f0ce087 100644 --- a/Source/NETworkManager/ViewModels/PingMonitorHostViewModel.cs +++ b/Source/NETworkManager/ViewModels/PingMonitorHostViewModel.cs @@ -324,7 +324,8 @@ public async Task Start() try { hosts = await HostRangeHelper.ResolveAsync(HostRangeHelper.CreateListFromInput(Host), - SettingsManager.Current.Network_ResolveHostnamePreferIPv4, _cancellationTokenSource.Token); + SettingsManager.Current.Network_ResolveHostnamePreferIPv4, keepAllHostnames: true, + _cancellationTokenSource.Token); } catch (OperationCanceledException) { diff --git a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs index 34cc5776a1..4b8b3b9e8d 100644 --- a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs @@ -681,12 +681,8 @@ private async Task Start() try { hosts = await HostRangeHelper.ResolveAsync(HostRangeHelper.CreateListFromInput(Host), - SettingsManager.Current.Network_ResolveHostnamePreferIPv4, _cancellationTokenSource.Token); - - // The scanner doesn't use the hostname of the input (it resolves the hostname via PTR) - an IP address - // entered with different hostnames (e.g. two names of one server) would be scanned once per hostname, - // with the same result - hosts.hosts = [.. hosts.hosts.DistinctBy(x => x.ipAddress)]; + SettingsManager.Current.Network_ResolveHostnamePreferIPv4, keepAllHostnames: false, + _cancellationTokenSource.Token); ports = await PortRangeHelper.ConvertPortRangeToIntArrayAsync(Ports); } From dca395b455c36ac8965811e43c06f0e8f11d19ea Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:27:07 +0200 Subject: [PATCH 063/146] Fix: Port Scanner rescan keeps collapsed groups and the scroll position (review 6, #4) The single reset of review 5 #3 recreates every host group: their expanders are expanded by default, so groups the user collapsed opened again and the grid lost its scroll position. ReplaceResults removes / adds up to 1000 rows one by one (other groups unchanged) and only uses one reset for more rows, where single changes would freeze the window. Also used to restore the rows of a canceled rescan. Co-Authored-By: Claude Opus 5.5 --- .../ViewModels/PortScannerViewModel.cs | 39 ++++++++++++++++--- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs index 4b8b3b9e8d..87670e77c7 100644 --- a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs @@ -33,6 +33,12 @@ public class PortScannerViewModel : ViewModelBase #region Variables private static readonly ILog Log = LogManager.GetLogger(typeof(PortScannerViewModel)); + /// + /// Maximum number of rows that are removed / added one by one when the results of hosts are replaced (rescan), + /// see . + /// + private const int MaxRowsChangedOneByOne = 1000; + private CancellationTokenSource _cancellationTokenSource; private readonly Guid _tabId; @@ -743,9 +749,7 @@ private void RescanSelectedHosts() _rescanPortsOpen = PortsOpen; _rescanPortsClosed = PortsClosed; - // One reset instead of removing the rows one by one - every removal searches the collection and the sorted, - // grouped view (e.g. a host with 1-65535 and closed ports shown would freeze the window for minutes) - Results.ReplaceAll(Results.Where(x => !rescannedIPAddresses.Contains(x.IPAddress))); + ReplaceResults(rescannedIPAddresses, []); // Every host was scanned with all ports: the other hosts keep their counters var rescanPortCount = ipAddresses.Count * _lastPorts.Length; @@ -778,6 +782,32 @@ private void RescanSelectedHosts() StartScanner([.. ipAddresses.Select(x => (x, string.Empty))], _lastPorts); } + /// + /// Replaces the rows of the given hosts. Few rows are removed / added one by one, so the groups of the other hosts + /// keep their state (collapsed) and the scroll position stays. Many rows with one reset instead: every single + /// change searches the collection and the sorted, grouped view (e.g. a host with 1-65535 and closed ports shown + /// would freeze the window for minutes). + /// + /// Hosts whose rows are replaced. + /// New rows of the hosts. + private void ReplaceResults(HashSet ipAddresses, IReadOnlyCollection rows) + { + var oldRows = Results.Where(x => ipAddresses.Contains(x.IPAddress)).ToList(); + + if (oldRows.Count + rows.Count > MaxRowsChangedOneByOne) + { + Results.ReplaceAll(Results.Where(x => !ipAddresses.Contains(x.IPAddress)).Concat(rows)); + + return; + } + + foreach (var row in oldRows) + Results.Remove(row); + + foreach (var row in rows) + Results.Add(row); + } + /// /// Creates the scanner (with ), subscribes to its events, starts the flush timer and /// the scan. @@ -1052,8 +1082,7 @@ private void ScanComplete(object sender, ScanCompleteArgs e) // rescan started, only some ports were scanned again) if (e.IsCanceled || e.Exception != null) { - Results.ReplaceAll(Results.Where(x => !_rescanIPAddresses.Contains(x.IPAddress)) - .Concat(_rescanRemovedRows)); + ReplaceResults(_rescanIPAddresses, _rescanRemovedRows); PortsOpen = _rescanPortsOpen; PortsClosed = _rescanPortsClosed; From 18119d0ae3e2044b177cdb877ac61cec068803a3 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:27:58 +0200 Subject: [PATCH 064/146] Fix: IP Scanner MAC address of IPv6 hosts with a scope id (review 6, #5) The neighbor table map was keyed without the IPv6 scope id, while the input keeps it: "fe80::1%3; fe80::1%7" (e.g. the routers of two interfaces) are two hosts, but both got the MAC address of the last "fe80::1" entry - also used for Wake on LAN and Add profile. The map now contains every IPv6 entry with its scope id (exact lookup for an address entered with one) and without it (an address entered without scope id, as before). Co-Authored-By: Claude Opus 5.5 --- Source/NETworkManager.Models/Network/IPScanner.cs | 8 ++++---- .../NETworkManager.Models/Network/NeighborTable.cs | 12 ++++++++---- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/Source/NETworkManager.Models/Network/IPScanner.cs b/Source/NETworkManager.Models/Network/IPScanner.cs index 9655febdaf..4d11d86dad 100644 --- a/Source/NETworkManager.Models/Network/IPScanner.cs +++ b/Source/NETworkManager.Models/Network/IPScanner.cs @@ -498,14 +498,14 @@ await Task.WhenAll( private async Task GetMACAddressFromNeighborTableAsync(IPAddress ipAddress, DateTime discoveredAt, CancellationToken cancellationToken) { - var key = IPAddressHelper.WithoutScopeId(ipAddress); - // Hosts wait without blocking a thread while another host reads the table await _neighborTableSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); try { - if (_neighborTable.TryGetValue(key, out var macAddress)) + // 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; // Already read after the host answered -> no entry @@ -516,7 +516,7 @@ private async Task GetMACAddressFromNeighborTableAsync(IPAddress ipAddre _neighborTableTimestamp = DateTime.UtcNow; _neighborTable = NeighborTable.GetMACAddressMap(); - return _neighborTable.GetValueOrDefault(key, string.Empty); + return _neighborTable.GetValueOrDefault(ipAddress, string.Empty); } finally { diff --git a/Source/NETworkManager.Models/Network/NeighborTable.cs b/Source/NETworkManager.Models/Network/NeighborTable.cs index 32ccd9d0e0..d1ad3ed551 100644 --- a/Source/NETworkManager.Models/Network/NeighborTable.cs +++ b/Source/NETworkManager.Models/Network/NeighborTable.cs @@ -313,8 +313,9 @@ 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 without scope id (see - /// ), entries without a MAC address are skipped. + /// (e.g. "001122AABBCC"). 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 are skipped. /// Reading the table once and looking up many addresses is much cheaper than reading it per address. /// public static Dictionary GetMACAddressMap() @@ -325,8 +326,11 @@ public static Dictionary GetMACAddressMap() { var macAddress = entry.MACAddress.ToString(); - if (!string.IsNullOrEmpty(macAddress)) - map[IPAddressHelper.WithoutScopeId(entry.IPAddress)] = macAddress; + if (string.IsNullOrEmpty(macAddress)) + continue; + + map[entry.IPAddress] = macAddress; + map[IPAddressHelper.WithoutScopeId(entry.IPAddress)] = macAddress; } return map; From f0760ab093502f14fbe61c0dbf8f23e6786f2419 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:28:19 +0200 Subject: [PATCH 065/146] Fix: IP Scanner sort by MAC address / Time in a fixed order for equal values (review 6, #6) Hosts with the same MAC address or ping time - especially all hosts without a MAC address or without a reply, and the many 0 ms replies on a LAN - compared as equal. The sort of the collection view is not stable, so these hosts were shown in a random order that changed with every sort. They are sorted by IP address now. Co-Authored-By: Claude Opus 5.5 --- Source/NETworkManager/Views/IPScannerView.xaml.cs | 8 +++++++- Website/docs/application/ip-scanner.md | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/Source/NETworkManager/Views/IPScannerView.xaml.cs b/Source/NETworkManager/Views/IPScannerView.xaml.cs index 6741994b1f..50d423b484 100644 --- a/Source/NETworkManager/Views/IPScannerView.xaml.cs +++ b/Source/NETworkManager/Views/IPScannerView.xaml.cs @@ -162,7 +162,7 @@ public int Compare(object x, object y) return 0; // Compare the data - return comparer switch + var result = comparer switch { // IP address 0 => direction == ListSortDirection.Ascending @@ -176,6 +176,12 @@ public int Compare(object x, object y) : CompareDescendingNoReplyLast(first, second), _ => 0 }; + + // Same MAC address / time (e.g. all hosts without a MAC address or ping reply): by IP address - the sort is + // not stable, the order of these hosts would be random otherwise + return result != 0 || comparer == 0 + ? result + : IPAddressHelper.CompareIPAddresses(first.PingInfo.IPAddress, second.PingInfo.IPAddress); } /// diff --git a/Website/docs/application/ip-scanner.md b/Website/docs/application/ip-scanner.md index 07220feb68..22d97ea451 100644 --- a/Website/docs/application/ip-scanner.md +++ b/Website/docs/application/ip-scanner.md @@ -93,7 +93,7 @@ The search box right of the **Result** header filters the results by IP address, 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. -The **Time** column shows the round trip time of the ping reply (`-/-` without a reply); sorted by time, hosts without a reply are always at the end. Sorted by **MAC address**, hosts without a MAC address are always at the end. +The **Time** column shows the round trip time of the ping reply (`-/-` without a reply); sorted by time, hosts without a reply are always at the end. Sorted by **MAC address**, hosts without a MAC address are always at the end. Hosts with the same time or MAC address (or none) are sorted by IP address. The columns (e.g. **Hostname**, **Port status**, **MAC address**) and the row details are shown according to the settings the scan was started with. Settings changed afterwards apply to the next scan. From d0d52915ef50a3b9087099b42393e773851cc61e Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:28:44 +0200 Subject: [PATCH 066/146] Fix: IP Scanner requests NetBIOS from IPv4 addresses only (review 6, #7) NetBIOS (UDP 137) doesn't exist over IPv6, but the request was sent to IPv6 hosts as well: every IPv6 host kept "..." in NetBIOS reachable and wasn't finished until the NetBIOS timeout (default 2 s), then showed "No". Skipped for IPv6 now (result: not reachable, right away). Co-Authored-By: Claude Opus 5.5 --- Source/NETworkManager.Models/Network/IPScanner.cs | 3 ++- Website/docs/application/ip-scanner.md | 2 +- Website/docs/changelog/next-release.md | 1 + 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Source/NETworkManager.Models/Network/IPScanner.cs b/Source/NETworkManager.Models/Network/IPScanner.cs index 4d11d86dad..2ea0cc6691 100644 --- a/Source/NETworkManager.Models/Network/IPScanner.cs +++ b/Source/NETworkManager.Models/Network/IPScanner.cs @@ -215,7 +215,8 @@ private async Task ScanHostAsync(IPAddress ipAddress, ParallelOptions portScanPa ? PortScanAsync(ipAddress, portScanParallelOptions, () => portAnswered.TrySetResult(), cancellationToken) : Task.FromResult(new List()); - var netBIOSTask = options.NetBIOSEnabled + // NetBIOS only exists over IPv4 - an IPv6 host would never answer and wait for the timeout + var netBIOSTask = options.NetBIOSEnabled && ipAddress.AddressFamily == AddressFamily.InterNetwork ? NetBIOSResolver.ResolveAsync(ipAddress, options.NetBIOSTimeout, cancellationToken) : Task.FromResult(new NetBIOSInfo(ipAddress)); diff --git a/Website/docs/application/ip-scanner.md b/Website/docs/application/ip-scanner.md index 22d97ea451..e6f3b00c21 100644 --- a/Website/docs/application/ip-scanner.md +++ b/Website/docs/application/ip-scanner.md @@ -242,7 +242,7 @@ In a local network (LAN), `1` is enough. On an unreliable network (e.g. Wi-Fi, V ### Scan for NetBIOS -Scan each IP address for NetBIOS information. +Scan each IP address for NetBIOS information. Only IPv4 addresses are scanned, NetBIOS doesn't exist over IPv6. **Type:** `Boolean` diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index 119ceb3eee..4027206e08 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -267,6 +267,7 @@ Release date: **xx.xx.2026** - Fixed the application crashing when the results were sorted by **MAC address** and a host had no MAC address (e.g. a host behind a router, or while the MAC address was still resolved). Hosts without a MAC address are shown at the end. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) - Fixed the sort arrow of the previously sorted column still being shown after sorting by **IP address**, **MAC address** or **Time**. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) - 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) **Port Scanner** From 0e422fa43436ee14b6d4a23f2ec94b471cbc8eed Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:29:42 +0200 Subject: [PATCH 067/146] Fix: IP Scanner export of running probes as empty values (review 6, #8) Export during a scan (or of a scan canceled before every probe finished) wrote the placeholders of probes that were still running as results: a running port scan as PortStatus "Closed", a running ping as PingStatus "Unknown" with timestamp / TTL / bytes, a running NetBIOS request as not reachable. These values are empty now (CSV empty, XML empty element, JSON null); finished results are exported as before. Co-Authored-By: Claude Opus 5.5 --- .../Export/ExportManager.IPScannerHostInfo.cs | 105 +++++++++++------- Website/docs/application/ip-scanner.md | 2 +- 2 files changed, 67 insertions(+), 40 deletions(-) diff --git a/Source/NETworkManager.Models/Export/ExportManager.IPScannerHostInfo.cs b/Source/NETworkManager.Models/Export/ExportManager.IPScannerHostInfo.cs index 5ebeeffbb4..509d6fdc08 100644 --- a/Source/NETworkManager.Models/Export/ExportManager.IPScannerHostInfo.cs +++ b/Source/NETworkManager.Models/Export/ExportManager.IPScannerHostInfo.cs @@ -77,6 +77,11 @@ private static void CreateCsv(IEnumerable collection, string if (info?.PingInfo == null) 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 + var ping = info.IsPingPending ? null : info.PingInfo; + var netBIOSInfo = info.IsNetBIOSPending ? null : info.NetBIOSInfo; + var stringBuilderPorts = new StringBuilder(); if (info.Ports != null) @@ -90,20 +95,20 @@ private static void CreateCsv(IEnumerable collection, string $"{info.IsReachable}," + $"{info.PingInfo.IPAddress}," + $"{CsvHelper.QuoteString(info.Hostname)}," + - $"{info.PingInfo.Status}," + - $"{DateTimeHelper.DateTimeToFullDateTimeString(info.PingInfo.Timestamp)}," + - $"{Ping.TimeToString(info.PingInfo.Status, info.PingInfo.Time, true)}," + - $"{info.PingInfo.TTL}," + - $"{info.PingInfo.Bytes}," + - $"{(info.IsAnyPortOpen ? PortState.Open : PortState.Closed)}," + + $"{ping?.Status}," + + $"{(ping == null ? null : DateTimeHelper.DateTimeToFullDateTimeString(ping.Timestamp))}," + + $"{(ping == null ? null : Ping.TimeToString(ping.Status, ping.Time, true))}," + + $"{ping?.TTL}," + + $"{ping?.Bytes}," + + $"{GetPortStatus(info)}," + $"{CsvHelper.QuoteString(stringBuilderPorts.ToString().TrimEnd(';'))}," + - $"{info.NetBIOSInfo?.IsReachable}," + - $"{info.NetBIOSInfo?.IPAddress}," + - $"{CsvHelper.QuoteString(info.NetBIOSInfo?.ComputerName)}," + - $"{CsvHelper.QuoteString(info.NetBIOSInfo?.UserName)}," + - $"{CsvHelper.QuoteString(info.NetBIOSInfo?.GroupName)}," + - $"{info.NetBIOSInfo?.MACAddress}," + - $"{CsvHelper.QuoteString(info.NetBIOSInfo?.Vendor)}," + + $"{netBIOSInfo?.IsReachable}," + + $"{netBIOSInfo?.IPAddress}," + + $"{CsvHelper.QuoteString(netBIOSInfo?.ComputerName)}," + + $"{CsvHelper.QuoteString(netBIOSInfo?.UserName)}," + + $"{CsvHelper.QuoteString(netBIOSInfo?.GroupName)}," + + $"{netBIOSInfo?.MACAddress}," + + $"{CsvHelper.QuoteString(netBIOSInfo?.Vendor)}," + $"{info.MACAddress}," + $"{CsvHelper.QuoteString(info.Vendor)}," + $"{CsvHelper.QuoteString(info.DNSHostname)}" @@ -125,19 +130,22 @@ private static void CreateXml(IEnumerable collection, string new XElement(nameof(IPScannerHostInfo) + "s", from info in collection where info?.PingInfo != null + // Placeholders of running probes are exported as empty values (see CreateCsv) + let ping = info.IsPingPending ? null : info.PingInfo + let netBIOSInfo = info.IsNetBIOSPending ? null : info.NetBIOSInfo select new XElement(nameof(IPScannerHostInfo), new XElement(nameof(IPScannerHostInfo.IsReachable), info.IsReachable), new XElement(nameof(PingInfo.IPAddress), info.PingInfo.IPAddress), new XElement(nameof(IPScannerHostInfo.Hostname), info.Hostname), - new XElement("PingStatus", info.PingInfo.Status), + new XElement("PingStatus", ping?.Status), new XElement(nameof(PingInfo.Timestamp), - DateTimeHelper.DateTimeToFullDateTimeString(info.PingInfo.Timestamp)), + ping == null ? null : DateTimeHelper.DateTimeToFullDateTimeString(ping.Timestamp)), new XElement(nameof(PingInfo.Time), - Ping.TimeToString(info.PingInfo.Status, info.PingInfo.Time, true)), - new XElement(nameof(PingInfo.TTL), info.PingInfo.TTL), - new XElement(nameof(PingInfo.Bytes), info.PingInfo.Bytes), - new XElement("PortStatus", info.IsAnyPortOpen ? PortState.Open : PortState.Closed), + 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 ? from port in info.Ports select new XElement(nameof(PortInfo), @@ -147,13 +155,13 @@ from info in collection new XElement(nameof(PortInfo.LookupInfo.Description), port.LookupInfo.Description), new XElement(nameof(PortInfo.State), port.State)) : Enumerable.Empty(), - new XElement("NetBIOSIsReachable", info.NetBIOSInfo?.IsReachable), - new XElement("NetBIOSIPAddress", info.NetBIOSInfo?.IPAddress), - new XElement("NetBIOSComputerName", info.NetBIOSInfo?.ComputerName), - new XElement("NetBIOSUserName", info.NetBIOSInfo?.UserName), - new XElement("NetBIOSGroupName", info.NetBIOSInfo?.GroupName), - new XElement("NetBIOSMACAddress", info.NetBIOSInfo?.MACAddress), - new XElement("NetBIOSVendor", info.NetBIOSInfo?.Vendor), + new XElement("NetBIOSIsReachable", netBIOSInfo?.IsReachable), + new XElement("NetBIOSIPAddress", netBIOSInfo?.IPAddress), + new XElement("NetBIOSComputerName", netBIOSInfo?.ComputerName), + new XElement("NetBIOSUserName", netBIOSInfo?.UserName), + new XElement("NetBIOSGroupName", netBIOSInfo?.GroupName), + new XElement("NetBIOSMACAddress", netBIOSInfo?.MACAddress), + new XElement("NetBIOSVendor", netBIOSInfo?.Vendor), 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 @@ -179,6 +187,11 @@ private static void CreateJson(IReadOnlyList collection, stri for (var i = 0; i < validCollection.Count; i++) { var info = validCollection[i]; + + // Placeholders of running probes are exported as empty values (see CreateCsv) + var ping = info.IsPingPending ? null : info.PingInfo; + var netBIOSInfo = info.IsNetBIOSPending ? null : info.NetBIOSInfo; + object[] jsonDataPorts; if (info.Ports != null && info.Ports.Count > 0) @@ -204,21 +217,21 @@ private static void CreateJson(IReadOnlyList collection, stri info.IsReachable, IPAddress = info.PingInfo.IPAddress.ToString(), info.Hostname, - PingStatus = info.PingInfo.Status.ToString(), - Timestamp = DateTimeHelper.DateTimeToFullDateTimeString(info.PingInfo.Timestamp), - Time = Ping.TimeToString(info.PingInfo.Status, info.PingInfo.Time, true), - info.PingInfo.TTL, - info.PingInfo.Bytes, + PingStatus = ping?.Status.ToString(), + Timestamp = ping == null ? null : DateTimeHelper.DateTimeToFullDateTimeString(ping.Timestamp), + Time = ping == null ? null : Ping.TimeToString(ping.Status, ping.Time, true), + TTL = ping?.TTL, + Bytes = ping?.Bytes, info.DNSHostname, - PortStatus = info.IsAnyPortOpen ? PortState.Open.ToString() : PortState.Closed.ToString(), + PortStatus = GetPortStatus(info)?.ToString(), Ports = jsonDataPorts, - NetBIOSIsReachable = info.NetBIOSInfo?.IsReachable, - NetBIOSIPAddress = info.NetBIOSInfo?.IPAddress?.ToString(), - NetBIOSComputerName = info.NetBIOSInfo?.ComputerName, - NetBIOSUserName = info.NetBIOSInfo?.UserName, - NetBIOSGroupName = info.NetBIOSInfo?.GroupName, - NetBIOSMACAddress = info.NetBIOSInfo?.MACAddress, - NetBIOSVendor = info.NetBIOSInfo?.Vendor, + NetBIOSIsReachable = netBIOSInfo?.IsReachable, + NetBIOSIPAddress = netBIOSInfo?.IPAddress?.ToString(), + NetBIOSComputerName = netBIOSInfo?.ComputerName, + NetBIOSUserName = netBIOSInfo?.UserName, + NetBIOSGroupName = netBIOSInfo?.GroupName, + NetBIOSMACAddress = netBIOSInfo?.MACAddress, + NetBIOSVendor = netBIOSInfo?.Vendor, info.MACAddress, info.Vendor }; @@ -226,4 +239,18 @@ private static void CreateJson(IReadOnlyList collection, stri File.WriteAllText(filePath, JsonConvert.SerializeObject(jsonData, Formatting.Indented)); } + + /// + /// 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). + /// + /// Host to get the port status for. + /// Port status or null. + private static PortState? GetPortStatus(IPScannerHostInfo info) + { + if (info.IsPortScanPending) + return null; + + return info.IsAnyPortOpen ? PortState.Open : PortState.Closed; + } } \ No newline at end of file diff --git a/Website/docs/application/ip-scanner.md b/Website/docs/application/ip-scanner.md index e6f3b00c21..eb647576b6 100644 --- a/Website/docs/application/ip-scanner.md +++ b/Website/docs/application/ip-scanner.md @@ -89,7 +89,7 @@ A host is shown (and counted as up) as soon as it answers the first probe: ping 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. +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. 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. From e1578e80cd3378e2e3ab155fe70ebdfc8e4db54a Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:31:28 +0200 Subject: [PATCH 068/146] Chore: IP Scanner help icon for NetBIOS IPv4 only (review 6, #7) Scan for NetBIOS has a help icon with the tooltip that only IPv4 addresses are scanned for NetBIOS (NetBIOS doesn't exist over IPv6), like the other settings with a help icon (e.g. Firewall > Show all rules). Docs as note. Co-Authored-By: Claude Opus 5.5 --- .../Resources/Strings.Designer.cs | 10 ++++++++++ .../Resources/Strings.resx | 3 +++ .../NETworkManager/Views/IPScannerSettingsView.xaml | 12 ++++++++++-- Website/docs/application/ip-scanner.md | 8 +++++++- 4 files changed, 30 insertions(+), 3 deletions(-) diff --git a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs index dbc99ed066..0ddad9e3f2 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs +++ b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs @@ -1,6 +1,7 @@ //------------------------------------------------------------------------------ // // This code was generated by a tool. +// Runtime Version:4.0.30319.42000 // // Changes to this file may cause incorrect behavior and will be lost if // the code is regenerated. @@ -5959,6 +5960,15 @@ public static string HelpMessage_SaveCredentials { } } + /// + /// Looks up a localized string similar to Only IPv4 addresses are scanned for NetBIOS, NetBIOS doesn't exist over IPv6.. + /// + public static string HelpMessage_ScanForNetBIOS { + get { + return ResourceManager.GetString("HelpMessage_ScanForNetBIOS", resourceCulture); + } + } + /// /// Looks up a localized string similar to Displays the status bar in the bottom-left of the WebView when hovering over a link.. /// diff --git a/Source/NETworkManager.Localization/Resources/Strings.resx b/Source/NETworkManager.Localization/Resources/Strings.resx index 7e05a80096..99f68c88eb 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.resx +++ b/Source/NETworkManager.Localization/Resources/Strings.resx @@ -5089,4 +5089,7 @@ Do you want to start the scan? {0} Up + + Only IPv4 addresses are scanned for NetBIOS, NetBIOS doesn't exist over IPv6. + \ No newline at end of file diff --git a/Source/NETworkManager/Views/IPScannerSettingsView.xaml b/Source/NETworkManager/Views/IPScannerSettingsView.xaml index d491e51a34..6435fc8e01 100644 --- a/Source/NETworkManager/Views/IPScannerSettingsView.xaml +++ b/Source/NETworkManager/Views/IPScannerSettingsView.xaml @@ -55,8 +55,16 @@ - + + + + + - + + + + + + + + + + + + + + + + + - + diff --git a/Website/docs/application/port-scanner.md b/Website/docs/application/port-scanner.md index 2cc65e9900..5dbb5bd78d 100644 --- a/Website/docs/application/port-scanner.md +++ b/Website/docs/application/port-scanner.md @@ -43,6 +43,7 @@ TCP (Transmission Control Protocol) is a connection-oriented transport-layer pro | --------------------- | -------------------------------------------------- | | `1-1024` | All ports in a given range (`1`, `2`, ..., `1024`) | | `80; 443; 8080; 8443` | Multiple ports like (`80`, `443`, `8080`, `8443`) | +| `Webserver; 8080` | Ports of a [port profile](#port-profiles) (`80`, `443`) and other ports (`8080`) | :::note @@ -152,7 +153,7 @@ See also the [Port Scanner](./port-scanner) example inputs for more information ### Port profiles -List of common TCP ports to scan for. +List of common TCP ports to scan for. A port profile can be entered by its name in the ports input (case-insensitive, also several and together with ports, e.g. `Webserver; Database; 8080`), so the name must be unique, must not contain `;` and must not be a port or port range (e.g. `80`). **Type:** `List` diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index 1a015ed5c9..1e6bfe8d78 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -30,7 +30,7 @@ Release date: **xx.xx.2026** - The [**Run command**](../run-command.md) (`Ctrl+Shift+P`) now runs a feature directly, e.g. `ping 10.0.0.1`, `dns borntoberoot.net 1.1.1.1 aaaa`, `rdap 1.10 asn` or `rdp @server-01` (profile). It opens in a dialog instead of a flyout, with short aliases (e.g. `ping`, `dns`, `rdp`, `ssh`, `geo`, `wol`), suggestions for recent commands, profiles of the feature and types (`Up`/`Down` to select, `Tab` to complete, `Enter` to run, `Esc` to close), and validates the input like the feature does. Only visible features are offered. [#3644](https://github.com/BornToBeRoot/NETworkManager/pull/3644) - New run command targets for the **Subnet Calculator** (`subnet 10.0.0.0/24`, `subnet 10.0.0.0/16 /24` for subnetting, `subnet 10.0.0.0/24 10.0.1.0/24` for the wide subnet) and the **Lookup** (`lookup oui 00:11:22`, `lookup port 22; ssh`). [#3646](https://github.com/BornToBeRoot/NETworkManager/pull/3646) - New global hotkey to bring the window to the foreground and open the run command (default `Ctrl+Alt+P`, disabled by default), see [Settings > HotKeys](../settings/hotkeys.md). [#3646](https://github.com/BornToBeRoot/NETworkManager/pull/3646) -- Run command: the **Port Scanner** needs ports, which can also be the name of a port profile (e.g. `portscan 10.0.0.1 webserver`, suggested after the host). Own history size for the run command (default 20, [Settings > General](../settings/general.md#number-of-stored-entries-run-command)). `Tab` also completes profiles and port profiles, `PageUp`/`PageDown` move the selection by 5 suggestions. [#3646](https://github.com/BornToBeRoot/NETworkManager/pull/3646) +- Run command: the **Port Scanner** needs ports, which can also be names of port profiles, also several and together with ports (e.g. `portscan 10.0.0.1 webserver` or `portscan 10.0.0.1 webserver;database;22`, suggested after the host and after a `;`). Own history size for the run command (default 20, [Settings > General](../settings/general.md#number-of-stored-entries-run-command)). `Tab` also completes profiles and port profiles, `PageUp`/`PageDown` move the selection by 5 suggestions. [#3646](https://github.com/BornToBeRoot/NETworkManager/pull/3646) - Run command dialog redesigned: the icon of the recognized feature, the syntax with the part being typed highlighted, the values as they were understood (e.g. `Hosts: borntoberoot.net`, `Type: AAAA`), validation while typing, grouped suggestions with the typed text highlighted, examples and key hints (can be hidden in the settings). The dialog uses more space in a large window. The title bar shows a **Run command...** box in the center (like the command center of VS Code) instead of the icon. Type only a target (e.g. `10.0.0.1`, `server-01`, `00:11:22:33:44:55`, `10.0.0.0/24`) to get the commands that accept it, recent commands are also suggested while typing (e.g. a host); optionally, a target in the clipboard is suggested ([Settings > General](../settings/general.md#suggest-the-clipboard-content-as-target)). `Ctrl+Enter` runs and keeps the dialog open, `Shift+Enter` fills in the input without starting, `Shift+Del` or the remove button removes a recent command. [#3646](https://github.com/BornToBeRoot/NETworkManager/pull/3646) **RDAP** @@ -100,6 +100,7 @@ Release date: **xx.xx.2026** - 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) - The header of each host shows the number of open ports (e.g. `server (10.0.0.1) · 3 Open`), also while the scan is running. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) +- Port profiles can be entered by name in the **Ports** input, also several and together with ports (e.g. `Webserver; Database; 8080`, case-insensitive). The name of a port profile must be unique, must not contain `;` (replaced by `,` when updating) and must not be a port or port range. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) - Added **Add profile...** to the context menu: creates a profile with the host (IP address), the hostname as name and the ports of the shown scan. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) - Added a search box above the result to filter the ports by IP address, hostname, port, protocol, service, description and status, also while the scan is running. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) - **Copy** > **IP address** / **Hostname** / **Port** / **IP address:Port** is available if multiple results are selected (one value per line, in the order shown). [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) diff --git a/Website/docs/run-command.md b/Website/docs/run-command.md index 81f9fcfc4e..8d2ae3f006 100644 --- a/Website/docs/run-command.md +++ b/Website/docs/run-command.md @@ -29,7 +29,7 @@ The input is validated like the input field of the feature (e.g. a valid host or | Command | Aliases | Arguments | Example | | ------------------ | ------------------ | ----------------------------------- | ---------------------------------------- | | `ipscanner` | `ipscan` | `` | `ipscan 10.0.0.0/24` | -| `portscanner` | `portscan` | ` ` | `portscan 10.0.0.1 22; 80; 443` | +| `portscanner` | `portscan` | ` ` | `portscan 10.0.0.1 webserver;22` | | `pingmonitor` | `ping` | `` | `ping 10.0.0.1; 10.0.0.2` | | `traceroute` | `trace`, `tracert` | `` | `trace borntoberoot.net` | | `dnslookup` | `dns`, `nslookup` | ` [server] [type]` | `dns borntoberoot.net 1.1.1.1 aaaa` | @@ -56,7 +56,7 @@ All features that accept a target also accept a profile (`@profile`), except the - **RDAP:** The type is `domain`, `nameserver`, `tld`, `ip`, `asn` or `entity`. Without a type, it is detected from the query (see [RDAP](./application/rdap.md#example-inputs)). - **Subnet Calculator:** A subnet (e.g. `subnet 10.0.0.0/24`) is calculated in **Calculator**. With a new subnet mask or CIDR (e.g. `/26`, `26` or `255.255.255.192`), it is split in **Subnetting**; the new mask must be between the CIDR of the subnet and `/32` (IPv4) or `/128` (IPv6). With a second subnet (e.g. `subnet 10.0.0.0/24 10.0.1.0/24`), the **Wide Subnet** is calculated (IPv4 only). - **Lookup:** The first word after the command is the lookup: `oui` (MAC addresses or vendors, e.g. `lookup oui 00:11:22`) or `port` (ports, port ranges or services, e.g. `lookup port 22; 80-90/tcp; ssh`). The rest of the input is the query, so vendors can contain spaces without quotes (e.g. `lookup oui Cisco Systems`). Without a query (e.g. `lookup port`), the lookup is only shown. -- **Port Scanner:** The ports are required (a new tab has none): ports and port ranges (e.g. `22; 80; 443`, `1-1024`) or the name of a port profile of the Port Scanner (case-insensitive, e.g. `portscan 10.0.0.1 webserver`; a name with spaces in quotes). The port profiles are suggested after the host. +- **Port Scanner:** The ports are required (a new tab has none): ports and port ranges (e.g. `22; 80; 443`, `1-1024`) and / or names of port profiles of the Port Scanner (case-insensitive, e.g. `portscan 10.0.0.1 webserver` or `portscan 10.0.0.1 webserver;database;22`; a single name with spaces in quotes). The port profiles are suggested after the host, also after a `;`. - **SNMP:** Opens a tab with the host; the query is started in the feature (as before), also with `Enter`. - **Wake on LAN:** The magic packet is sent to the broadcast address (or the one last used in the feature). If there is no valid broadcast address yet, the MAC address is only filled in. A profile sends the magic packet with its settings. From d403a65fcec995b6487934136ef9973a9af5a714 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:00:00 +0200 Subject: [PATCH 087/146] Feature: Port Scanner ports input with port profiles and history (D2) The Ports input is an editable ComboBox like the DNS server of DNS Lookup: the list shows the port profiles (name and ports, sorted) and the history, grouped (PortScannerPortsSuggestions, updated when the port profiles or the history change - weak events, no leak of closed tabs / dialogs). Selecting a port profile inserts its name, resolved when the scan starts (D1); several port profiles can be typed. Removed the "Select port profile" button and its dialog (PortProfilesChildWindow, PortProfilesViewModel) - also the changelog entries of fixes in that dialog (it doesn't exist in the release). Co-Authored-By: Claude Opus 5.5 --- .../PortScannerPortsSuggestions.cs | 71 +++++++++ .../ViewModels/PortProfilesViewModel.cs | 136 ------------------ .../ViewModels/PortScannerViewModel.cs | 47 +----- .../Views/PortProfilesChildWindow.xaml | 85 ----------- .../Views/PortProfilesChildWindow.xaml.cs | 43 ------ .../NETworkManager/Views/PortScannerView.xaml | 49 ++++--- Website/docs/application/port-scanner.md | 6 +- Website/docs/changelog/next-release.md | 5 +- 8 files changed, 105 insertions(+), 337 deletions(-) create mode 100644 Source/NETworkManager/PortScannerPortsSuggestions.cs delete mode 100644 Source/NETworkManager/ViewModels/PortProfilesViewModel.cs delete mode 100644 Source/NETworkManager/Views/PortProfilesChildWindow.xaml delete mode 100644 Source/NETworkManager/Views/PortProfilesChildWindow.xaml.cs diff --git a/Source/NETworkManager/PortScannerPortsSuggestions.cs b/Source/NETworkManager/PortScannerPortsSuggestions.cs new file mode 100644 index 0000000000..ec70ff2c1f --- /dev/null +++ b/Source/NETworkManager/PortScannerPortsSuggestions.cs @@ -0,0 +1,71 @@ +using System; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Linq; +using System.Windows.Data; +using NETworkManager.Controls; +using NETworkManager.Localization.Resources; +using NETworkManager.Settings; + +namespace NETworkManager; + +/// +/// Entry of the ports input of the Port Scanner: a port profile (with its ports) or an entry of the history. +/// +/// Text inserted into the input: name of the port profile or the history entry. +/// Ports of the port profile, null for a history entry. +/// Group in the list (port profiles, history). +public sealed record PortScannerPortsSuggestion(string Text, string Ports, string Group) +{ + /// + /// The text, e.g. kept in an editable ComboBox when the entry is removed from the list (see + /// ). + /// + public override string ToString() + { + return Text; + } +} + +/// +/// Port profiles and history of the Port Scanner for the ports input (an editable ComboBox like the DNS server of +/// DNS Lookup): the port profiles (sorted by name) and the history, grouped. Updated when the port profiles or the +/// history change (weak events, the list doesn't keep a closed tab or dialog alive). +/// +public sealed class PortScannerPortsSuggestions +{ + private readonly ObservableRangeCollection _suggestions = []; + + /// + /// Creates the list with the current port profiles and history. + /// + public PortScannerPortsSuggestions() + { + View = new CollectionViewSource { Source = _suggestions }.View; + View.GroupDescriptions.Add(new PropertyGroupDescription(nameof(PortScannerPortsSuggestion.Group))); + + CollectionChangedEventManager.AddHandler(SettingsManager.Current.PortScanner_PortProfiles, OnSourceChanged); + CollectionChangedEventManager.AddHandler(SettingsManager.Current.PortScanner_PortHistory, OnSourceChanged); + + Refresh(); + } + + /// + /// Gets the view of the entries, grouped (port profiles, history). + /// + public ICollectionView View { get; } + + private void OnSourceChanged(object sender, NotifyCollectionChangedEventArgs e) + { + Refresh(); + } + + private void Refresh() + { + _suggestions.ReplaceAll(SettingsManager.Current.PortScanner_PortProfiles + .OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase) + .Select(x => new PortScannerPortsSuggestion(x.Name, x.Ports, Strings.PortProfiles)) + .Concat(SettingsManager.Current.PortScanner_PortHistory + .Select(x => new PortScannerPortsSuggestion(x, null, Strings.History)))); + } +} diff --git a/Source/NETworkManager/ViewModels/PortProfilesViewModel.cs b/Source/NETworkManager/ViewModels/PortProfilesViewModel.cs deleted file mode 100644 index bdb1b75407..0000000000 --- a/Source/NETworkManager/ViewModels/PortProfilesViewModel.cs +++ /dev/null @@ -1,136 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.ComponentModel; -using System.Linq; -using System.Windows.Data; -using System.Windows.Input; -using NETworkManager.Models.Network; -using NETworkManager.Settings; -using NETworkManager.Utilities; - -namespace NETworkManager.ViewModels; - -/// -/// Represents the view model for managing port profiles. -/// -public class PortProfilesViewModel : ViewModelBase -{ - #region Constructor - - /// - /// Initializes a new instance of the class. - /// - /// The action to execute when the OK command is invoked. - /// The action to execute when the Cancel command is invoked. - public PortProfilesViewModel(Action okCommand, Action cancelHandler) - { - // Only with a selected profile - also for Enter in the grid (the OK button is disabled without a selection) - OKCommand = new RelayCommand(_ => okCommand(this), _ => SelectedPortProfiles.Count > 0); - CancelCommand = new RelayCommand(_ => cancelHandler(this)); - - // Own view (not the default view of the collection, which the settings show too) - sort and filter only - // apply to this dialog - PortProfiles = new CollectionViewSource { Source = SettingsManager.Current.PortScanner_PortProfiles }.View; - PortProfiles.SortDescriptions.Add( - new SortDescription(nameof(PortProfileInfo.Name), ListSortDirection.Ascending)); - PortProfiles.Filter = o => - { - if (string.IsNullOrEmpty(Search)) - return true; - - if (o is not PortProfileInfo info) - return false; - - var search = Search.Trim(); - - // Search: Name, Ports - return info.Name.IndexOf(search, StringComparison.OrdinalIgnoreCase) > -1 || - info.Ports.IndexOf(search, StringComparison.OrdinalIgnoreCase) > -1; - }; - - SelectedPortProfiles = CreateFirstProfileSelection(); - } - - #endregion - - #region Variables - - /// - /// Gets the command to confirm the selection. - /// - public ICommand OKCommand { get; } - - /// - /// Gets the command to cancel the operation. - /// - public ICommand CancelCommand { get; } - - /// - /// Gets or sets the search text to filter the port profiles. - /// - public string Search - { - get; - set - { - if (value == field) - return; - - field = value; - - PortProfiles.Refresh(); - - SelectedPortProfiles = CreateFirstProfileSelection(); - - OnPropertyChanged(); - } - } - - /// - /// Gets the collection of port profiles. - /// - public ICollectionView PortProfiles { get; } - - /// - /// Gets or sets the list of selected port profiles. - /// - public IList SelectedPortProfiles - { - get; - set - { - if (Equals(value, field)) - return; - - field = value; - OnPropertyChanged(); - } - } = new ArrayList(); - - #endregion - - #region Methods - /// - /// Gets the selected port profiles as a typed collection. - /// - /// A collection of selected . - public IEnumerable GetSelectedPortProfiles() - { - return [.. SelectedPortProfiles.OfType()]; - } - - /// - /// Creates the selection with the first shown profile - empty if no profile is shown (no profiles or no match for - /// the search), never a entry. - /// - /// Selection. - private ArrayList CreateFirstProfileSelection() - { - var firstProfile = PortProfiles.Cast().FirstOrDefault(); - - return firstProfile != null ? new ArrayList { firstProfile } : new ArrayList(); - } - #endregion -} \ No newline at end of file diff --git a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs index 465f0f6e6d..c690e65d07 100644 --- a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs @@ -130,9 +130,9 @@ public string Ports } /// - /// Gets the collection view for the ports history. + /// Gets the port profiles and the ports history for the ports input (grouped). /// - public ICollectionView PortsHistoryView { get; } + public ICollectionView PortsSuggestions { get; } = new PortScannerPortsSuggestions().View; /// /// Gets or sets a value indicating whether the scan is currently running. @@ -429,7 +429,6 @@ public PortScannerViewModel(Guid tabId, string host, string port, bool start = t // Set collection view HostHistoryView = CollectionViewSource.GetDefaultView(SettingsManager.Current.PortScanner_HostHistory); - PortsHistoryView = CollectionViewSource.GetDefaultView(SettingsManager.Current.PortScanner_PortHistory); // Columns etc. until the first scan ScanOptions = CreateScanOptions(); @@ -512,21 +511,6 @@ public void OnClose() #region ICommands & Actions - public ICommand OpenPortProfileSelectionCommand => - new RelayCommand(_ => OpenPortProfileSelectionAction(), OpenPortProfileSelection_CanExecute); - - private bool OpenPortProfileSelection_CanExecute(object parameter) - { - return Application.Current.MainWindow != null && - !((MetroWindow)Application.Current.MainWindow).IsAnyDialogOpen && - !ConfigurationManager.Current.IsChildWindowOpen; - } - - private void OpenPortProfileSelectionAction() - { - _ = OpenPortProfileSelection(); - } - public ICommand ScanCommand => new RelayCommand(_ => ScanAction(), Scan_CanExecute); private bool Scan_CanExecute(object parameter) @@ -692,33 +676,6 @@ private void ExportAction() #region Methods - private async Task OpenPortProfileSelection() - { - var window = Application.Current.Windows.OfType().FirstOrDefault(x => x.IsActive); - - var childWindow = new PortProfilesChildWindow(window); - - var childWindowViewModel = new PortProfilesViewModel(async instance => - { - childWindow.IsOpen = false; - ConfigurationManager.Current.IsChildWindowOpen = false; - - Ports = string.Join("; ", instance.GetSelectedPortProfiles().Select(x => x.Ports)); - }, async _ => - { - childWindow.IsOpen = false; - ConfigurationManager.Current.IsChildWindowOpen = false; - }); - - childWindow.Title = Strings.SelectPortProfile; - - childWindow.DataContext = childWindowViewModel; - - ConfigurationManager.Current.IsChildWindowOpen = true; - - await window.ShowChildWindowAsync(childWindow); - } - private async Task Start() { // Port profiles in the input (e.g. "Webserver; 8080") are replaced by their ports. The input is validated, but diff --git a/Source/NETworkManager/Views/PortProfilesChildWindow.xaml b/Source/NETworkManager/Views/PortProfilesChildWindow.xaml deleted file mode 100644 index 0a89488e89..0000000000 --- a/Source/NETworkManager/Views/PortProfilesChildWindow.xaml +++ /dev/null @@ -1,85 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - public class PortsOrPortProfilesValidator : ValidationRule { + /// + /// An empty input is valid (e.g. the ports of a profile). + /// + public bool AllowEmpty { get; set; } + public override ValidationResult Validate(object value, CultureInfo cultureInfo) { + if (AllowEmpty && string.IsNullOrWhiteSpace(value as string)) + return ValidationResult.ValidResult; + return PortProfile.TryResolvePorts(value as string, SettingsManager.Current.PortScanner_PortProfiles, out _) ? ValidationResult.ValidResult : new ValidationResult(false, Strings.EnterValidPortsOrPortProfiles); diff --git a/Source/NETworkManager/ProfileDialogManager.cs b/Source/NETworkManager/ProfileDialogManager.cs index 9107f937eb..772dacd442 100644 --- a/Source/NETworkManager/ProfileDialogManager.cs +++ b/Source/NETworkManager/ProfileDialogManager.cs @@ -6,6 +6,7 @@ using NETworkManager.Models.PuTTY; using NETworkManager.Models.RemoteDesktop; using NETworkManager.Profiles; +using NETworkManager.Settings; using NETworkManager.Utilities; using NETworkManager.ViewModels; using NETworkManager.Views; @@ -54,7 +55,14 @@ private static ProfileInfo ParseProfileInfo(ProfileViewModel instance) PortScanner_Host = instance.PortScanner_InheritHost ? instance.Host?.Trim() : instance.PortScanner_Host?.Trim(), - PortScanner_Ports = instance.PortScanner_Ports?.Trim(), + // Port profiles are referenced by their ID (found after a rename), their ports are saved as well (used if + // a port profile can't be found, e.g. a profile file from another computer) + PortScanner_Ports = PortProfile.TryResolvePorts(instance.PortScanner_Ports, + SettingsManager.Current.PortScanner_PortProfiles, out var portScannerPorts) + ? portScannerPorts + : instance.PortScanner_Ports?.Trim(), + PortScanner_PortsInput = PortProfile.EncodeReferences(instance.PortScanner_Ports, + SettingsManager.Current.PortScanner_PortProfiles), // Ping Monitor PingMonitor_Enabled = instance.PingMonitor_Enabled, diff --git a/Source/NETworkManager/Resources/Styles/ComboBoxStyles.xaml b/Source/NETworkManager/Resources/Styles/ComboBoxStyles.xaml index a248a87516..a5b9abe829 100644 --- a/Source/NETworkManager/Resources/Styles/ComboBoxStyles.xaml +++ b/Source/NETworkManager/Resources/Styles/ComboBoxStyles.xaml @@ -1,7 +1,8 @@  + xmlns:controls="clr-namespace:NETworkManager.Controls;assembly=NETworkManager.Controls" + xmlns:converters="clr-namespace:NETworkManager.Converters;assembly=NETworkManager.Converters"> + + + + + + + + + + + + \ No newline at end of file diff --git a/Source/NETworkManager/ViewModels/PortScannerHostViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerHostViewModel.cs index 7e86dae89d..e095857c2c 100644 --- a/Source/NETworkManager/ViewModels/PortScannerHostViewModel.cs +++ b/Source/NETworkManager/ViewModels/PortScannerHostViewModel.cs @@ -2,7 +2,9 @@ using NETworkManager.Controls; using NETworkManager.Localization.Resources; using NETworkManager.Models; +using NETworkManager.Models.Network; using NETworkManager.Profiles; +using NETworkManager.Settings; using NETworkManager.Utilities; using NETworkManager.Views; using System; @@ -137,19 +139,36 @@ private static void CloseItemAction(ItemActionCallbackArgs args) /// The host to scan. /// The ports to scan. /// Starts it on the first load. If false, the input is only filled in. - public void AddTab(string host = null, string ports = null, bool start = true) + /// Message shown below the input (e.g. a port profile of the profile was not found). + public void AddTab(string host = null, string ports = null, bool start = true, string message = null) { var tabId = Guid.NewGuid(); TabItems.Add(new DragablzTabItem(string.IsNullOrEmpty(host) ? Strings.NewTab : host, - new PortScannerView(tabId, host, ports, start), tabId)); + new PortScannerView(tabId, host, ports, start, message), tabId)); SelectedTabIndex = TabItems.Count - 1; } public void AddTab(ProfileInfo profile, bool start = true) { - AddTab(profile.PortScanner_Host, profile.PortScanner_Ports, start: start); + // Port profiles with their current name and ports. A port profile that can't be found (deleted, or a profile + // file shared from another computer - port profiles are saved in the settings): the ports saved with the + // profile, with a note. + if (string.IsNullOrEmpty(profile.PortScanner_PortsInput)) + { + AddTab(profile.PortScanner_Host, profile.PortScanner_Ports, start); + } + else if (PortProfile.TryDecodeReferences(profile.PortScanner_PortsInput, + SettingsManager.Current.PortScanner_PortProfiles, out var ports)) + { + AddTab(profile.PortScanner_Host, ports, start); + } + else + { + AddTab(profile.PortScanner_Host, profile.PortScanner_Ports, start, + Strings.PortProfileOfProfileNotFoundMessage); + } } #endregion diff --git a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs index c690e65d07..72cf503be6 100644 --- a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs @@ -67,6 +67,9 @@ 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; + // Message of the tab, shown until the first scan has started (see constructor) + private string _message; + // Rescan of shown hosts (see RescanSelectedHosts): their rows are removed and added again by the rescan. The // counters are the ones of the other hosts (base) plus the ones of the rescan; the progress of the last scan is // restored afterwards. @@ -417,10 +420,21 @@ private set /// The initial host to scan. /// The initial ports to scan. /// Starts it on the first load. If false, the input is only filled in. - public PortScannerViewModel(Guid tabId, string host, string port, bool start = true) + /// + /// Message shown below the input, also with the first scan (e.g. a port profile of the profile was not found). + /// + public PortScannerViewModel(Guid tabId, string host, string port, bool start = true, string message = null) { _firstLoad = start; + if (!string.IsNullOrEmpty(message)) + { + _message = message; + + StatusMessage = message; + IsStatusMessageDisplayed = true; + } + ConfigurationManager.Current.PortScannerTabCount++; _tabId = tabId; @@ -740,6 +754,15 @@ private async Task Start() IsStatusMessageDisplayed = false; StatusMessage = string.Empty; + // Message of the tab (see constructor): still shown with the first scan + if (_message != null) + { + StatusMessage = _message; + IsStatusMessageDisplayed = true; + + _message = null; + } + IsRunning = true; PreparingScan = true; diff --git a/Source/NETworkManager/ViewModels/ProfileViewModel.cs b/Source/NETworkManager/ViewModels/ProfileViewModel.cs index b67356d8ee..b53858a0fe 100644 --- a/Source/NETworkManager/ViewModels/ProfileViewModel.cs +++ b/Source/NETworkManager/ViewModels/ProfileViewModel.cs @@ -89,7 +89,12 @@ public ProfileViewModel(Action saveCommand, Action + /// Gets the port profiles and the ports history of the Port Scanner for the ports input (grouped). + /// + public ICollectionView PortScanner_PortsSuggestions { get; } = new PortScannerPortsSuggestions().View; + public string PortScanner_Ports { get; diff --git a/Source/NETworkManager/Views/PortScannerView.xaml b/Source/NETworkManager/Views/PortScannerView.xaml index e15fb130b0..63d6dbc72b 100644 --- a/Source/NETworkManager/Views/PortScannerView.xaml +++ b/Source/NETworkManager/Views/PortScannerView.xaml @@ -24,7 +24,6 @@ - @@ -83,34 +82,15 @@ x:Name="ComboBoxPorts" ItemsSource="{Binding PortsSuggestions}" TextSearch.TextPath="Text" + ItemTemplate="{StaticResource PortScannerPortsSuggestionTemplate}" mah:TextBoxHelper.Watermark="{x:Static localization:StaticStrings.ExamplePortScanRange}" IsReadOnly="{Binding Path=IsRunning}" controls:ComboBoxPasteBehavior.ConvertMultilineToSemicolon="True" Style="{StaticResource HistoryComboBox}"> - - - - - - - + - - - - - - - - diff --git a/Source/NETworkManager/Views/PortScannerView.xaml.cs b/Source/NETworkManager/Views/PortScannerView.xaml.cs index cfe8d174ae..52e5ba14af 100644 --- a/Source/NETworkManager/Views/PortScannerView.xaml.cs +++ b/Source/NETworkManager/Views/PortScannerView.xaml.cs @@ -15,11 +15,12 @@ public partial class PortScannerView : IDragablzTabItem { private readonly PortScannerViewModel _viewModel; - public PortScannerView(Guid tabId, string host = null, string ports = null, bool start = true) + public PortScannerView(Guid tabId, string host = null, string ports = null, bool start = true, + string message = null) { InitializeComponent(); - _viewModel = new PortScannerViewModel(tabId, host, ports, start); + _viewModel = new PortScannerViewModel(tabId, host, ports, start, message); DataContext = _viewModel; diff --git a/Source/NETworkManager/Views/ProfileChildWindow.xaml b/Source/NETworkManager/Views/ProfileChildWindow.xaml index 58db166951..570961f19b 100644 --- a/Source/NETworkManager/Views/ProfileChildWindow.xaml +++ b/Source/NETworkManager/Views/ProfileChildWindow.xaml @@ -320,7 +320,7 @@ Binding="{Binding Data.PortScanner_Enabled, Source={StaticResource BindingProxy}}" Value="True" /> @@ -1718,17 +1718,26 @@ - - + + + + + + - + - - + + @@ -4321,7 +4330,7 @@ diff --git a/Website/docs/application/port-scanner.md b/Website/docs/application/port-scanner.md index 9f70434c14..28b3822d89 100644 --- a/Website/docs/application/port-scanner.md +++ b/Website/docs/application/port-scanner.md @@ -130,7 +130,7 @@ See also the [Port Scanner](./port-scanner) example inputs for more information ### Ports -TCP ports to scan each host for. +TCP ports to scan each host for: ports, port ranges and names of [port profiles](#port-profiles) (selectable in the list). **Type:** `String` @@ -140,6 +140,13 @@ TCP ports to scan each host for. - `1-1024` - `80; 443; 8080; 8443` +- `Webserver; Database; 8080` + +:::note + +A port profile is referenced by the profile, so changes to the port profile apply to the profile, also if it is renamed. Its ports are saved with the profile as well: if the port profile can't be found (it was deleted, or the profile file is shared from another computer - port profiles are saved in the settings of each computer), the saved ports are used and a note is shown. + +::: :::note diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index fe88249e16..ceb630363a 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -101,6 +101,7 @@ Release date: **xx.xx.2026** - 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) - The header of each host shows the number of open ports (e.g. `server (10.0.0.1) · 3 Open`), also while the scan is running. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) - The **Ports** input lists the port profiles (with their ports) and the history, like the DNS servers in **DNS Lookup**: select a port profile, or type ports and port profile names. Replaces the **Select port profile** button and its dialog. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) +- Profiles: the **Ports** of the Port Scanner can contain port profiles (selectable in the list like in the tool), which are referenced by the profile - changes to a port profile apply, also after a rename. The ports are saved with the profile as well and used, with a note, if the port profile can't be found (e.g. a profile file shared from another computer, port profiles are saved in the settings). [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) - Port profiles can be entered by name in the **Ports** input, also several and together with ports (e.g. `Webserver; Database; 8080`, case-insensitive). The name of a port profile must be unique, must not contain `;` (replaced by `,` when updating) and must not be a port or port range. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) - Added **Add profile...** to the context menu: creates a profile with the host (IP address), the hostname as name and the ports of the shown scan. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) - Added a search box above the result to filter the ports by IP address, hostname, port, protocol, service, description and status, also while the scan is running. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) From 864a3c743e846120872494ca1d2c8fec95d760a3 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:07:10 +0200 Subject: [PATCH 091/146] Chore: Remove the strings of the removed port profiles dialog (D2) OpenPortProfilesDots (button tooltip) and SelectPortProfile (dialog title) are no longer used since the "Select port profile" button and its dialog were replaced by the ports input (d403a65fc). English only, the translations follow via Transifex. Co-Authored-By: Claude Opus 5.5 --- .../Resources/Strings.Designer.cs | 18 ------------------ .../Resources/Strings.resx | 6 ------ 2 files changed, 24 deletions(-) diff --git a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs index 212ef57fdb..b4c23ff3e0 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs +++ b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs @@ -8707,15 +8707,6 @@ public static string OpenOIDProfilesDots { } } - /// - /// Looks up a localized string similar to Open port profiles.... - /// - public static string OpenPortProfilesDots { - get { - return ResourceManager.GetString("OpenPortProfilesDots", resourceCulture); - } - } - /// /// Looks up a localized string similar to Open project. /// @@ -12281,15 +12272,6 @@ public static string SelectOIDProfile { } } - /// - /// Looks up a localized string similar to Select port profile. - /// - public static string SelectPortProfile { - get { - return ResourceManager.GetString("SelectPortProfile", resourceCulture); - } - } - /// /// Looks up a localized string similar to Send. /// diff --git a/Source/NETworkManager.Localization/Resources/Strings.resx b/Source/NETworkManager.Localization/Resources/Strings.resx index 8a6d7365fe..e4119c82b7 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.resx +++ b/Source/NETworkManager.Localization/Resources/Strings.resx @@ -2871,12 +2871,6 @@ is disabled! The selected port profile will be deleted permanently. - - Select port profile - - - Open port profiles... - Show SplashScreen on start From c8ec0fc525e75d8fc93b7399ecc282ec070c0ed2 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:08:07 +0200 Subject: [PATCH 092/146] Refactor: Shared ping probe for the IP Scanner and Port Scanner (C2) The ping of the IP Scanner (attempts, retry only on a timeout) is moved unchanged into PingProbe (like PortProbe), so the Port Scanner can ping the hosts before the scan with the same logic. Co-Authored-By: Claude Opus 5.5 --- .../Network/IPScanner.cs | 63 +------------- .../Network/PingProbe.cs | 85 +++++++++++++++++++ 2 files changed, 87 insertions(+), 61 deletions(-) create mode 100644 Source/NETworkManager.Models/Network/PingProbe.cs diff --git a/Source/NETworkManager.Models/Network/IPScanner.cs b/Source/NETworkManager.Models/Network/IPScanner.cs index 2d1e4ba719..7272b85387 100644 --- a/Source/NETworkManager.Models/Network/IPScanner.cs +++ b/Source/NETworkManager.Models/Network/IPScanner.cs @@ -209,7 +209,8 @@ private async Task ScanHostAsync(IPAddress ipAddress, ParallelOptions portScanPa // all ports are finished var portAnswered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var pingTask = PingAsync(ipAddress, cancellationToken); + var pingTask = PingProbe.PingAsync(ipAddress, options.ICMPTimeout, options.ICMPAttempts, + options.ICMPBuffer, cancellationToken); var portScanTask = options.PortScanEnabled ? PortScanAsync(ipAddress, portScanParallelOptions, () => portAnswered.TrySetResult(), cancellationToken) @@ -562,66 +563,6 @@ private static bool IsInLocalSubnet(IPAddress ipAddress, IEnumerable PingAsync(IPAddress ipAddress, CancellationToken cancellationToken) - { - using var ping = new System.Net.NetworkInformation.Ping(); - - PingInfo pingInfo = null; - - for (var i = 0; i < options.ICMPAttempts; i++) - { - // Get timestamp - var timestamp = DateTime.Now; - - try - { - // Note: the CancellationToken-accepting overload requires a TimeSpan timeout, - // unlike the legacy int-based overloads used elsewhere in .NET's Ping API. - var pingReply = await ping.SendPingAsync(ipAddress, TimeSpan.FromMilliseconds(options.ICMPTimeout), - options.ICMPBuffer, cancellationToken: cancellationToken).ConfigureAwait(false); - - // Success - if (pingReply is { Status: IPStatus.Success }) - { - switch (ipAddress.AddressFamily) - { - case AddressFamily.InterNetwork: - return new PingInfo( - timestamp, - pingReply.Address, - pingReply.Buffer.Length, - pingReply.RoundtripTime, - pingReply.Options!.Ttl, - pingReply.Status); - case AddressFamily.InterNetworkV6: - return new PingInfo( - timestamp, - pingReply.Address, - pingReply.Buffer.Length, - pingReply.RoundtripTime, - pingReply.Status); - } - } - - // Failed - pingInfo = new PingInfo(timestamp, ipAddress, pingReply.Status); - - // Only try again if the request timed out (e.g. a lost packet). Other states like - // "destination host unreachable" are a definitive answer. - if (pingReply.Status != IPStatus.TimedOut) - break; - } - catch (PingException) - { - // Ping failed with unknown status - return new PingInfo(timestamp, ipAddress, IPStatus.Unknown); - } - } - - // Result of the last attempt (falls back to unknown status if there was no attempt) - return pingInfo ?? new PingInfo(DateTime.Now, ipAddress, IPStatus.Unknown); - } - /// /// Probes all configured ports of a host. Returns every port (also closed / timed out ones), because /// a closed port is used to detect a reachable host as well - the caller filters what is shown. diff --git a/Source/NETworkManager.Models/Network/PingProbe.cs b/Source/NETworkManager.Models/Network/PingProbe.cs new file mode 100644 index 0000000000..b5dff7a4cc --- /dev/null +++ b/Source/NETworkManager.Models/Network/PingProbe.cs @@ -0,0 +1,85 @@ +using System; +using System.Net; +using System.Net.NetworkInformation; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; + +namespace NETworkManager.Models.Network; + +/// +/// Provides a shared ICMP echo (ping) probe used by and . +/// +internal static class PingProbe +{ + /// + /// Pings the host. Another echo request is only sent if the previous one timed out (e.g. a lost packet), + /// other states like "destination host unreachable" are a definitive answer. + /// + /// IP address of the host. + /// Timeout in milliseconds of one echo request. + /// Number of attempts. + /// Data sent with the echo request. + /// Token to monitor for cancellation requests. + /// Result of the successful attempt, otherwise of the last attempt. + public static async Task PingAsync(IPAddress ipAddress, int timeoutMs, int attempts, byte[] buffer, + CancellationToken cancellationToken) + { + using var ping = new System.Net.NetworkInformation.Ping(); + + PingInfo pingInfo = null; + + for (var i = 0; i < attempts; i++) + { + // Get timestamp + var timestamp = DateTime.Now; + + try + { + // Note: the CancellationToken-accepting overload requires a TimeSpan timeout, + // unlike the legacy int-based overloads used elsewhere in .NET's Ping API. + var pingReply = await ping.SendPingAsync(ipAddress, TimeSpan.FromMilliseconds(timeoutMs), + buffer, cancellationToken: cancellationToken).ConfigureAwait(false); + + // Success + if (pingReply is { Status: IPStatus.Success }) + { + switch (ipAddress.AddressFamily) + { + case AddressFamily.InterNetwork: + return new PingInfo( + timestamp, + pingReply.Address, + pingReply.Buffer.Length, + pingReply.RoundtripTime, + pingReply.Options!.Ttl, + pingReply.Status); + case AddressFamily.InterNetworkV6: + return new PingInfo( + timestamp, + pingReply.Address, + pingReply.Buffer.Length, + pingReply.RoundtripTime, + pingReply.Status); + } + } + + // Failed + pingInfo = new PingInfo(timestamp, ipAddress, pingReply.Status); + + // Only try again if the request timed out (e.g. a lost packet). Other states like + // "destination host unreachable" are a definitive answer. + if (pingReply.Status != IPStatus.TimedOut) + break; + } + catch (PingException) + { + // Ping failed with unknown status + return new PingInfo(timestamp, ipAddress, IPStatus.Unknown); + } + } + + // Result of the last attempt (falls back to unknown status if there was no attempt) + return pingInfo ?? new PingInfo(DateTime.Now, ipAddress, IPStatus.Unknown); + } +} From 07c196cb3bad851d9b07f28c74a38f20f25c38b1 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:09:07 +0200 Subject: [PATCH 093/146] Feature: Port Scanner option to ping hosts before the scan (C2, PS-M5) New setting "Ping hosts before the scan" (default off, help icon): every host is pinged first (PingProbe, with the timeout and attempts of the port scan, retry only on a timeout); a host that doesn't answer is not scanned, its ports are counted as scanned for the progress. After the scan the number of skipped hosts is shown below the input. Sparse ranges with many ports no longer wait for the timeout of every port of an unused address; hosts that block ICMP are skipped (warning in the docs and the tooltip). Co-Authored-By: Claude Opus 5.5 --- .../Resources/Strings.Designer.cs | 27 ++++++++++++++ .../Resources/Strings.resx | 9 +++++ .../Network/PortScanner.cs | 37 +++++++++++++++++-- .../Network/PortScannerOptions.cs | 6 +++ .../GlobalStaticConfiguration.cs | 1 + .../NETworkManager.Settings/SettingsInfo.cs | 13 +++++++ .../PortScannerSettingsViewModel.cs | 20 ++++++++++ .../ViewModels/PortScannerViewModel.cs | 11 ++++++ .../Views/PortScannerSettingsView.xaml | 13 ++++++- Website/docs/application/port-scanner.md | 14 +++++++ Website/docs/changelog/next-release.md | 1 + 11 files changed, 148 insertions(+), 4 deletions(-) diff --git a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs index b4c23ff3e0..12b38682f9 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs +++ b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs @@ -5903,6 +5903,15 @@ public static string HelpMessage_PasswordNotDisplayedCanBeOverwritten { } } + /// + /// Looks up a localized string similar to Only hosts that answer a ping (ICMP echo, with the timeout and attempts above) are scanned. Faster for ranges with many unused IP addresses. Hosts that block ICMP (e.g. Windows with the firewall enabled) are not scanned.. + /// + public static string HelpMessage_PingHostsBeforeScan { + get { + return ResourceManager.GetString("HelpMessage_PingHostsBeforeScan", resourceCulture); + } + } + /// /// Looks up a localized string similar to Host threads × port threads can be at most 8,192 connections at the same time (every connection uses a local port, this is half of them). Many connections at the same time may be blocked by a firewall or overload small routers.. /// @@ -9031,6 +9040,15 @@ public static string PingDetails { } } + /// + /// Looks up a localized string similar to Ping hosts before the scan. + /// + public static string PingHostsBeforeScan { + get { + return ResourceManager.GetString("PingHostsBeforeScan", resourceCulture); + } + } + /// /// Looks up a localized string similar to Ping Monitor. /// @@ -14749,6 +14767,15 @@ public static string XDown { } } + /// + /// Looks up a localized string similar to {0} host(s) did not answer the ping and were not scanned.. + /// + public static string XHostsDidNotAnswerThePingAndWereNotScanned { + get { + return ResourceManager.GetString("XHostsDidNotAnswerThePingAndWereNotScanned", 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 e4119c82b7..d79e6862ca 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.resx +++ b/Source/NETworkManager.Localization/Resources/Strings.resx @@ -5107,4 +5107,13 @@ Do you want to start the scan? A port profile of this profile was not found, the ports saved with the profile are used. Port profiles are saved in the settings - if the profile file was shared from another computer, create the port profile here or edit the profile. + + Ping hosts before the scan + + + Only hosts that answer a ping (ICMP echo, with the timeout and attempts above) are scanned. Faster for ranges with many unused IP addresses. Hosts that block ICMP (e.g. Windows with the firewall enabled) are not scanned. + + + {0} host(s) did not answer the ping and were not scanned. + \ No newline at end of file diff --git a/Source/NETworkManager.Models/Network/PortScanner.cs b/Source/NETworkManager.Models/Network/PortScanner.cs index 607a3aea33..590a68af84 100644 --- a/Source/NETworkManager.Models/Network/PortScanner.cs +++ b/Source/NETworkManager.Models/Network/PortScanner.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; using System.Net; +using System.Net.NetworkInformation; using System.Threading; using System.Threading.Tasks; using NETworkManager.Models.Lookup; @@ -53,6 +54,13 @@ public PortScanner(PortScannerOptions options) private readonly InterlockedCounter _portsOpen = new(); private readonly InterlockedCounter _portsClosed = new(); private readonly InterlockedCounter _portsTimedOut = new(); + private readonly InterlockedCounter _hostsSkipped = new(); + + /// + /// Data of the ping before the scan (see ), like the + /// default buffer of the IP Scanner. + /// + private static readonly byte[] PingBuffer = new byte[32]; // Closed and timed out ports per host, see GetClosedAndTimedOutPortsPerHost private readonly ConcurrentDictionary @@ -84,6 +92,13 @@ public PortScanner(PortScannerOptions options) /// public int PortsTimedOut => _portsTimedOut.Value; + /// + /// Gets the number of hosts that were not scanned because they didn't answer the ping before the scan (see + /// ). Thread-safe; may be read from any thread while the + /// scan is running. + /// + public int HostsSkipped => _hostsSkipped.Value; + /// /// Gets the number of closed and timed out ports per host scanned so far, only hosts with such a port. These /// ports are only raised in if they are shown - e.g. a rescan of some hosts needs @@ -169,6 +184,22 @@ public void ScanAsync(IEnumerable<(IPAddress ipAddress, string hostname)> hosts, await Parallel.ForEachAsync(hosts, hostParallelOptions, async (host, hostCt) => { + // Only hosts that answer a ping are scanned (option) - the ports of the other hosts are counted + // as scanned, so the progress is complete + if (_options.PingHostsBeforeScan) + { + var pingInfo = await PingProbe.PingAsync(host.ipAddress, _options.Timeout, _options.Attempts, + PingBuffer, hostCt).ConfigureAwait(false); + + if (pingInfo.Status != IPStatus.Success) + { + _hostsSkipped.Increment(); + IncreaseProgress(portCount); + + return; + } + } + // Resolve Hostname (PTR) while the ports are scanned. Started with the first port probe of the // host, so the lookups of many hosts are spread over time like the probes instead of all at once var hostnameTask = new Lazy>(() => _options.ResolveHostname @@ -257,10 +288,10 @@ await Parallel.ForEachAsync(ports, portParallelOptions, async (port, portCt) => return (Math.Clamp(AutoMaxConnections / portsAtOnce, 1, AutoMaxHostThreads), AutoMaxPortThreads); } - private void IncreaseProgress() + private void IncreaseProgress(int count = 1) { - // Increase the progress - Interlocked.Increment(ref _progressValue); + // Increase the progress + Interlocked.Add(ref _progressValue, count); OnProgressChanged(); } diff --git a/Source/NETworkManager.Models/Network/PortScannerOptions.cs b/Source/NETworkManager.Models/Network/PortScannerOptions.cs index 3c531daee8..94b6c12d84 100644 --- a/Source/NETworkManager.Models/Network/PortScannerOptions.cs +++ b/Source/NETworkManager.Models/Network/PortScannerOptions.cs @@ -31,6 +31,12 @@ public class PortScannerOptions /// public required int Attempts { get; init; } + /// + /// Ping every host before its ports are scanned (with and ), a host + /// that doesn't answer is not scanned. + /// + public required bool PingHostsBeforeScan { get; init; } + /// /// Resolve the hostname for an IP address. /// diff --git a/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs b/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs index cdc201aa27..9ddfd821b1 100644 --- a/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs +++ b/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs @@ -166,6 +166,7 @@ public static class GlobalStaticConfiguration public static int PortScanner_MaxConnections => 8192; public static int PortScanner_Timeout => 2000; public static int PortScanner_Attempts => 1; + public static bool PortScanner_PingHostsBeforeScan => false; public static ExportFileType PortScanner_ExportFileType => ExportFileType.Csv; // Larger scans (hosts x ports) need a confirmation, even larger ones are refused public static long PortScanner_ConfirmPortCount => 65536; diff --git a/Source/NETworkManager.Settings/SettingsInfo.cs b/Source/NETworkManager.Settings/SettingsInfo.cs index 52e88573b8..db3738bdcf 100644 --- a/Source/NETworkManager.Settings/SettingsInfo.cs +++ b/Source/NETworkManager.Settings/SettingsInfo.cs @@ -1541,6 +1541,19 @@ public int PortScanner_Attempts } } = GlobalStaticConfiguration.PortScanner_Attempts; + public bool PortScanner_PingHostsBeforeScan + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } = GlobalStaticConfiguration.PortScanner_PingHostsBeforeScan; + public bool PortScanner_AutoAdjustThreads { get; diff --git a/Source/NETworkManager/ViewModels/PortScannerSettingsViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerSettingsViewModel.cs index 5ea9f5af9e..2d991dc575 100644 --- a/Source/NETworkManager/ViewModels/PortScannerSettingsViewModel.cs +++ b/Source/NETworkManager/ViewModels/PortScannerSettingsViewModel.cs @@ -120,6 +120,25 @@ public bool ResolveHostname } } + /// + /// Gets or sets whether every host is pinged before its ports are scanned (hosts without answer are skipped). + /// + public bool PingHostsBeforeScan + { + get; + set + { + if (value == field) + return; + + if (!_isLoading) + SettingsManager.Current.PortScanner_PingHostsBeforeScan = value; + + field = value; + OnPropertyChanged(); + } + } + /// /// Gets or sets whether the number of hosts and ports scanned at the same time is adjusted to the scan (instead of /// and ). @@ -222,6 +241,7 @@ private void LoadSettings() ShowAllResults = SettingsManager.Current.PortScanner_ShowAllResults; Timeout = SettingsManager.Current.PortScanner_Timeout; Attempts = SettingsManager.Current.PortScanner_Attempts; + PingHostsBeforeScan = SettingsManager.Current.PortScanner_PingHostsBeforeScan; ResolveHostname = SettingsManager.Current.PortScanner_ResolveHostname; AutoAdjustThreads = SettingsManager.Current.PortScanner_AutoAdjustThreads; MaxHostThreads = SettingsManager.Current.PortScanner_MaxHostThreads; diff --git a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs index 72cf503be6..30abb3c5be 100644 --- a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs @@ -1007,6 +1007,7 @@ private static PortScannerOptions CreateScanOptions() MaxPortThreads = SettingsManager.Current.PortScanner_MaxPortThreads, Timeout = SettingsManager.Current.PortScanner_Timeout, Attempts = SettingsManager.Current.PortScanner_Attempts, + PingHostsBeforeScan = SettingsManager.Current.PortScanner_PingHostsBeforeScan, ResolveHostname = SettingsManager.Current.PortScanner_ResolveHostname, ShowAllResults = SettingsManager.Current.PortScanner_ShowAllResults }; @@ -1300,6 +1301,16 @@ private void ScanComplete(object sender, ScanCompleteArgs e) IsStatusMessageDisplayed = true; } + // Hosts that were not scanned because they didn't answer the ping before the scan (option) + if (!e.IsCanceled && e.Exception == null && sender is PortScanner { HostsSkipped: > 0 } pingScanner) + { + var message = string.Format(Strings.XHostsDidNotAnswerThePingAndWereNotScanned, + pingScanner.HostsSkipped); + + StatusMessage = IsStatusMessageDisplayed ? StatusMessage + Environment.NewLine + message : message; + IsStatusMessageDisplayed = true; + } + PreparingScan = false; IsCanceling = false; IsRunning = false; diff --git a/Source/NETworkManager/Views/PortScannerSettingsView.xaml b/Source/NETworkManager/Views/PortScannerSettingsView.xaml index 9393368833..e44742383d 100644 --- a/Source/NETworkManager/Views/PortScannerSettingsView.xaml +++ b/Source/NETworkManager/Views/PortScannerSettingsView.xaml @@ -100,7 +100,18 @@ - + + + + + + - + @@ -33,13 +33,13 @@ Foreground="{DynamicResource MahApps.Brushes.Gray5}" Margin="5,5,5,2" /> - + - + Visibility="{Binding Path=Description, Converter={StaticResource ComboBoxStringIsNotNullOrEmptyToVisibilityCollapsedConverter}}" /> \ No newline at end of file diff --git a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs index e1300adf41..db323f1894 100644 --- a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs @@ -132,7 +132,7 @@ public string Ports } } - private readonly PortScannerPortsSuggestions _portsSuggestions = new(); + private readonly InputSuggestions _portsSuggestions = InputSuggestions.ForPortScannerPorts(); /// /// Gets the port profiles and the ports history for the ports input (grouped). @@ -142,7 +142,7 @@ public string Ports /// /// Gets the names of the port profiles for the Tab completion of the ports input. /// - public IEnumerable PortProfileNames => _portsSuggestions.PortProfileNames; + public IEnumerable PortProfileNames => _portsSuggestions.ProfileNames; /// /// Gets or sets a value indicating whether the scan is currently running. diff --git a/Source/NETworkManager/ViewModels/ProfileViewModel.cs b/Source/NETworkManager/ViewModels/ProfileViewModel.cs index 5739768b3b..7c36f91f88 100644 --- a/Source/NETworkManager/ViewModels/ProfileViewModel.cs +++ b/Source/NETworkManager/ViewModels/ProfileViewModel.cs @@ -739,7 +739,7 @@ public string PortScanner_Host } } - private readonly PortScannerPortsSuggestions _portScannerPortsSuggestions = new(); + private readonly InputSuggestions _portScannerPortsSuggestions = InputSuggestions.ForPortScannerPorts(); /// /// Gets the port profiles and the ports history of the Port Scanner for the ports input (grouped). @@ -749,7 +749,7 @@ public string PortScanner_Host /// /// Gets the names of the port profiles for the Tab completion of the ports input of the Port Scanner. /// - public IEnumerable PortScanner_PortProfileNames => _portScannerPortsSuggestions.PortProfileNames; + public IEnumerable PortScanner_PortProfileNames => _portScannerPortsSuggestions.ProfileNames; public string PortScanner_Ports { diff --git a/Source/NETworkManager/Views/PortScannerView.xaml b/Source/NETworkManager/Views/PortScannerView.xaml index 09468b7c10..02e56d2ee0 100644 --- a/Source/NETworkManager/Views/PortScannerView.xaml +++ b/Source/NETworkManager/Views/PortScannerView.xaml @@ -82,7 +82,7 @@ x:Name="ComboBoxPorts" ItemsSource="{Binding PortsSuggestions}" TextSearch.TextPath="Text" - ItemTemplate="{StaticResource PortScannerPortsSuggestionTemplate}" + ItemTemplate="{StaticResource InputSuggestionTemplate}" controls:ComboBoxListCompletionBehavior.Items="{Binding PortProfileNames}" mah:TextBoxHelper.Watermark="{x:Static localization:StaticStrings.ExamplePortScanRange}" IsReadOnly="{Binding Path=IsRunning}" diff --git a/Source/NETworkManager/Views/ProfileChildWindow.xaml b/Source/NETworkManager/Views/ProfileChildWindow.xaml index f528eaed75..c7080e8b96 100644 --- a/Source/NETworkManager/Views/ProfileChildWindow.xaml +++ b/Source/NETworkManager/Views/ProfileChildWindow.xaml @@ -1723,7 +1723,7 @@ From ce5b4a82c44cd6259c282f59558dbba84c28e46b Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:25:15 +0200 Subject: [PATCH 097/146] Feature: DNS Lookup several DNS servers and DNS server names in a list (E1) The DNS server input accepts several entries separated by ";": names of DNS server profiles (case-insensitive) and DNS servers (IP address or hostname, optional port), e.g. "Google Public DNS; Cloudflare; 9.9.9.9". Shared resolver DNSServer.ResolveServers (every server once, [Windows DNS] alone uses the Windows resolver as before, in a list it adds the DNS servers of Windows) and GetInvalidEntries for the validators; unit tests. Fixes: the tool matched the name case-sensitively while the run command validated it case-insensitively - "dns host cloudflare" was resolved as hostname "cloudflare". Run command: every entry is validated (custom servers still need an IP or a domain), suggestions also after a ";". DNS server names must not contain ";" (validator in the server profile dialog, shared with SNTP; the update replaces ";" with ","), the name of [Windows DNS] counts as used. Co-Authored-By: Claude Opus 5.5 --- .../Resources/Strings.Designer.cs | 18 +++ .../Resources/Strings.resx | 6 + .../Network/DNSServer.cs | 131 +++++++++++++++++- .../SettingsManager.cs | 8 ++ .../Network/DNSServerTests.cs | 57 ++++++++ .../DNSServersValidator.cs | 29 ++++ .../NoSemicolonValidator.cs | 19 +++ Source/NETworkManager/RunCommandManager.cs | 21 +-- .../ViewModels/DNSLookupSettingsViewModel.cs | 4 +- .../ViewModels/DNSLookupViewModel.cs | 31 +---- .../ViewModels/RunCommandViewModel.cs | 5 +- ...erverConnectionInfoProfileChildWindow.xaml | 2 + Website/docs/application/dns-lookup.md | 3 +- Website/docs/changelog/next-release.md | 2 + Website/docs/run-command.md | 2 +- 15 files changed, 293 insertions(+), 45 deletions(-) create mode 100644 Source/NETworkManager.Tests/Network/DNSServerTests.cs create mode 100644 Source/NETworkManager.Validators/DNSServersValidator.cs create mode 100644 Source/NETworkManager.Validators/NoSemicolonValidator.cs diff --git a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs index 12b38682f9..1ee1dcf93d 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs +++ b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs @@ -4292,6 +4292,15 @@ public static string EnterValidBaud { } } + /// + /// Looks up a localized string similar to Enter valid DNS servers (IP address or hostname, optional port) and/or names of DNS servers!. + /// + public static string EnterValidDNSServersOrDNSServerProfiles { + get { + return ResourceManager.GetString("EnterValidDNSServersOrDNSServerProfiles", resourceCulture); + } + } + /// /// Looks up a localized string similar to Enter a valid domain (e.g., "example.com")!. /// @@ -4625,6 +4634,15 @@ public static string ErrorMessage_NameIsAlreadyUsed { } } + /// + /// Looks up a localized string similar to The name must not contain ";".. + /// + public static string ErrorMessage_NameMustNotContainSemicolon { + get { + return ResourceManager.GetString("ErrorMessage_NameMustNotContainSemicolon", resourceCulture); + } + } + /// /// Looks up a localized string similar to The name must not contain ";" and must not be a port or port range.. /// diff --git a/Source/NETworkManager.Localization/Resources/Strings.resx b/Source/NETworkManager.Localization/Resources/Strings.resx index d79e6862ca..ec25663be6 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.resx +++ b/Source/NETworkManager.Localization/Resources/Strings.resx @@ -5116,4 +5116,10 @@ Do you want to start the scan? {0} host(s) did not answer the ping and were not scanned. + + Enter valid DNS servers (IP address or hostname, optional port) and/or names of DNS servers! + + + The name must not contain ";". + \ No newline at end of file diff --git a/Source/NETworkManager.Models/Network/DNSServer.cs b/Source/NETworkManager.Models/Network/DNSServer.cs index acbd0270ee..d55a2c564f 100644 --- a/Source/NETworkManager.Models/Network/DNSServer.cs +++ b/Source/NETworkManager.Models/Network/DNSServer.cs @@ -1,4 +1,8 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using DnsClient; namespace NETworkManager.Models.Network; @@ -7,6 +11,131 @@ namespace NETworkManager.Models.Network; /// public static class DNSServer { + /// + /// Separator of the entries of a DNS server input (e.g. "Google Public DNS; 9.9.9.9"). + /// + private const char EntrySeparator = ';'; + + /// + /// Default port of a DNS server entered without a port. + /// + private const int DefaultPort = 53; + + /// + /// Finds a DNS server profile by its name (case-insensitive, leading / trailing spaces are ignored). + /// + /// DNS server profiles. + /// Name of the DNS server profile. + /// DNS server profile, or null if there is none with the name. + public static DNSServerConnectionInfoProfile FindByName(IEnumerable profiles, + string name) + { + var trimmedName = name?.Trim(); + + return string.IsNullOrEmpty(trimmedName) + ? null + : profiles.FirstOrDefault(x => + string.Equals(x.Name?.Trim(), trimmedName, StringComparison.OrdinalIgnoreCase)); + } + + /// + /// Gets the entries of a DNS server input that are neither the name of a DNS server profile nor a DNS server + /// (see ), without resolving anything. + /// + /// DNS server input. + /// DNS server profiles that can be used by their name. + /// + /// A hostname must contain a dot (e.g. the run command, where a single word can also be a mistyped type). + /// + /// Invalid entries, the input itself if it is empty. + public static List GetInvalidEntries(string input, IEnumerable profiles, + bool hostnameNeedsDomain = false) + { + if (string.IsNullOrWhiteSpace(input)) + return [input ?? string.Empty]; + + var profileList = profiles.ToList(); + + return + [ + .. input.Split(EntrySeparator) + .Where(entry => FindByName(profileList, entry) == null && + !TryParseServer(entry, hostnameNeedsDomain, out _)) + .Select(entry => entry.Trim()) + ]; + } + + /// + /// Resolves a DNS server input into DNS servers. Every entry (separated by ";") is the name of a DNS server + /// profile (e.g. "Google Public DNS", case-insensitive), which is replaced by its servers, or a DNS server (IP + /// address or hostname, optional port, e.g. "9.9.9.9" or "dns.example.com:5353") - e.g. "Google Public DNS; + /// 9.9.9.9". The Windows DNS profile in a list is replaced by the DNS servers of Windows. Every server only once. + /// + /// DNS server input. + /// DNS server profiles that can be used by their name. + /// + /// True if the input is only the Windows DNS profile: the DNS servers of Windows are used (no servers are + /// returned). + /// + /// Entries that are neither a DNS server profile nor a DNS server (skipped). + /// DNS servers (hostnames are not resolved). + public static List ResolveServers(string input, + IEnumerable profiles, out bool useWindowsDNSServer, + out List invalidEntries) + { + useWindowsDNSServer = false; + invalidEntries = []; + + List servers = []; + + if (string.IsNullOrWhiteSpace(input)) + return servers; + + var profileList = profiles.ToList(); + var entries = input.Split(EntrySeparator); + + foreach (var entry in entries) + { + var profile = FindByName(profileList, entry); + + if (profile is { UseWindowsDNSServer: true }) + { + // Only the Windows DNS profile: DNS lookup uses the DNS servers of Windows itself + if (entries.Length == 1) + { + useWindowsDNSServer = true; + return servers; + } + + servers.AddRange(NameServer.ResolveNameServers(true, false).Select(x => + new ServerConnectionInfo(x.Address, x.Port, TransportProtocol.Udp))); + } + else if (profile != null) + { + servers.AddRange(profile.Servers); + } + else if (TryParseServer(entry, false, out var server)) + { + servers.Add(server); + } + else + { + invalidEntries.Add(entry.Trim()); + } + } + + return [.. servers.DistinctBy(x => (x.Server.ToLowerInvariant(), x.Port, x.TransportProtocol))]; + } + + /// + /// Parses a DNS server entry (IP address or hostname, optional port). + /// + private static bool TryParseServer(string entry, bool hostnameNeedsDomain, out ServerConnectionInfo server) + { + return ServerConnectionInfo.TryParse(entry, out server, DefaultPort, TransportProtocol.Udp) && + (!hostnameNeedsDomain || IPAddress.TryParse(server.Server, out _) || server.Server.Contains('.')); + } + /// /// Gets the default list of common dns servers. /// diff --git a/Source/NETworkManager.Settings/SettingsManager.cs b/Source/NETworkManager.Settings/SettingsManager.cs index f07013f902..db2ace57b7 100644 --- a/Source/NETworkManager.Settings/SettingsManager.cs +++ b/Source/NETworkManager.Settings/SettingsManager.cs @@ -792,6 +792,14 @@ private static void UpgradeToLatest(Version version) Current.PortScanner_MaxPortThreads = GlobalStaticConfiguration.PortScanner_MaxPortThreads; } + // DNS Lookup - DNS servers can be entered by name in a list (e.g. "Google Public DNS; Cloudflare"), where ";" + // separates the entries: replace it in the names + foreach (var dnsServer in Current.DNSLookup_DNSServers.Where(x => x.Name?.Contains(';') == true)) + { + Log.Info($"Replace \";\" in the name of the DNS server \"{dnsServer.Name}\"..."); + dnsServer.Name = dnsServer.Name.Replace(';', ','); + } + // Port Scanner - port profiles have an internal ID (profiles reference them by it) foreach (var portProfile in Current.PortScanner_PortProfiles.Where(x => x.Id == Guid.Empty)) { diff --git a/Source/NETworkManager.Tests/Network/DNSServerTests.cs b/Source/NETworkManager.Tests/Network/DNSServerTests.cs new file mode 100644 index 0000000000..0093301b21 --- /dev/null +++ b/Source/NETworkManager.Tests/Network/DNSServerTests.cs @@ -0,0 +1,57 @@ +using System.Collections.Generic; +using System.Linq; +using NETworkManager.Models.Network; +using Xunit; + +namespace NETworkManager.Tests.Network; + +public class DNSServerTests +{ + private static readonly List Profiles = DNSServer.GetDefaultList(); + + [Theory] + [InlineData("Cloudflare", "1.1.1.1:53; 1.0.0.1:53")] + [InlineData("cloudflare", "1.1.1.1:53; 1.0.0.1:53")] + [InlineData("Google Public DNS;Cloudflare", "8.8.8.8:53; 8.8.4.4:53; 1.1.1.1:53; 1.0.0.1:53")] + [InlineData(" Quad9 ; 1.1.1.1", "9.9.9.9:53; 149.112.112.112:53; 1.1.1.1:53")] + [InlineData("1.1.1.1:5353; dns.example.com", "1.1.1.1:5353; dns.example.com:53")] + [InlineData("Cloudflare; 1.1.1.1", "1.1.1.1:53; 1.0.0.1:53")] + public void ResolveServers(string input, string expected) + { + var servers = DNSServer.ResolveServers(input, Profiles, out var useWindowsDNSServer, out var invalidEntries); + + Assert.False(useWindowsDNSServer); + Assert.Empty(invalidEntries); + Assert.Equal(expected, string.Join("; ", servers.Select(x => $"{x.Server}:{x.Port}"))); + } + + [Fact] + public void ResolveServers_WindowsDNSServerAlone() + { + var servers = DNSServer.ResolveServers("[windows dns]", Profiles, out var useWindowsDNSServer, out _); + + Assert.True(useWindowsDNSServer); + Assert.Empty(servers); + } + + [Fact] + public void ResolveServers_InvalidEntriesAreSkipped() + { + var servers = DNSServer.ResolveServers("Cloudflare; not valid!", Profiles, out _, out var invalidEntries); + + Assert.Equal(2, servers.Count); + Assert.Equal(["not valid!"], invalidEntries); + } + + [Theory] + [InlineData("Cloudflare", false, 0)] + [InlineData("google public dns;9.9.9.9", true, 0)] + [InlineData("dns01", false, 0)] + [InlineData("dns01", true, 1)] + [InlineData("Cloudflare;aaa", true, 1)] + [InlineData("", false, 1)] + public void GetInvalidEntries(string input, bool hostnameNeedsDomain, int expectedCount) + { + Assert.Equal(expectedCount, DNSServer.GetInvalidEntries(input, Profiles, hostnameNeedsDomain).Count); + } +} diff --git a/Source/NETworkManager.Validators/DNSServersValidator.cs b/Source/NETworkManager.Validators/DNSServersValidator.cs new file mode 100644 index 0000000000..4976a50776 --- /dev/null +++ b/Source/NETworkManager.Validators/DNSServersValidator.cs @@ -0,0 +1,29 @@ +using System.Globalization; +using System.Windows.Controls; +using NETworkManager.Localization.Resources; +using NETworkManager.Models.Network; +using NETworkManager.Settings; + +namespace NETworkManager.Validators; + +/// +/// Validates a DNS server input of DNS Lookup: names of DNS servers and DNS servers (IP address or hostname, +/// optional port) separated by ";" (e.g. "Google Public DNS; 9.9.9.9"), see . +/// +public class DNSServersValidator : ValidationRule +{ + /// + /// An empty input is valid (e.g. the DNS server of a profile). + /// + public bool AllowEmpty { get; set; } + + public override ValidationResult Validate(object value, CultureInfo cultureInfo) + { + if (AllowEmpty && string.IsNullOrWhiteSpace(value as string)) + return ValidationResult.ValidResult; + + return DNSServer.GetInvalidEntries(value as string, SettingsManager.Current.DNSLookup_DNSServers).Count == 0 + ? ValidationResult.ValidResult + : new ValidationResult(false, Strings.EnterValidDNSServersOrDNSServerProfiles); + } +} diff --git a/Source/NETworkManager.Validators/NoSemicolonValidator.cs b/Source/NETworkManager.Validators/NoSemicolonValidator.cs new file mode 100644 index 0000000000..f5e21b25c3 --- /dev/null +++ b/Source/NETworkManager.Validators/NoSemicolonValidator.cs @@ -0,0 +1,19 @@ +using System.Globalization; +using System.Windows.Controls; +using NETworkManager.Localization.Resources; + +namespace NETworkManager.Validators; + +/// +/// Validates that a name doesn't contain ";" - e.g. the name of a DNS server, which can be entered in a list +/// separated by ";". +/// +public class NoSemicolonValidator : ValidationRule +{ + public override ValidationResult Validate(object value, CultureInfo cultureInfo) + { + return (value as string)?.Contains(';') == true + ? new ValidationResult(false, Strings.ErrorMessage_NameMustNotContainSemicolon) + : ValidationResult.ValidResult; + } +} diff --git a/Source/NETworkManager/RunCommandManager.cs b/Source/NETworkManager/RunCommandManager.cs index 1c80e0a49a..65da272890 100644 --- a/Source/NETworkManager/RunCommandManager.cs +++ b/Source/NETworkManager/RunCommandManager.cs @@ -358,24 +358,17 @@ ApplicationName.Traceroute or ApplicationName.PowerShell or ApplicationName.PuTT } /// - /// Validates the DNS server argument: the name of a configured DNS server, or custom DNS server(s) in the format - /// of the DNS lookup (IP address or host with a domain, optional port). A single word that is neither (e.g. a - /// mistyped type like "aaa") is an unknown argument instead of a DNS server. + /// Validates the DNS server argument: names of configured DNS servers (case-insensitive) and / or custom DNS + /// servers in the format of the DNS lookup (IP address or host with a domain, optional port), separated by ";" + /// (e.g. "google;cloudflare;9.9.9.9"). A single word that is neither (e.g. a mistyped type like "aaa") is an + /// unknown argument instead of a DNS server. /// private static string ValidateDNSServer(string dnsServer) { - if (GetDNSServers().Any(x => string.Equals(x, dnsServer, StringComparison.OrdinalIgnoreCase))) - return null; + var invalidEntry = DNSServer.GetInvalidEntries(dnsServer, SettingsManager.Current.DNSLookup_DNSServers, + hostnameNeedsDomain: true).FirstOrDefault(); - foreach (var server in dnsServer.Split(';', - StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) - { - if (!ServerConnectionInfo.TryParse(server, out var serverInfo, 53, TransportProtocol.Udp) || - !IPAddress.TryParse(serverInfo.Server, out _) && !serverInfo.Server.Contains('.')) - return string.Format(Strings.UnknownArgumentX, server); - } - - return null; + return invalidEntry == null ? null : string.Format(Strings.UnknownArgumentX, invalidEntry); } /// diff --git a/Source/NETworkManager/ViewModels/DNSLookupSettingsViewModel.cs b/Source/NETworkManager/ViewModels/DNSLookupSettingsViewModel.cs index d9dcee5604..61a356dd20 100644 --- a/Source/NETworkManager/ViewModels/DNSLookupSettingsViewModel.cs +++ b/Source/NETworkManager/ViewModels/DNSLookupSettingsViewModel.cs @@ -59,8 +59,8 @@ public DNSServerConnectionInfoProfile SelectedDNSServer /// private List ServerInfoProfileNames => [ - .. SettingsManager.Current.DNSLookup_DNSServers - .Where(x => !x.UseWindowsDNSServer).Select(x => x.Name) + // Also the Windows DNS server - names are used in the DNS server input, they must be unique + .. SettingsManager.Current.DNSLookup_DNSServers.Select(x => x.Name) ]; /// diff --git a/Source/NETworkManager/ViewModels/DNSLookupViewModel.cs b/Source/NETworkManager/ViewModels/DNSLookupViewModel.cs index 5233973d29..aab3413878 100644 --- a/Source/NETworkManager/ViewModels/DNSLookupViewModel.cs +++ b/Source/NETworkManager/ViewModels/DNSLookupViewModel.cs @@ -410,32 +410,15 @@ private async Task QueryAsync() dnsSettings.CustomDNSSuffix = SettingsManager.Current.DNSLookup_CustomDNSSuffix?.TrimStart('.'); } - // Try to find DNS server profile - var dnsServerProfile = SettingsManager.Current.DNSLookup_DNSServers - .FirstOrDefault(x => x.Name == DNSServer); + // DNS server profiles (by name, case-insensitive) and DNS servers, also several (e.g. "Google Public DNS; + // 9.9.9.9") + var dnsServersToResolve = Models.Network.DNSServer.ResolveServers(DNSServer, + SettingsManager.Current.DNSLookup_DNSServers, out var useWindowsDNSServer, out var invalidEntries); - DNSLookup dnsLookup = null; - List dnsServersToResolve = []; + foreach (var invalidEntry in invalidEntries) + AppendStatusMessage(Strings.DNSServer + ": " + string.Format(Strings.CouldNotParseX, invalidEntry)); - // Use DNS server profile if found - if (dnsServerProfile != null) - { - if (dnsServerProfile.UseWindowsDNSServer) - dnsLookup = new DNSLookup(dnsSettings); - else - dnsServersToResolve = dnsServerProfile.Servers; - } - // Parse DNS server input - else - { - foreach (var dnsServerInput in DNSServer.Split(';')) - { - if (ServerConnectionInfo.TryParse(dnsServerInput, out var serverInfo, 53, TransportProtocol.Udp)) - dnsServersToResolve.Add(serverInfo); - else - AppendStatusMessage(Strings.DNSServer + ": " + string.Format(Strings.CouldNotParseX, dnsServerInput)); - } - } + var dnsLookup = useWindowsDNSServer ? new DNSLookup(dnsSettings) : null; // Resolve DNS server hostnames (if any) - not required when using Windows DNS servers if (dnsLookup == null) diff --git a/Source/NETworkManager/ViewModels/RunCommandViewModel.cs b/Source/NETworkManager/ViewModels/RunCommandViewModel.cs index 94eb4388b3..07a34dd5df 100644 --- a/Source/NETworkManager/ViewModels/RunCommandViewModel.cs +++ b/Source/NETworkManager/ViewModels/RunCommandViewModel.cs @@ -1143,13 +1143,14 @@ private IEnumerable GetPortProfileSuggestions() /// /// DNS servers configured in the DNS lookup (e.g. "Cloudflare") that contain the typed word, if no DNS server was - /// entered yet. + /// entered yet. Several DNS servers can be entered (e.g. "\"Google Public DNS\";cloudflare"), the entry after + /// the last ";" is completed. /// private IEnumerable GetDNSServerSuggestions() { return GetArgumentValueSuggestions(RunCommandArgument.DNSServer, RunCommandManager.GetDNSServers().Select(x => (x, Strings.DNSServer)), RunCommandSuggestionKind.DNSServer, - Strings.DNSServers); + Strings.DNSServers, isList: true); } /// diff --git a/Source/NETworkManager/Views/ServerConnectionInfoProfileChildWindow.xaml b/Source/NETworkManager/Views/ServerConnectionInfoProfileChildWindow.xaml index 3a3ccb48f6..51e25d336a 100644 --- a/Source/NETworkManager/Views/ServerConnectionInfoProfileChildWindow.xaml +++ b/Source/NETworkManager/Views/ServerConnectionInfoProfileChildWindow.xaml @@ -52,6 +52,8 @@ + + |:` (`` is optional, to use a custom port with IPv6 enclose the address in square brackets: `[]:53`). +The DNS server can be selected from a list of configured servers or you can enter a custom DNS server in the format `|:` (`` is optional, to use a custom port with IPv6 enclose the address in square brackets: `[]:53`). Names of configured DNS servers (case-insensitive) and custom DNS servers can be combined with a semicolon (e.g. `Google Public DNS; Cloudflare; 9.9.9.9`), every DNS server is queried once. `[Windows DNS]` together with other DNS servers adds the DNS servers configured in Windows. ### Context menu diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index e0a7946680..bddd837f12 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -127,6 +127,7 @@ Release date: **xx.xx.2026** **DNS Lookup** +- Several DNS servers can be entered, also names of configured DNS servers together with custom DNS servers (e.g. `Google Public DNS; Cloudflare; 9.9.9.9`), every DNS server is queried once; `[Windows DNS]` in a list adds the DNS servers of Windows. The name of a DNS server must not contain `;` (replaced by `,` when updating). Also with the run command (`dns borntoberoot.net "Google Public DNS";cloudflare`). [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) - IP addresses opened from another feature (e.g. **IP Scanner** > **DNS lookup** > **IP address**), from a profile or with the run command are looked up with `PTR` instead of the selected record type. [#3644](https://github.com/BornToBeRoot/NETworkManager/pull/3644) **Hosts File Editor** @@ -246,6 +247,7 @@ Release date: **xx.xx.2026** **DNS Lookup** +- Fixed a DNS server opened with the run command in another case than configured (e.g. `cloudflare` for `Cloudflare`) being looked up as hostname instead of using the configured DNS server. Names of DNS servers are case-insensitive now. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) - Fixed **Add DNS suffix (primary) to hostname** silently appending no suffix at all on machines without a Windows primary DNS suffix configured (e.g. not domain-joined), even though a usable suffix was available via the active network adapter's connection-specific DNS suffix (shown under **DNS Suffix Search List** in `ipconfig /all`). It now falls back to that adapter's suffix in this case. [#3591](https://github.com/BornToBeRoot/NETworkManager/pull/3591) - Fixed columns that couldn't be made wider than the window (by dragging or double-clicking the gripper) in the result and the DNS server profiles in the settings. The grid scrolls horizontally now, the last (or main) column still fills the remaining width. [#3557](https://github.com/BornToBeRoot/NETworkManager/issues/3557) diff --git a/Website/docs/run-command.md b/Website/docs/run-command.md index 9af8ef4a32..bc2348226e 100644 --- a/Website/docs/run-command.md +++ b/Website/docs/run-command.md @@ -52,7 +52,7 @@ All features that accept a target also accept a profile (`@profile`), except the :::note - **Remote Desktop, PowerShell, PuTTY, TigerVNC and Web Console** open the connect dialog with the host, so the connection settings can be adjusted before connecting. A profile connects directly with its settings. -- **DNS lookup:** The type is the DNS record type (`a`, `aaaa`, `any`, `caa`, `cname`, `dnskey`, `mx`, `ns`, `ptr`, `soa`, `srv`, `txt`). Without a type, IP addresses are looked up with `ptr` and other hosts with the type selected in the feature. The DNS server is the name of a configured DNS server (case-insensitive, e.g. `cloudflare`) or custom DNS server(s): an IP address or a host with a domain, with an optional port, separated by `;` (e.g. `1.1.1.1`, `dns.google:53`). +- **DNS lookup:** The type is the DNS record type (`a`, `aaaa`, `any`, `caa`, `cname`, `dnskey`, `mx`, `ns`, `ptr`, `soa`, `srv`, `txt`). Without a type, IP addresses are looked up with `ptr` and other hosts with the type selected in the feature. The DNS server is the name of a configured DNS server (case-insensitive, e.g. `cloudflare`) or a custom DNS server (an IP address or a host with a domain, with an optional port, e.g. `1.1.1.1`, `dns.google:53`), also several separated by `;` (e.g. `"Google Public DNS";cloudflare;9.9.9.9`, the configured DNS servers are suggested after a `;` as well). - **RDAP:** The type is `domain`, `nameserver`, `tld`, `ip`, `asn` or `entity`. Without a type, it is detected from the query (see [RDAP](./application/rdap.md#example-inputs)). - **Subnet Calculator:** A subnet (e.g. `subnet 10.0.0.0/24`) is calculated in **Calculator**. With a new subnet mask or CIDR (e.g. `/26`, `26` or `255.255.255.192`), it is split in **Subnetting**; the new mask must be between the CIDR of the subnet and `/32` (IPv4) or `/128` (IPv6). With a second subnet (e.g. `subnet 10.0.0.0/24 10.0.1.0/24`), the **Wide Subnet** is calculated (IPv4 only). - **Lookup:** The first word after the command is the lookup: `oui` (MAC addresses or vendors, e.g. `lookup oui 00:11:22`) or `port` (ports, port ranges or services, e.g. `lookup port 22; 80-90/tcp; ssh`). The rest of the input is the query, so vendors can contain spaces without quotes (e.g. `lookup oui Cisco Systems`). Without a query (e.g. `lookup port`), the lookup is only shown. From 60459b6d615740c5bd4b29b7af63757532fbc5fb Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:26:27 +0200 Subject: [PATCH 098/146] Feature: DNS Lookup DNS server input with history and Tab completion (E2) The DNS server ComboBox shows the DNS servers (Windows DNS first, then by name, with their servers) and a new history (DNSLookup_DNSServerHistory), grouped like the ports of the Port Scanner (InputSuggestions); Tab completes the name of a DNS server that is being typed (ComboBoxListCompletionBehavior), the input is validated (DNSServersValidator). The SelectedItem binding to a profile is gone - the input is resolved by name (E1). A single profile name is not added to the history, it is already in the list (DNS Lookup and Port Scanner). Co-Authored-By: Claude Opus 5.5 --- .../NETworkManager.Settings/SettingsInfo.cs | 15 +++++++ Source/NETworkManager/InputSuggestions.cs | 16 +++++++ .../ViewModels/DNSLookupViewModel.cs | 43 +++++++------------ .../ViewModels/PortScannerViewModel.cs | 6 ++- .../NETworkManager/Views/DNSLookupView.xaml | 18 +++++--- Website/docs/application/dns-lookup.md | 2 +- Website/docs/changelog/next-release.md | 4 +- 7 files changed, 67 insertions(+), 37 deletions(-) diff --git a/Source/NETworkManager.Settings/SettingsInfo.cs b/Source/NETworkManager.Settings/SettingsInfo.cs index db3738bdcf..a460ab433b 100644 --- a/Source/NETworkManager.Settings/SettingsInfo.cs +++ b/Source/NETworkManager.Settings/SettingsInfo.cs @@ -1985,6 +1985,20 @@ public ObservableCollection DNSLookup_HostHistory } } = []; + public ObservableCollection DNSLookup_DNSServerHistory + { + get; + set + { + if (value == field) + return; + + UpdateCollectionChanged(field, value); + field = value; + OnPropertyChanged(); + } + } = []; + public ObservableCollection DNSLookup_DNSServers { get; @@ -4239,6 +4253,7 @@ public SettingsInfo() // DNS Lookup DNSLookup_HostHistory.CollectionChanged += CollectionChanged; DNSLookup_DNSServers.CollectionChanged += CollectionChanged; + DNSLookup_DNSServerHistory.CollectionChanged += CollectionChanged; // Remote Desktop RemoteDesktop_HostHistory.CollectionChanged += CollectionChanged; diff --git a/Source/NETworkManager/InputSuggestions.cs b/Source/NETworkManager/InputSuggestions.cs index 2fd1d0aa87..c3d3057723 100644 --- a/Source/NETworkManager/InputSuggestions.cs +++ b/Source/NETworkManager/InputSuggestions.cs @@ -80,6 +80,22 @@ public static InputSuggestions ForPortScannerPorts() Strings.PortProfiles, SettingsManager.Current.PortScanner_PortHistory); } + /// + /// Creates the list for the DNS server of DNS Lookup: the DNS servers (Windows DNS first, then sorted by name, + /// with their servers) and the DNS server history. + /// + public static InputSuggestions ForDNSLookupDNSServers() + { + return new InputSuggestions(SettingsManager.Current.DNSLookup_DNSServers, + () => SettingsManager.Current.DNSLookup_DNSServers + .OrderByDescending(x => x.UseWindowsDNSServer) + .ThenBy(x => x.Name, StringComparer.OrdinalIgnoreCase) + .Select(x => (x.Name, x.UseWindowsDNSServer + ? null + : string.Join("; ", x.Servers.Select(server => server.Port == 53 ? server.Server : server.ToString())))), + Strings.DNSServers, SettingsManager.Current.DNSLookup_DNSServerHistory); + } + /// /// Gets the view of the entries, grouped (profiles, history). /// diff --git a/Source/NETworkManager/ViewModels/DNSLookupViewModel.cs b/Source/NETworkManager/ViewModels/DNSLookupViewModel.cs index aab3413878..c094b46a80 100644 --- a/Source/NETworkManager/ViewModels/DNSLookupViewModel.cs +++ b/Source/NETworkManager/ViewModels/DNSLookupViewModel.cs @@ -62,14 +62,21 @@ public string Host /// public ICollectionView HostHistoryView { get; } + private readonly InputSuggestions _dnsServerSuggestions = InputSuggestions.ForDNSLookupDNSServers(); + /// - /// Gets the collection view of DNS servers. + /// Gets the DNS servers and the DNS server history for the DNS server input (grouped). /// - public ICollectionView DNSServers { get; } + public ICollectionView DNSServers => _dnsServerSuggestions.View; /// - /// Gets or sets the selected DNS server. - /// This can either be an ip/host:port or a profile name. + /// Gets the names of the DNS servers for the Tab completion of the DNS server input. + /// + public IEnumerable DNSServerNames => _dnsServerSuggestions.ProfileNames; + + /// + /// Gets or sets the DNS server input: names of DNS servers (profiles) and / or DNS servers (ip/host:port), + /// separated by ";" (see ). /// public string DNSServer { @@ -79,10 +86,6 @@ public string DNSServer if (field == value) return; - // Try finding matching dns server profile by name, otherwise set to null (de-select) - SelectedDNSServer = SettingsManager.Current.DNSLookup_DNSServers - .FirstOrDefault(x => x.Name == value); - if (!_isLoading) SettingsManager.Current.DNSLookup_SelectedDNSServer_v2 = value; @@ -91,19 +94,6 @@ public string DNSServer } } - public DNSServerConnectionInfoProfile SelectedDNSServer - { - get; - set - { - if (field == value) - return; - - field = value; - OnPropertyChanged(); - } - } - /// /// Gets the list of available query types. /// @@ -265,12 +255,6 @@ public DNSLookupViewModel(Guid tabId, string host, string dnsServer = null, Quer HostHistoryView = CollectionViewSource.GetDefaultView(SettingsManager.Current.DNSLookup_HostHistory); - DNSServers = new CollectionViewSource { Source = SettingsManager.Current.DNSLookup_DNSServers }.View; - DNSServers.SortDescriptions.Add(new SortDescription(nameof(DNSServerConnectionInfoProfile.UseWindowsDNSServer), - ListSortDirection.Descending)); - DNSServers.SortDescriptions.Add(new SortDescription(nameof(DNSServerConnectionInfoProfile.Name), - ListSortDirection.Ascending)); - DNSServer = string.IsNullOrEmpty(SettingsManager.Current.DNSLookup_SelectedDNSServer_v2) ? SettingsManager.Current.DNSLookup_DNSServers.FirstOrDefault()?.Name : SettingsManager.Current.DNSLookup_SelectedDNSServer_v2; @@ -392,6 +376,11 @@ private async Task QueryAsync() ListHelper.Modify(SettingsManager.Current.DNSLookup_HostHistory, Host, SettingsManager.Current.General_HistoryListEntries); + // A single DNS server name is already in the list, only other inputs are added to the history + if (Models.Network.DNSServer.FindByName(SettingsManager.Current.DNSLookup_DNSServers, DNSServer) == null) + ListHelper.Modify(SettingsManager.Current.DNSLookup_DNSServerHistory, DNSServer.Trim(), + SettingsManager.Current.General_HistoryListEntries); + DNSLookupSettings dnsSettings = new() { AddDNSSuffix = SettingsManager.Current.DNSLookup_AddDNSSuffix, diff --git a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs index db323f1894..623c43ef9f 100644 --- a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs @@ -844,8 +844,10 @@ private async Task Start() // Add host(s) to the history ListHelper.Modify(SettingsManager.Current.PortScanner_HostHistory, Host, SettingsManager.Current.General_HistoryListEntries); - ListHelper.Modify(SettingsManager.Current.PortScanner_PortHistory, Ports, - 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(); diff --git a/Source/NETworkManager/Views/DNSLookupView.xaml b/Source/NETworkManager/Views/DNSLookupView.xaml index c445837c87..f6bee381af 100644 --- a/Source/NETworkManager/Views/DNSLookupView.xaml +++ b/Source/NETworkManager/Views/DNSLookupView.xaml @@ -69,15 +69,23 @@ - + + + + + + diff --git a/Website/docs/application/dns-lookup.md b/Website/docs/application/dns-lookup.md index 8f19932340..96df2cec23 100644 --- a/Website/docs/application/dns-lookup.md +++ b/Website/docs/application/dns-lookup.md @@ -53,7 +53,7 @@ Example: `server-01.borntoberoot.net; 10.0.0.1` ::: -The DNS server can be selected from a list of configured servers or you can enter a custom DNS server in the format `|:` (`` is optional, to use a custom port with IPv6 enclose the address in square brackets: `[]:53`). Names of configured DNS servers (case-insensitive) and custom DNS servers can be combined with a semicolon (e.g. `Google Public DNS; Cloudflare; 9.9.9.9`), every DNS server is queried once. `[Windows DNS]` together with other DNS servers adds the DNS servers configured in Windows. +The DNS server can be selected from a list of configured servers or you can enter a custom DNS server in the format `|:` (`` is optional, to use a custom port with IPv6 enclose the address in square brackets: `[]:53`). Names of configured DNS servers (case-insensitive) and custom DNS servers can be combined with a semicolon (e.g. `Google Public DNS; Cloudflare; 9.9.9.9`), every DNS server is queried once. `[Windows DNS]` together with other DNS servers adds the DNS servers configured in Windows. The list shows the configured DNS servers (with their IP addresses) and the recently used DNS servers (history, a single configured DNS server is not added). `Tab` completes the name of a configured DNS server that is being typed (e.g. `google; cl` → `google; Cloudflare`), `Tab` again shows the next match. ### Context menu diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index bddd837f12..3daaf39e79 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -100,7 +100,7 @@ Release date: **xx.xx.2026** - 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) - The header of each host shows the number of open ports (e.g. `server (10.0.0.1) · 3 Open`), also while the scan is running. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) -- The **Ports** input lists the port profiles (with their ports) and the history, like the DNS servers in **DNS Lookup**: select a port profile, or type ports and port profile names. Replaces the **Select port profile** button and its dialog. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) +- The **Ports** input lists the port profiles (with their ports) and the history (a single port profile is not added to the history), like the DNS servers in **DNS Lookup**: select a port profile, or type ports and port profile names. Replaces the **Select port profile** button and its dialog. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) - Profiles: the **Ports** of the Port Scanner can contain port profiles (selectable in the list like in the tool), which are referenced by the profile - changes to a port profile apply, also after a rename. The ports are saved with the profile as well and used, with a note, if the port profile can't be found (e.g. a profile file shared from another computer, port profiles are saved in the settings). [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) - Port profiles can be entered by name in the **Ports** input, also several and together with ports (e.g. `Webserver; Database; 8080`, case-insensitive). `Tab` completes the name that is being typed (e.g. `webserver; dat` → `webserver; Database`), also in the profile dialog. The name of a port profile must be unique, must not contain `;` (replaced by `,` when updating) and must not be a port or port range. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) - Added **Add profile...** to the context menu: creates a profile with the host (IP address), the hostname as name and the ports of the shown scan. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) @@ -127,7 +127,7 @@ Release date: **xx.xx.2026** **DNS Lookup** -- Several DNS servers can be entered, also names of configured DNS servers together with custom DNS servers (e.g. `Google Public DNS; Cloudflare; 9.9.9.9`), every DNS server is queried once; `[Windows DNS]` in a list adds the DNS servers of Windows. The name of a DNS server must not contain `;` (replaced by `,` when updating). Also with the run command (`dns borntoberoot.net "Google Public DNS";cloudflare`). [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) +- Several DNS servers can be entered, also names of configured DNS servers together with custom DNS servers (e.g. `Google Public DNS; Cloudflare; 9.9.9.9`), every DNS server is queried once; `[Windows DNS]` in a list adds the DNS servers of Windows. The list of the **DNS server** input shows the configured DNS servers (with their IP addresses) and the history, `Tab` completes the name of a DNS server that is being typed (e.g. `google; cl` → `google; Cloudflare`). The name of a DNS server must not contain `;` (replaced by `,` when updating). Also with the run command (`dns borntoberoot.net "Google Public DNS";cloudflare`). [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) - IP addresses opened from another feature (e.g. **IP Scanner** > **DNS lookup** > **IP address**), from a profile or with the run command are looked up with `PTR` instead of the selected record type. [#3644](https://github.com/BornToBeRoot/NETworkManager/pull/3644) **Hosts File Editor** From b8b8a2f959b923a530fb49d6afc2ad8a5586c159 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:27:09 +0200 Subject: [PATCH 099/146] Feature: DNS Lookup DNS servers with an internal ID (E3) Profiles will reference DNS servers by an ID instead of the name, so a renamed DNS server is still found (E4). New DNS servers (and the default Windows DNS server) get a new Guid, editing keeps it, the update adds an ID to existing DNS servers. The Windows DNS server will be referenced by name (it exists on every computer, with another ID). GlobalAssemblyInfo 2026.10.27.0 -> 2026.10.27.1, so the update runs again on a development machine. Co-Authored-By: Claude Opus 5.5 --- Source/GlobalAssemblyInfo.cs | 4 ++-- Source/NETworkManager.Models/Network/DNSServer.cs | 2 +- .../Network/DNSServerConnectionInfoProfile.cs | 12 ++++++++++-- Source/NETworkManager.Settings/SettingsManager.cs | 7 +++++++ .../ViewModels/DNSLookupSettingsViewModel.cs | 15 +++++++++++---- 5 files changed, 31 insertions(+), 9 deletions(-) diff --git a/Source/GlobalAssemblyInfo.cs b/Source/GlobalAssemblyInfo.cs index 7a6801ffcc..2d5beb97d3 100644 --- a/Source/GlobalAssemblyInfo.cs +++ b/Source/GlobalAssemblyInfo.cs @@ -6,5 +6,5 @@ [assembly: AssemblyTrademark("")] [assembly: AssemblyCulture("")] -[assembly: AssemblyVersion("2026.10.27.0")] -[assembly: AssemblyFileVersion("2026.10.27.0")] +[assembly: AssemblyVersion("2026.10.27.1")] +[assembly: AssemblyFileVersion("2026.10.27.1")] diff --git a/Source/NETworkManager.Models/Network/DNSServer.cs b/Source/NETworkManager.Models/Network/DNSServer.cs index d55a2c564f..e7f6dc3a9f 100644 --- a/Source/NETworkManager.Models/Network/DNSServer.cs +++ b/Source/NETworkManager.Models/Network/DNSServer.cs @@ -144,7 +144,7 @@ public static List GetDefaultList() { return [ - new(), // Windows DNS server + new() { Id = Guid.NewGuid() }, // Windows DNS server new("Cloudflare", [ new("1.1.1.1", 53, TransportProtocol.Udp), diff --git a/Source/NETworkManager.Models/Network/DNSServerConnectionInfoProfile.cs b/Source/NETworkManager.Models/Network/DNSServerConnectionInfoProfile.cs index ccc9209aae..961a9fce2a 100644 --- a/Source/NETworkManager.Models/Network/DNSServerConnectionInfoProfile.cs +++ b/Source/NETworkManager.Models/Network/DNSServerConnectionInfoProfile.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; namespace NETworkManager.Models.Network; @@ -24,9 +25,16 @@ public DNSServerConnectionInfoProfile() /// List of servers as . public DNSServerConnectionInfoProfile(string name, List servers) : base(name, servers) { - + Id = Guid.NewGuid(); } + /// + /// Internal ID of the DNS server profile - profiles reference a DNS server profile by it, so a renamed DNS + /// server profile is still found. Kept when the DNS server profile is edited. The Windows DNS profile is + /// referenced by its name (it exists on every computer, with another ID). + /// + public Guid Id { get; set; } + /// /// Use the DNS server from Windows. /// diff --git a/Source/NETworkManager.Settings/SettingsManager.cs b/Source/NETworkManager.Settings/SettingsManager.cs index db2ace57b7..2dc5a326ff 100644 --- a/Source/NETworkManager.Settings/SettingsManager.cs +++ b/Source/NETworkManager.Settings/SettingsManager.cs @@ -800,6 +800,13 @@ private static void UpgradeToLatest(Version version) dnsServer.Name = dnsServer.Name.Replace(';', ','); } + // DNS Lookup - DNS servers have an internal ID (profiles reference them by it) + foreach (var dnsServer in Current.DNSLookup_DNSServers.Where(x => x.Id == Guid.Empty)) + { + Log.Info($"Add an ID to the DNS server \"{dnsServer.Name}\"..."); + dnsServer.Id = Guid.NewGuid(); + } + // Port Scanner - port profiles have an internal ID (profiles reference them by it) foreach (var portProfile in Current.PortScanner_PortProfiles.Where(x => x.Id == Guid.Empty)) { diff --git a/Source/NETworkManager/ViewModels/DNSLookupSettingsViewModel.cs b/Source/NETworkManager/ViewModels/DNSLookupSettingsViewModel.cs index 61a356dd20..383dc3736b 100644 --- a/Source/NETworkManager/ViewModels/DNSLookupSettingsViewModel.cs +++ b/Source/NETworkManager/ViewModels/DNSLookupSettingsViewModel.cs @@ -370,21 +370,28 @@ public async Task EditDNSServer() { var childWindow = new ServerConnectionInfoProfileChildWindow(); + var dnsServer = SelectedDNSServer; + var childWindowViewModel = new ServerConnectionInfoProfileViewModel(instance => { childWindow.IsOpen = false; ConfigurationManager.Current.IsChildWindowOpen = false; - SettingsManager.Current.DNSLookup_DNSServers.Remove(SelectedDNSServer); - SettingsManager.Current.DNSLookup_DNSServers.Add( - new DNSServerConnectionInfoProfile(instance.Name, [.. instance.Servers])); + // Same ID, profiles that reference the DNS server still find it (also with a new name) + var editedDNSServer = new DNSServerConnectionInfoProfile(instance.Name, [.. instance.Servers]); + + if (dnsServer.Id != Guid.Empty) + editedDNSServer.Id = dnsServer.Id; + + SettingsManager.Current.DNSLookup_DNSServers.Remove(dnsServer); + SettingsManager.Current.DNSLookup_DNSServers.Add(editedDNSServer); }, _ => { childWindow.IsOpen = false; ConfigurationManager.Current.IsChildWindowOpen = false; }, (ServerInfoProfileNames, true, false), - _profileDialogDefaultValues, SelectedDNSServer); + _profileDialogDefaultValues, dnsServer); childWindow.Title = Strings.EditDNSServer; From aecaa3f371c06a9353b026b17ca216b8025d724c Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:28:33 +0200 Subject: [PATCH 100/146] Fix: Profile DNS Lookup "Inherit host" saved with the Traceroute value ParseProfileInfo set DNSLookup_InheritHost from Traceroute_InheritHost (copy & paste). A profile with an own DNS Lookup host and Traceroute inheriting the host was saved with DNS Lookup inheriting too - the next save replaced the DNS Lookup host with the host of the profile. Co-Authored-By: Claude Opus 5.5 --- Source/NETworkManager/ProfileDialogManager.cs | 2 +- Website/docs/changelog/next-release.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Source/NETworkManager/ProfileDialogManager.cs b/Source/NETworkManager/ProfileDialogManager.cs index 772dacd442..e464007194 100644 --- a/Source/NETworkManager/ProfileDialogManager.cs +++ b/Source/NETworkManager/ProfileDialogManager.cs @@ -79,7 +79,7 @@ private static ProfileInfo ParseProfileInfo(ProfileViewModel instance) // DNS Lookup DNSLookup_Enabled = instance.DNSLookup_Enabled, - DNSLookup_InheritHost = instance.Traceroute_InheritHost, + DNSLookup_InheritHost = instance.DNSLookup_InheritHost, DNSLookup_Host = instance.DNSLookup_InheritHost ? instance.Host?.Trim() : instance.DNSLookup_Host?.Trim(), // Remote Desktop diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index 3daaf39e79..45768f0654 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -247,6 +247,7 @@ Release date: **xx.xx.2026** **DNS Lookup** +- Fixed **Inherit host from general** of a profile being saved with the value of the **Traceroute**, so the host of the DNS lookup could be replaced with the host of the profile. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) - Fixed a DNS server opened with the run command in another case than configured (e.g. `cloudflare` for `Cloudflare`) being looked up as hostname instead of using the configured DNS server. Names of DNS servers are case-insensitive now. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) - Fixed **Add DNS suffix (primary) to hostname** silently appending no suffix at all on machines without a Windows primary DNS suffix configured (e.g. not domain-joined), even though a usable suffix was available via the active network adapter's connection-specific DNS suffix (shown under **DNS Suffix Search List** in `ipconfig /all`). It now falls back to that adapter's suffix in this case. [#3591](https://github.com/BornToBeRoot/NETworkManager/pull/3591) - Fixed columns that couldn't be made wider than the window (by dragging or double-clicking the gripper) in the result and the DNS server profiles in the settings. The grid scrolls horizontally now, the last (or main) column still fills the remaining width. [#3557](https://github.com/BornToBeRoot/NETworkManager/issues/3557) From 4ff123ecb67919e59fcf91b5ff39f4619be0607e Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:30:35 +0200 Subject: [PATCH 101/146] Feature: Profiles can override the DNS server of DNS Lookup (E4) New profile option "DNS server" (DNS Lookup tab, checkbox + the same ComboBox as the tool: DNS servers + history, Tab completion, validation; tab error indicator / OK disabled on an invalid input). Saved like the ports of the Port Scanner (D4): DNSLookup_DNSServerInput = the input with DNS servers by name as references to their ID ("{guid}; 9.9.9.9", DNSServer.EncodeReferences; [Windows DNS] by name, its ID differs per computer), null without a DNS server by name; DNSLookup_DNSServer = the input with the servers of the DNS servers (DNSServer.ReplaceProfilesByServers). Opened / looked up: the references with the current names; if a DNS server can't be found (deleted, or a profile file shared from another computer) the saved servers, and the tab shows a note (also with the first lookup). Unit tests for encode / decode / replace. Co-Authored-By: Claude Opus 5.5 --- .../Resources/Strings.Designer.cs | 9 ++ .../Resources/Strings.resx | 3 + .../Network/DNSServer.cs | 99 +++++++++++++++++++ Source/NETworkManager.Profiles/ProfileInfo.cs | 23 +++++ .../Network/DNSServerTests.cs | 34 +++++++ Source/NETworkManager/ProfileDialogManager.cs | 7 ++ .../ViewModels/DNSLookupHostViewModel.cs | 30 +++++- .../ViewModels/DNSLookupViewModel.cs | 23 ++++- .../ViewModels/ProfileViewModel.cs | 46 +++++++++ .../Views/DNSLookupView.xaml.cs | 4 +- .../Views/ProfileChildWindow.xaml | 59 +++++++++++ Website/docs/application/dns-lookup.md | 19 ++++ Website/docs/changelog/next-release.md | 1 + 13 files changed, 351 insertions(+), 6 deletions(-) diff --git a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs index 1ee1dcf93d..b2d9f945a6 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs +++ b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs @@ -3649,6 +3649,15 @@ public static string DNSServer { } } + /// + /// Looks up a localized string similar to A DNS server of this profile was not found, the DNS servers saved with the profile are used. DNS servers are saved in the settings - if the profile file was shared from another computer, create the DNS server here or edit the profile.. + /// + public static string DNSServerOfProfileNotFoundMessage { + get { + return ResourceManager.GetString("DNSServerOfProfileNotFoundMessage", resourceCulture); + } + } + /// /// Looks up a localized string similar to DNS server(s). /// diff --git a/Source/NETworkManager.Localization/Resources/Strings.resx b/Source/NETworkManager.Localization/Resources/Strings.resx index ec25663be6..b4b95f2682 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.resx +++ b/Source/NETworkManager.Localization/Resources/Strings.resx @@ -5122,4 +5122,7 @@ Do you want to start the scan? The name must not contain ";". + + A DNS server of this profile was not found, the DNS servers saved with the profile are used. DNS servers are saved in the settings - if the profile file was shared from another computer, create the DNS server here or edit the profile. + \ No newline at end of file diff --git a/Source/NETworkManager.Models/Network/DNSServer.cs b/Source/NETworkManager.Models/Network/DNSServer.cs index e7f6dc3a9f..495a85bcf9 100644 --- a/Source/NETworkManager.Models/Network/DNSServer.cs +++ b/Source/NETworkManager.Models/Network/DNSServer.cs @@ -127,6 +127,105 @@ public static List ResolveServers(string input, return [.. servers.DistinctBy(x => (x.Server.ToLowerInvariant(), x.Port, x.TransportProtocol))]; } + /// + /// Replaces the names of DNS server profiles in a DNS server input by references to their ID (e.g. "Cloudflare; + /// 9.9.9.9" -> "{8f1c...}; 9.9.9.9"), so a profile still finds the DNS server profile after it was renamed. The + /// Windows DNS profile is kept by name (it exists on every computer, with another ID), other entries are kept. + /// + /// DNS server input (see ). + /// DNS server profiles. + /// Input with references, null if it contains no DNS server profile with an ID. + public static string EncodeReferences(string input, IEnumerable profiles) + { + if (string.IsNullOrWhiteSpace(input)) + return null; + + var profileList = profiles.ToList(); + var hasReference = false; + List entries = []; + + foreach (var entry in input.Split(EntrySeparator)) + { + var profile = FindByName(profileList, entry); + + if (profile == null || profile.UseWindowsDNSServer || profile.Id == Guid.Empty) + { + entries.Add(entry.Trim()); + continue; + } + + entries.Add(profile.Id.ToString("B")); + hasReference = true; + } + + return hasReference ? string.Join("; ", entries) : null; + } + + /// + /// Replaces the references in a DNS server input (see ) by the current names of + /// the DNS server profiles (e.g. "{8f1c...}; 9.9.9.9" -> "Cloudflare; 9.9.9.9"). + /// + /// DNS server input with references. + /// DNS server profiles. + /// DNS server input with the names, null if a DNS server profile was not found. + /// True if every referenced DNS server profile was found. + public static bool TryDecodeReferences(string inputWithReferences, + IEnumerable profiles, out string input) + { + input = null; + + if (string.IsNullOrWhiteSpace(inputWithReferences)) + return false; + + var profileList = profiles.ToList(); + List entries = []; + + foreach (var entry in inputWithReferences.Split(EntrySeparator)) + { + if (!Guid.TryParseExact(entry.Trim(), "B", out var id)) + { + entries.Add(entry.Trim()); + continue; + } + + var profile = profileList.FirstOrDefault(x => x.Id == id); + + if (profile == null) + return false; + + entries.Add(profile.Name); + } + + input = string.Join("; ", entries); + + return true; + } + + /// + /// Replaces the names of DNS server profiles in a DNS server input by their servers (e.g. "Cloudflare; 9.9.9.9" + /// -> "1.1.1.1:53; 1.0.0.1:53; 9.9.9.9"), so the input still works without the DNS server profiles (e.g. on another + /// computer). The Windows DNS profile and other entries are kept. + /// + /// DNS server input. + /// DNS server profiles. + /// Input with servers instead of DNS server profiles. + public static string ReplaceProfilesByServers(string input, IEnumerable profiles) + { + if (string.IsNullOrWhiteSpace(input)) + return input; + + var profileList = profiles.ToList(); + + return string.Join("; ", input.Split(EntrySeparator).Select(entry => + { + var profile = FindByName(profileList, entry); + + return profile == null || profile.UseWindowsDNSServer + ? entry.Trim() + : string.Join("; ", profile.Servers.Select(x => x.ToString())); + })); + } + /// /// Parses a DNS server entry (IP address or hostname, optional port). /// diff --git a/Source/NETworkManager.Profiles/ProfileInfo.cs b/Source/NETworkManager.Profiles/ProfileInfo.cs index babd4bc8d0..ade5fa6376 100644 --- a/Source/NETworkManager.Profiles/ProfileInfo.cs +++ b/Source/NETworkManager.Profiles/ProfileInfo.cs @@ -79,6 +79,9 @@ public ProfileInfo(ProfileInfo profile) DNSLookup_Enabled = profile.DNSLookup_Enabled; DNSLookup_InheritHost = profile.DNSLookup_InheritHost; DNSLookup_Host = profile.DNSLookup_Host; + DNSLookup_OverrideDNSServer = profile.DNSLookup_OverrideDNSServer; + DNSLookup_DNSServer = profile.DNSLookup_DNSServer; + DNSLookup_DNSServerInput = profile.DNSLookup_DNSServerInput; // Remote Desktop RemoteDesktop_Enabled = profile.RemoteDesktop_Enabled; @@ -338,6 +341,26 @@ public ProfileInfo(ProfileInfo profile) public bool DNSLookup_InheritHost { get; set; } = true; public string DNSLookup_Host { get; set; } + /// + /// Use the DNS server of the profile instead of the DNS server selected in DNS Lookup. + /// + public bool DNSLookup_OverrideDNSServer { get; set; } + + /// + /// DNS server of the profile (names of DNS servers and / or DNS servers). If DNS servers were entered by name, + /// their servers at the time the profile was saved - used if a DNS server of + /// can't be found (e.g. a profile file from another computer, DNS servers + /// are saved in the settings). + /// + public string DNSLookup_DNSServer { get; set; } + + /// + /// DNS server as entered, if it contains DNS servers by name: they are referenced by their ID (e.g. "{8f1c...}; + /// 9.9.9.9", see DNSServer.EncodeReferences), so they are also found after a rename. Null if no DNS + /// server was entered by name. + /// + public string DNSLookup_DNSServerInput { get; set; } + public bool RemoteDesktop_Enabled { get; set; } public bool RemoteDesktop_InheritHost { get; set; } = true; public string RemoteDesktop_Host { get; set; } diff --git a/Source/NETworkManager.Tests/Network/DNSServerTests.cs b/Source/NETworkManager.Tests/Network/DNSServerTests.cs index 0093301b21..054722ce6b 100644 --- a/Source/NETworkManager.Tests/Network/DNSServerTests.cs +++ b/Source/NETworkManager.Tests/Network/DNSServerTests.cs @@ -43,6 +43,40 @@ public void ResolveServers_InvalidEntriesAreSkipped() Assert.Equal(["not valid!"], invalidEntries); } + [Fact] + public void EncodeAndDecodeReferences() + { + var cloudflare = Profiles.First(x => x.Name == "Cloudflare"); + + var input = DNSServer.EncodeReferences("[Windows DNS]; cloudflare; 9.9.9.9", Profiles); + + Assert.Equal($"[Windows DNS]; {cloudflare.Id:B}; 9.9.9.9", input); + Assert.True(DNSServer.TryDecodeReferences(input, Profiles, out var decoded)); + Assert.Equal("[Windows DNS]; Cloudflare; 9.9.9.9", decoded); + } + + [Theory] + [InlineData("9.9.9.9")] + [InlineData("[Windows DNS]")] + public void EncodeReferences_NullWithoutProfileReference(string input) + { + Assert.Null(DNSServer.EncodeReferences(input, Profiles)); + } + + [Fact] + public void DecodeReferences_FalseIfProfileIsMissing() + { + Assert.False(DNSServer.TryDecodeReferences($"{System.Guid.NewGuid():B}", Profiles, out var decoded)); + Assert.Null(decoded); + } + + [Fact] + public void ReplaceProfilesByServers() + { + Assert.Equal("[Windows DNS]; 1.1.1.1:53; 1.0.0.1:53; 9.9.9.9", + DNSServer.ReplaceProfilesByServers("[Windows DNS]; Cloudflare; 9.9.9.9", Profiles)); + } + [Theory] [InlineData("Cloudflare", false, 0)] [InlineData("google public dns;9.9.9.9", true, 0)] diff --git a/Source/NETworkManager/ProfileDialogManager.cs b/Source/NETworkManager/ProfileDialogManager.cs index e464007194..2c62478873 100644 --- a/Source/NETworkManager/ProfileDialogManager.cs +++ b/Source/NETworkManager/ProfileDialogManager.cs @@ -81,6 +81,13 @@ private static ProfileInfo ParseProfileInfo(ProfileViewModel instance) DNSLookup_Enabled = instance.DNSLookup_Enabled, DNSLookup_InheritHost = instance.DNSLookup_InheritHost, DNSLookup_Host = instance.DNSLookup_InheritHost ? instance.Host?.Trim() : instance.DNSLookup_Host?.Trim(), + DNSLookup_OverrideDNSServer = instance.DNSLookup_OverrideDNSServer, + // DNS servers by name are referenced by their ID (found after a rename), their servers are saved as well + // (used if a DNS server can't be found, e.g. a profile file from another computer) + DNSLookup_DNSServer = DNSServer.ReplaceProfilesByServers(instance.DNSLookup_DNSServer?.Trim(), + SettingsManager.Current.DNSLookup_DNSServers), + DNSLookup_DNSServerInput = DNSServer.EncodeReferences(instance.DNSLookup_DNSServer, + SettingsManager.Current.DNSLookup_DNSServers), // Remote Desktop RemoteDesktop_Enabled = instance.RemoteDesktop_Enabled, diff --git a/Source/NETworkManager/ViewModels/DNSLookupHostViewModel.cs b/Source/NETworkManager/ViewModels/DNSLookupHostViewModel.cs index 1d95cb4978..8ed0d47689 100644 --- a/Source/NETworkManager/ViewModels/DNSLookupHostViewModel.cs +++ b/Source/NETworkManager/ViewModels/DNSLookupHostViewModel.cs @@ -3,7 +3,9 @@ using NETworkManager.Controls; using NETworkManager.Localization.Resources; using NETworkManager.Models; +using NETworkManager.Models.Network; using NETworkManager.Profiles; +using NETworkManager.Settings; using NETworkManager.Utilities; using NETworkManager.Views; using System; @@ -158,7 +160,8 @@ private static void CloseItemAction(ItemActionCallbackArgs args) /// hosts. /// /// Starts it on the first load. If false, the input is only filled in. - public void AddTab(string host = null, string dnsServer = null, QueryType? queryType = null, bool start = true) + public void AddTab(string host = null, string dnsServer = null, QueryType? queryType = null, bool start = true, + string message = null) { if (queryType == null && IsIPAddresses(host)) queryType = QueryType.PTR; @@ -166,7 +169,7 @@ public void AddTab(string host = null, string dnsServer = null, QueryType? query var tabId = Guid.NewGuid(); TabItems.Add(new DragablzTabItem(host ?? Strings.NewTab, - new DNSLookupView(tabId, host, dnsServer, queryType, start), tabId)); + new DNSLookupView(tabId, host, dnsServer, queryType, start, message), tabId)); SelectedTabIndex = TabItems.Count - 1; } @@ -189,7 +192,28 @@ private static bool IsIPAddresses(string hosts) /// Starts it on the first load. If false, the input is only filled in. public void AddTab(ProfileInfo profile, bool start = true) { - AddTab(profile.DNSLookup_Host, start: start); + // DNS server of DNS Lookup (last selected) + if (!profile.DNSLookup_OverrideDNSServer || string.IsNullOrWhiteSpace(profile.DNSLookup_DNSServer)) + { + AddTab(profile.DNSLookup_Host, start: start); + return; + } + + // DNS servers by name with their current name (also after a rename) + if (!string.IsNullOrEmpty(profile.DNSLookup_DNSServerInput) && + DNSServer.TryDecodeReferences(profile.DNSLookup_DNSServerInput, + SettingsManager.Current.DNSLookup_DNSServers, out var dnsServer)) + { + AddTab(profile.DNSLookup_Host, dnsServer, start: start); + return; + } + + // No DNS server by name, or one can't be found (deleted, or a profile file shared from another computer - DNS + // servers are saved in the settings): the servers saved with the profile, with a note if one wasn't found + AddTab(profile.DNSLookup_Host, profile.DNSLookup_DNSServer, start: start, + message: string.IsNullOrEmpty(profile.DNSLookup_DNSServerInput) + ? null + : Strings.DNSServerOfProfileNotFoundMessage); } #endregion diff --git a/Source/NETworkManager/ViewModels/DNSLookupViewModel.cs b/Source/NETworkManager/ViewModels/DNSLookupViewModel.cs index c094b46a80..26499769c3 100644 --- a/Source/NETworkManager/ViewModels/DNSLookupViewModel.cs +++ b/Source/NETworkManager/ViewModels/DNSLookupViewModel.cs @@ -41,6 +41,9 @@ public class DNSLookupViewModel : ViewModelBase /// private readonly bool _isLoading; + // Message of the tab, shown until the first lookup has started (see constructor) + private string _message; + /// /// Gets or sets the host to lookup. /// @@ -241,11 +244,21 @@ private set /// DNS server(s) to use (not saved as default). If null, the last selected one is used. /// Query type (not saved as default). If null, the last selected one is used. /// Starts it on the first load. If false, the input is only filled in. + /// + /// Message shown below the input, also with the first lookup (e.g. a DNS server of the profile was not found). + /// public DNSLookupViewModel(Guid tabId, string host, string dnsServer = null, QueryType? queryType = null, - bool start = true) + bool start = true, string message = null) { _firstLoad = start; + if (!string.IsNullOrEmpty(message)) + { + _message = message; + + AppendStatusMessage(message); + } + _isLoading = true; ConfigurationManager.Current.DNSLookupTabCount++; @@ -367,6 +380,14 @@ private async Task QueryAsync() IsStatusMessageDisplayed = false; StatusMessage = string.Empty; + // Message of the tab (see constructor): still shown with the first lookup + if (_message != null) + { + AppendStatusMessage(_message); + + _message = null; + } + IsRunning = true; Results.Clear(); diff --git a/Source/NETworkManager/ViewModels/ProfileViewModel.cs b/Source/NETworkManager/ViewModels/ProfileViewModel.cs index 7c36f91f88..ca4c44aced 100644 --- a/Source/NETworkManager/ViewModels/ProfileViewModel.cs +++ b/Source/NETworkManager/ViewModels/ProfileViewModel.cs @@ -116,6 +116,14 @@ public ProfileViewModel(Action saveCommand, Action + /// Gets the DNS servers and the DNS server history of DNS Lookup for the DNS server input (grouped). + /// + public ICollectionView DNSLookup_DNSServerSuggestions => _dnsLookupDNSServerSuggestions.View; + + /// + /// Gets the names of the DNS servers for the Tab completion of the DNS server input of DNS Lookup. + /// + public IEnumerable DNSLookup_DNSServerNames => _dnsLookupDNSServerSuggestions.ProfileNames; + #endregion #region Remote Desktop diff --git a/Source/NETworkManager/Views/DNSLookupView.xaml.cs b/Source/NETworkManager/Views/DNSLookupView.xaml.cs index 335981648f..4f31313c6e 100644 --- a/Source/NETworkManager/Views/DNSLookupView.xaml.cs +++ b/Source/NETworkManager/Views/DNSLookupView.xaml.cs @@ -12,11 +12,11 @@ public partial class DNSLookupView : IDragablzTabItem private readonly DNSLookupViewModel _viewModel; public DNSLookupView(Guid tabId, string host = null, string dnsServer = null, QueryType? queryType = null, - bool start = true) + bool start = true, string message = null) { InitializeComponent(); - _viewModel = new DNSLookupViewModel(tabId, host, dnsServer, queryType, start); + _viewModel = new DNSLookupViewModel(tabId, host, dnsServer, queryType, start, message); DataContext = _viewModel; diff --git a/Source/NETworkManager/Views/ProfileChildWindow.xaml b/Source/NETworkManager/Views/ProfileChildWindow.xaml index c7080e8b96..23259d05e7 100644 --- a/Source/NETworkManager/Views/ProfileChildWindow.xaml +++ b/Source/NETworkManager/Views/ProfileChildWindow.xaml @@ -388,6 +388,25 @@ + + + + + + + + + + + @@ -1878,6 +1897,8 @@ + + @@ -1921,6 +1942,32 @@ + + + + + + + + + + + + + + + @@ -4378,6 +4425,18 @@ + + + + + + + + + + diff --git a/Website/docs/application/dns-lookup.md b/Website/docs/application/dns-lookup.md index 96df2cec23..fe44b20c7d 100644 --- a/Website/docs/application/dns-lookup.md +++ b/Website/docs/application/dns-lookup.md @@ -91,6 +91,25 @@ Hostname or IP address (or any other resource record) to query. - `server-01.borntoberoot.net` (`A` record) - `1.1.1.1` (`PTR` record) +### DNS server + +Use this DNS server instead of the DNS server selected in the DNS lookup: names of [DNS servers](#dns-server-1) and / or custom DNS servers, separated by `;` (selectable in the list, `Tab` completes a name). + +**Type:** `String` + +**Default:** `Disabled` / `Empty` + +**Example:** + +- `Cloudflare` +- `Google Public DNS; 9.9.9.9` + +:::note + +A configured DNS server is referenced by the profile, so changes to it apply to the profile, also if it is renamed. Its IP addresses are saved with the profile as well: if the DNS server can't be found (it was deleted, or the profile file is shared from another computer - DNS servers are saved in the settings of each computer), the saved IP addresses are used and a note is shown. `[Windows DNS]` always uses the DNS servers of the computer. + +::: + ## Settings ### DNS server diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index 45768f0654..234520daf8 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -127,6 +127,7 @@ Release date: **xx.xx.2026** **DNS Lookup** +- Profiles: new option **DNS server** to use a DNS server of the profile instead of the one selected in the DNS lookup, with the same list and `Tab` completion. Configured DNS servers are referenced by the profile (changes apply, also after a rename), their IP addresses are saved as well and used, with a note, if the DNS server can't be found (e.g. a profile file shared from another computer). [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) - Several DNS servers can be entered, also names of configured DNS servers together with custom DNS servers (e.g. `Google Public DNS; Cloudflare; 9.9.9.9`), every DNS server is queried once; `[Windows DNS]` in a list adds the DNS servers of Windows. The list of the **DNS server** input shows the configured DNS servers (with their IP addresses) and the history, `Tab` completes the name of a DNS server that is being typed (e.g. `google; cl` → `google; Cloudflare`). The name of a DNS server must not contain `;` (replaced by `,` when updating). Also with the run command (`dns borntoberoot.net "Google Public DNS";cloudflare`). [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) - IP addresses opened from another feature (e.g. **IP Scanner** > **DNS lookup** > **IP address**), from a profile or with the run command are looked up with `PTR` instead of the selected record type. [#3644](https://github.com/BornToBeRoot/NETworkManager/pull/3644) From 8f26cb538254b304fd4f0fb07de1934c276d2dc6 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:31:22 +0200 Subject: [PATCH 102/146] Test: Run command with several DNS servers by name (E1) Parser tests for "dns host "Google Public DNS";cloudflare;9.9.9.9 aaaa" (with / without spaces around ";", type before or after) and for completing the entry after the last ";" of a DNS server list. Co-Authored-By: Claude Opus 5.5 --- .../RunCommand/RunCommandParserTests.cs | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/Source/NETworkManager.Tests/RunCommand/RunCommandParserTests.cs b/Source/NETworkManager.Tests/RunCommand/RunCommandParserTests.cs index a7edbd7f90..a636e37b29 100644 --- a/Source/NETworkManager.Tests/RunCommand/RunCommandParserTests.cs +++ b/Source/NETworkManager.Tests/RunCommand/RunCommandParserTests.cs @@ -267,6 +267,33 @@ public void ListWithAQuotedEntryAndType() Assert.Equal("aaaa", result.Type); } + [Theory] + [InlineData("dns borntoberoot.net \"Google Public DNS\";cloudflare;9.9.9.9 aaaa")] + [InlineData("dns borntoberoot.net \"Google Public DNS\"; cloudflare; 9.9.9.9 aaaa")] + [InlineData("dns borntoberoot.net aaaa \"Google Public DNS\" ; cloudflare;9.9.9.9")] + public void DNSServerListWithNamesAndType(string input) + { + // Several DNS servers: names (one in quotes) and an IP address, the type before or after them + var result = Parse(input); + + Assert.Equal("borntoberoot.net", result.Target); + Assert.Equal("Google Public DNS;cloudflare;9.9.9.9", result.Argument); + Assert.Equal("aaaa", result.Type); + Assert.Equal(RunCommandParseError.None, result.Error); + } + + [Fact] + public void DNSServerListThatIsBeingTyped() + { + // Completion of the entry after the last ";", the entries before as typed + var result = Parse("dns borntoberoot.net \"Google Public DNS\";clo"); + + Assert.Equal(RunCommandPosition.Argument, result.Position); + Assert.Equal("Google Public DNS;clo", result.CurrentWord); + Assert.Equal("\"Google Public DNS\";clo", result.CurrentToken); + Assert.Equal("dns borntoberoot.net ", result.InputBeforeCurrentWord); + } + [Fact] public void ListInAnOpenQuote() { From 16939237d48b34101069d364fb687c9ffb75ce9f Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:38:58 +0200 Subject: [PATCH 103/146] Improvement: History first and profiles in two lines in the ports / DNS server input The drop down of the ports (Port Scanner, profile) and DNS server (DNS Lookup, profile) input looks like the suggestions of the run command: group headers in Gray3 with more space, a profile in two lines (name, then its ports / IP addresses in gray, trimmed with a tooltip), the items as wide as the ComboBox (the drop down grew with a long description). The history comes first: few entries (at most the history size, custom inputs only - a single profile name isn't added), visible without scrolling below 12+ profiles, and the ComboBox's own autocomplete prefers what was used last. Co-Authored-By: Claude Opus 5.5 --- Source/NETworkManager/InputSuggestions.cs | 15 +++++++++------ .../Resources/Styles/ComboBoxStyles.xaml | 18 ++++++++++++------ Website/docs/application/dns-lookup.md | 2 +- Website/docs/application/port-scanner.md | 2 +- Website/docs/changelog/next-release.md | 4 ++-- 5 files changed, 25 insertions(+), 16 deletions(-) diff --git a/Source/NETworkManager/InputSuggestions.cs b/Source/NETworkManager/InputSuggestions.cs index c3d3057723..630ec51630 100644 --- a/Source/NETworkManager/InputSuggestions.cs +++ b/Source/NETworkManager/InputSuggestions.cs @@ -31,8 +31,9 @@ public override string ToString() } /// -/// Profiles and history for an input (an editable ComboBox like the ports of the Port Scanner or the DNS server of -/// DNS Lookup): the profiles and the history, grouped, and the names of the profiles for the Tab completion. +/// History and profiles for an input (an editable ComboBox like the ports of the Port Scanner or the DNS server of +/// DNS Lookup): the history first (recently used, at most the number of history entries, a single profile name is +/// not added), then the profiles, grouped, and the names of the profiles for the Tab completion. /// Updated when the profiles or the history change (weak events, the list doesn't keep a closed tab or dialog /// alive). /// @@ -97,7 +98,7 @@ public static InputSuggestions ForDNSLookupDNSServers() } /// - /// Gets the view of the entries, grouped (profiles, history). + /// Gets the view of the entries, grouped (history, profiles). /// public ICollectionView View { get; } @@ -118,8 +119,10 @@ private void Refresh() ProfileNames.ReplaceAll(profiles.Select(x => x.Name)); - _suggestions.ReplaceAll(profiles - .Select(x => new InputSuggestion(x.Name, x.Description, _profilesGroup)) - .Concat(_history.Select(x => new InputSuggestion(x, null, Strings.History)))); + // History first: the recently used inputs (few, usually custom ones) are visible without scrolling below the + // profiles; the groups are shown in the order of their first entry + _suggestions.ReplaceAll(_history + .Select(x => new InputSuggestion(x, null, Strings.History)) + .Concat(profiles.Select(x => new InputSuggestion(x.Name, x.Description, _profilesGroup)))); } } diff --git a/Source/NETworkManager/Resources/Styles/ComboBoxStyles.xaml b/Source/NETworkManager/Resources/Styles/ComboBoxStyles.xaml index c5b2e6ff03..568f649f88 100644 --- a/Source/NETworkManager/Resources/Styles/ComboBoxStyles.xaml +++ b/Source/NETworkManager/Resources/Styles/ComboBoxStyles.xaml @@ -24,21 +24,27 @@ - + + Foreground="{DynamicResource MahApps.Brushes.Gray3}" + Margin="5,10,0,5" /> - - + + + + diff --git a/Website/docs/application/dns-lookup.md b/Website/docs/application/dns-lookup.md index fe44b20c7d..34513cb955 100644 --- a/Website/docs/application/dns-lookup.md +++ b/Website/docs/application/dns-lookup.md @@ -53,7 +53,7 @@ Example: `server-01.borntoberoot.net; 10.0.0.1` ::: -The DNS server can be selected from a list of configured servers or you can enter a custom DNS server in the format `|:` (`` is optional, to use a custom port with IPv6 enclose the address in square brackets: `[]:53`). Names of configured DNS servers (case-insensitive) and custom DNS servers can be combined with a semicolon (e.g. `Google Public DNS; Cloudflare; 9.9.9.9`), every DNS server is queried once. `[Windows DNS]` together with other DNS servers adds the DNS servers configured in Windows. The list shows the configured DNS servers (with their IP addresses) and the recently used DNS servers (history, a single configured DNS server is not added). `Tab` completes the name of a configured DNS server that is being typed (e.g. `google; cl` → `google; Cloudflare`), `Tab` again shows the next match. +The DNS server can be selected from a list of configured servers or you can enter a custom DNS server in the format `|:` (`` is optional, to use a custom port with IPv6 enclose the address in square brackets: `[]:53`). Names of configured DNS servers (case-insensitive) and custom DNS servers can be combined with a semicolon (e.g. `Google Public DNS; Cloudflare; 9.9.9.9`), every DNS server is queried once. `[Windows DNS]` together with other DNS servers adds the DNS servers configured in Windows. The list shows the recently used DNS servers (history, a single configured DNS server is not added) and the configured DNS servers (name, with the IP addresses below). `Tab` completes the name of a configured DNS server that is being typed (e.g. `google; cl` → `google; Cloudflare`), `Tab` again shows the next match. ### Context menu diff --git a/Website/docs/application/port-scanner.md b/Website/docs/application/port-scanner.md index 51cf13b848..3cc6929e23 100644 --- a/Website/docs/application/port-scanner.md +++ b/Website/docs/application/port-scanner.md @@ -59,7 +59,7 @@ A scan of more than `65,536` ports (hosts × ports, e.g. 64 hosts with `1-1024`) ### Ports -The **Ports** input lists the [port profiles](#port-profiles) (with their ports) and the recently scanned ports (history). Select an entry, or type ports, port ranges and names of port profiles separated by `;` (e.g. `Webserver; Database; 8080`). Port profiles are replaced by their ports when the scan starts. `Tab` completes the name of a port profile that is being typed (e.g. `webserver; dat` → `webserver; Database`), `Tab` again shows the next matching port profile. +The **Ports** input lists the recently scanned ports (history) and the [port profiles](#port-profiles) (name, with the ports below). Select an entry, or type ports, port ranges and names of port profiles separated by `;` (e.g. `Webserver; Database; 8080`). Port profiles are replaced by their ports when the scan starts. `Tab` completes the name of a port profile that is being typed (e.g. `webserver; dat` → `webserver; Database`), `Tab` again shows the next matching port profile. ### Context menu diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index 234520daf8..1b4aad273b 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -100,7 +100,7 @@ Release date: **xx.xx.2026** - 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) - The header of each host shows the number of open ports (e.g. `server (10.0.0.1) · 3 Open`), also while the scan is running. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) -- The **Ports** input lists the port profiles (with their ports) and the history (a single port profile is not added to the history), like the DNS servers in **DNS Lookup**: select a port profile, or type ports and port profile names. Replaces the **Select port profile** button and its dialog. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) +- The **Ports** input lists the history (a single port profile is not added to it) and the port profiles (name, with the ports below), like the DNS servers in **DNS Lookup**: select a port profile, or type ports and port profile names. Replaces the **Select port profile** button and its dialog. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) - Profiles: the **Ports** of the Port Scanner can contain port profiles (selectable in the list like in the tool), which are referenced by the profile - changes to a port profile apply, also after a rename. The ports are saved with the profile as well and used, with a note, if the port profile can't be found (e.g. a profile file shared from another computer, port profiles are saved in the settings). [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) - Port profiles can be entered by name in the **Ports** input, also several and together with ports (e.g. `Webserver; Database; 8080`, case-insensitive). `Tab` completes the name that is being typed (e.g. `webserver; dat` → `webserver; Database`), also in the profile dialog. The name of a port profile must be unique, must not contain `;` (replaced by `,` when updating) and must not be a port or port range. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) - Added **Add profile...** to the context menu: creates a profile with the host (IP address), the hostname as name and the ports of the shown scan. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) @@ -128,7 +128,7 @@ Release date: **xx.xx.2026** **DNS Lookup** - Profiles: new option **DNS server** to use a DNS server of the profile instead of the one selected in the DNS lookup, with the same list and `Tab` completion. Configured DNS servers are referenced by the profile (changes apply, also after a rename), their IP addresses are saved as well and used, with a note, if the DNS server can't be found (e.g. a profile file shared from another computer). [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) -- Several DNS servers can be entered, also names of configured DNS servers together with custom DNS servers (e.g. `Google Public DNS; Cloudflare; 9.9.9.9`), every DNS server is queried once; `[Windows DNS]` in a list adds the DNS servers of Windows. The list of the **DNS server** input shows the configured DNS servers (with their IP addresses) and the history, `Tab` completes the name of a DNS server that is being typed (e.g. `google; cl` → `google; Cloudflare`). The name of a DNS server must not contain `;` (replaced by `,` when updating). Also with the run command (`dns borntoberoot.net "Google Public DNS";cloudflare`). [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) +- Several DNS servers can be entered, also names of configured DNS servers together with custom DNS servers (e.g. `Google Public DNS; Cloudflare; 9.9.9.9`), every DNS server is queried once; `[Windows DNS]` in a list adds the DNS servers of Windows. The list of the **DNS server** input shows the history and the configured DNS servers (name, with the IP addresses below), `Tab` completes the name of a DNS server that is being typed (e.g. `google; cl` → `google; Cloudflare`). The name of a DNS server must not contain `;` (replaced by `,` when updating). Also with the run command (`dns borntoberoot.net "Google Public DNS";cloudflare`). [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) - IP addresses opened from another feature (e.g. **IP Scanner** > **DNS lookup** > **IP address**), from a profile or with the run command are looked up with `PTR` instead of the selected record type. [#3644](https://github.com/BornToBeRoot/NETworkManager/pull/3644) **Hosts File Editor** From 0adf83b6c0fe47f09e2f2c0619d903c5af6fb16d Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:48:35 +0200 Subject: [PATCH 104/146] Chore: GitHub issue forms and simplified pull request template Bug report, feature request and question are issue forms now (affected feature, version, installation, architecture, Windows version, log) with contact links to the docs, discussions, Transifex and security advisories. Blank issues are disabled. The pull request template drops the Copilot summary and gets a type of change, a test section and the repo checklist. Co-Authored-By: Claude Opus 5.5 --- .claude/skills/feature-review/SKILL.md | 4 +- .github/ISSUE_TEMPLATE/Bug_report.md | 28 ---- .github/ISSUE_TEMPLATE/Feature_request.md | 7 - .github/ISSUE_TEMPLATE/Question.md | 5 - .github/ISSUE_TEMPLATE/bug_report.yml | 149 +++++++++++++++++++++ .github/ISSUE_TEMPLATE/config.yml | 14 ++ .github/ISSUE_TEMPLATE/feature_request.yml | 90 +++++++++++++ .github/ISSUE_TEMPLATE/question.yml | 29 ++++ .github/pull_request_template.md | 55 +++++--- README.md | 4 +- Website/docs/changelog/next-release.md | 1 + 11 files changed, 324 insertions(+), 62 deletions(-) delete mode 100644 .github/ISSUE_TEMPLATE/Bug_report.md delete mode 100644 .github/ISSUE_TEMPLATE/Feature_request.md delete mode 100644 .github/ISSUE_TEMPLATE/Question.md create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/ISSUE_TEMPLATE/question.yml diff --git a/.claude/skills/feature-review/SKILL.md b/.claude/skills/feature-review/SKILL.md index ba73fa8aca..78d9d3a8bd 100644 --- a/.claude/skills/feature-review/SKILL.md +++ b/.claude/skills/feature-review/SKILL.md @@ -147,8 +147,8 @@ Reference code as `path:line`. Rate each finding (High / Medium / Low) in the fi prioritize. Proposed fixes describe the smallest change that follows the existing patterns. For larger missing features, add an issue draft at the end that follows the repository's feature request -template (`.github/ISSUE_TEMPLATE/Feature_request.md`): a short title, "Describe your feature request", -the motivation, a proposed behaviour (UI + logic), and which existing feature it could be modelled on. +form (`.github/ISSUE_TEMPLATE/feature_request.yml`): a short title, the affected feature, "Problem / motivation", +"Proposed solution" (UI + logic), and "Alternatives / workarounds" incl. which existing feature it could be modelled on. ### 7. After the report diff --git a/.github/ISSUE_TEMPLATE/Bug_report.md b/.github/ISSUE_TEMPLATE/Bug_report.md deleted file mode 100644 index 37f5895da5..0000000000 --- a/.github/ISSUE_TEMPLATE/Bug_report.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -name: Bug report -about: Report an issue -labels: ":mag: needs triage" ---- - -**Describe your issue**: - -**Steps to reproduce the issue**: - -1. -2. -3. - -**NETworkManager version**: - -- [ ] Portable - -**Windows version** (like 10.0.x.x): - -**.NET version** (like 6.0.x): - -**Screenshots** - -
-Eventlog (if application crashes) - -
diff --git a/.github/ISSUE_TEMPLATE/Feature_request.md b/.github/ISSUE_TEMPLATE/Feature_request.md deleted file mode 100644 index 377f9a53f5..0000000000 --- a/.github/ISSUE_TEMPLATE/Feature_request.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -name: Feature request -about: Create a feature request -labels: ":rocket: feature-request" ---- - -**Describe your feature request**: diff --git a/.github/ISSUE_TEMPLATE/Question.md b/.github/ISSUE_TEMPLATE/Question.md deleted file mode 100644 index f941c3be7f..0000000000 --- a/.github/ISSUE_TEMPLATE/Question.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -name: Question -about: Ask a question -labels: ":question: question" ---- diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000000..f25368f9f2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,149 @@ +name: 🐛 Bug report +description: Something doesn't work as expected, or the application crashes. +labels: [":mag: needs triage"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to report a bug! 🙏 + + Please check the [existing issues](https://github.com/BornToBeRoot/NETworkManager/issues?q=is%3Aissue) first and whether the bug still happens with the [latest version](https://github.com/BornToBeRoot/NETworkManager/releases) (or pre-release). + + > [!IMPORTANT] + > **Security vulnerabilities** must not be reported here - please use a [private security advisory](https://github.com/BornToBeRoot/NETworkManager/security/advisories/new). + + - type: dropdown + id: area + attributes: + label: Affected feature + description: Where does the bug happen? + options: + - General (start, settings, profiles, update, ...) + - Run command + - Dashboard + - Network Interface + - WiFi + - IP Scanner + - Port Scanner + - Ping Monitor + - Traceroute + - DNS Lookup + - Remote Desktop + - PowerShell + - PuTTY + - TigerVNC + - Web Console + - SNMP + - SNTP Lookup + - Hosts File Editor + - Firewall + - Discovery Protocol + - Wake on LAN + - RDAP + - Whois + - IP Geolocation + - Subnet Calculator + - Bit Calculator + - Lookup + - Connections + - Listeners + - Neighbor Table + - Documentation / website + - Other + validations: + required: true + + - type: textarea + id: description + attributes: + label: What happened? + description: Describe the bug and what you expected to happen instead. + placeholder: When I scan a range, hosts are shown as down, but I expected them to be up because ... + validations: + required: true + + - type: textarea + id: steps + attributes: + label: Steps to reproduce + description: How can the bug be reproduced? Include the input and settings you used (without sensitive data). + placeholder: | + 1. Open the IP Scanner + 2. Enter `10.0.0.0/24` and click **Scan** + 3. ... + validations: + required: true + + - type: input + id: version + attributes: + label: NETworkManager version + description: Shown in **Settings > About**. + placeholder: e.g. 2026.7.7.0 + validations: + required: true + + - type: dropdown + id: installation + attributes: + label: Installation + options: + - Setup (MSI) + - Portable + - Archive + - WinGet + - Chocolatey + - Built from source + validations: + required: true + + - type: dropdown + id: architecture + attributes: + label: Architecture + options: + - x64 + - ARM64 + validations: + required: true + + - type: input + id: windows + attributes: + label: Windows version + description: Run `winver` to see the version and build. + placeholder: e.g. Windows 11 24H2 (Build 26100.4061) + validations: + required: true + + - type: input + id: dotnet + attributes: + label: .NET Desktop Runtime version + description: Only needed if the application doesn't start - run `dotnet --list-runtimes`. + placeholder: e.g. Microsoft.WindowsDesktop.App 10.0.0 + + - type: textarea + id: logs + attributes: + label: Error message / log + description: | + The error message shown by NETworkManager, or the relevant part of the log file `%LocalAppData%\NETworkManager\NETworkManager.log`. + If the application crashes, also the error in the Event Viewer (**Windows Logs > Application**). + render: text + + - type: textarea + id: screenshots + attributes: + label: Screenshots / additional information + description: Drag & drop screenshots or files here, or add anything else that helps to understand the bug. + + - type: checkboxes + id: checklist + attributes: + label: Checklist + options: + - label: I have searched the existing issues and this bug hasn't been reported yet. + required: true + - label: I have removed sensitive data (e.g. passwords, IP addresses, hostnames) from logs and screenshots. + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000..87c23fc802 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,14 @@ +blank_issues_enabled: false +contact_links: + - name: 📖 Documentation + url: https://borntoberoot.net/NETworkManager/docs/introduction + about: Features, settings, FAQ and how to use NETworkManager. + - name: 💬 Discussions + url: https://github.com/BornToBeRoot/NETworkManager/discussions + about: Share ideas, show what you built or talk with the community. + - name: 🌍 Translations + url: https://app.transifex.com/BornToBeRoot/NETworkManager/dashboard/ + about: Translations are managed on Transifex - help translate NETworkManager there. + - name: 🔒 Security vulnerability + url: https://github.com/BornToBeRoot/NETworkManager/security/advisories/new + about: Please report security vulnerabilities privately, not as a public issue. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000000..9047944f3c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,90 @@ +name: 💡 Feature request +description: Suggest a new feature or an improvement of an existing one. +labels: [":rocket: feature-request"] +body: + - type: markdown + attributes: + value: | + Thanks for helping to make NETworkManager better! 🚀 + + Please check the [existing issues](https://github.com/BornToBeRoot/NETworkManager/issues?q=is%3Aissue) first - maybe your idea was already suggested. If so, give it a 👍 instead of creating a new issue. + + - type: dropdown + id: area + attributes: + label: Feature + description: Which feature is it about? + options: + - New feature / tool + - General (start, settings, profiles, update, ...) + - Run command + - Dashboard + - Network Interface + - WiFi + - IP Scanner + - Port Scanner + - Ping Monitor + - Traceroute + - DNS Lookup + - Remote Desktop + - PowerShell + - PuTTY + - TigerVNC + - Web Console + - SNMP + - SNTP Lookup + - Hosts File Editor + - Firewall + - Discovery Protocol + - Wake on LAN + - RDAP + - Whois + - IP Geolocation + - Subnet Calculator + - Bit Calculator + - Lookup + - Connections + - Listeners + - Neighbor Table + - Documentation / website + - Other + validations: + required: true + + - type: textarea + id: problem + attributes: + label: Problem / motivation + description: What do you want to achieve, or what is missing or annoying today? + placeholder: I often need to ... but currently I have to ... + validations: + required: true + + - type: textarea + id: solution + attributes: + label: Proposed solution + description: How should it work? Describe the behavior and the UI (e.g. button, setting, context menu entry). + placeholder: Add a ... to the ... that ... + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives / workarounds + description: Other solutions you have considered, or how other tools solve it. + + - type: textarea + id: context + attributes: + label: Additional information + description: Screenshots, mockups, links or anything else that helps. + + - type: checkboxes + id: checklist + attributes: + label: Checklist + options: + - label: I have searched the existing issues and this feature hasn't been requested yet. + required: true diff --git a/.github/ISSUE_TEMPLATE/question.yml b/.github/ISSUE_TEMPLATE/question.yml new file mode 100644 index 0000000000..a2c5c7746f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question.yml @@ -0,0 +1,29 @@ +name: ❓ Question +description: Ask a question about NETworkManager or how to use a feature. +labels: [":question: question"] +body: + - type: markdown + attributes: + value: | + Have a question? Maybe the [documentation](https://borntoberoot.net/NETworkManager/docs/introduction) or the [FAQ](https://borntoberoot.net/NETworkManager/docs/category/faq) already has the answer. For ideas and general discussions, you can also use the [discussions](https://github.com/BornToBeRoot/NETworkManager/discussions). + + - type: textarea + id: question + attributes: + label: Question + description: What would you like to know? + validations: + required: true + + - type: textarea + id: context + attributes: + label: Context + description: What are you trying to achieve, and what have you tried? Screenshots are welcome. + + - type: input + id: version + attributes: + label: NETworkManager version + description: Shown in **Settings > About** (if the question is about the application). + placeholder: e.g. 2026.7.7.0 diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index c0570eda3a..48a570cee5 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,32 +1,51 @@ -## Changes proposed in this pull request + -- -- +## Description -## Related issue(s) + -- Fixes #{issue number} -## Copilot generated summary -Provide a Copilot generated summary of the changes in this pull request. +## Related issue -
-Copilot summary + -{generated summary} +Closes # -
+## Type of change -## To-Do +- [ ] 🐛 Bug fix +- [ ] ✨ New feature / improvement +- [ ] 📖 Documentation / website +- [ ] 🧹 Refactoring / maintenance (no functional change) -- [ ] Update [documentation](https://github.com/BornToBeRoot/NETworkManager/tree/main/Website/docs) to reflect this changes -- [ ] Update [changelog](https://github.com/BornToBeRoot/NETworkManager/tree/main/Website/docs/changelog) to reflect this changes +## How has this been tested? + + + + + +## Screenshots + + + + + +## Checklist + +- [ ] The solution builds and was tested on Windows (`dotnet build`, `dotnet test`). +- [ ] The [documentation](https://github.com/BornToBeRoot/NETworkManager/tree/main/Website/docs) is updated (if needed). +- [ ] An entry is added to the [changelog](https://github.com/BornToBeRoot/NETworkManager/blob/main/Website/docs/changelog/next-release.md). +- [ ] New texts are only added to `Strings.resx` (translations are done on [Transifex](https://app.transifex.com/BornToBeRoot/NETworkManager/dashboard/)). +- [ ] No files in `Source/3rdparty/` are changed. ## Contributing -**By submitting this pull request, I confirm the following:** +**By submitting this pull request, I confirm that:** -- [ ] I have read and understood the [contributing guidelines](https://github.com/BornToBeRoot/NETworkManager/blob/main/CONTRIBUTING.md) and the [code of conduct](https://github.com/BornToBeRoot/NETworkManager/blob/main/CODE_OF_CONDUCT.md). -- [ ] I have have added my name, username or email to the [contributors](https://github.com/BornToBeRoot/NETworkManager/blob/main/CONTRIBUTORS.md) list or don't want to. -- [ ] The code or resource is compatible with the [GNU General Public License v3.0](https://github.com/BornToBeRoot/NETworkManager/blob/main/LICENSE). +- [ ] I have read the [contributing guidelines](https://github.com/BornToBeRoot/NETworkManager/blob/main/CONTRIBUTING.md) and the [code of conduct](https://github.com/BornToBeRoot/NETworkManager/blob/main/CODE_OF_CONDUCT.md). +- [ ] My contribution is compatible with the [GNU General Public License v3.0](https://github.com/BornToBeRoot/NETworkManager/blob/main/LICENSE). +- [ ] I have added myself to the [contributors](https://github.com/BornToBeRoot/NETworkManager/blob/main/CONTRIBUTORS.md) list, or I don't want to be listed. diff --git a/README.md b/README.md index 062e8c98ab..7d07bd4d26 100644 --- a/README.md +++ b/README.md @@ -46,10 +46,10 @@ Transifex - + Feature Request - + Bug Report

diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index 1b4aad273b..eee279f9ae 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -365,5 +365,6 @@ Release date: **xx.xx.2026** - Replaced the internal event system for opening a feature with data (e.g. **Open in RDAP** from the **IP Scanner**) with typed requests, which can also carry options (e.g. DNS server and record type) and profiles. Features no longer fail to open from another feature while the application list is filtered by a search. [#3644](https://github.com/BornToBeRoot/NETworkManager/pull/3644) - Input histories (e.g. hosts, domains, ports) are updated in place instead of being cleared and refilled, which avoids clearing the text of the input field and unnecessary updates. [#3644](https://github.com/BornToBeRoot/NETworkManager/pull/3644) - Added a unit test project (xUnit) with tests for the run command parser and the history lists. [#3644](https://github.com/BornToBeRoot/NETworkManager/pull/3644) +- Reworked the GitHub issue templates as forms (bug report, feature request, question) that ask for the relevant information (e.g. affected feature, version, installation, Windows version, log), with links to the documentation, discussions, translations and security advisories. Simplified the pull request template and removed the Copilot summary section. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) - Language files updated via [#transifex](https://github.com/BornToBeRoot/NETworkManager/pulls?q=author%3Aapp%2Ftransifex-integration) - Dependencies updated via [#dependabot](https://github.com/BornToBeRoot/NETworkManager/pulls?q=author%3Aapp%2Fdependabot) From 9ed96a63fcc8ad5efe68dd0003efecd6335e3f95 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:14:07 +0200 Subject: [PATCH 105/146] Fix: IP Scanner host duration of the start scheduler overflows int (review #4) Co-Authored-By: Claude Opus 5.5 --- Source/NETworkManager.Models/Network/IPScanner.cs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/Source/NETworkManager.Models/Network/IPScanner.cs b/Source/NETworkManager.Models/Network/IPScanner.cs index 7272b85387..19fe7bf6f0 100644 --- a/Source/NETworkManager.Models/Network/IPScanner.cs +++ b/Source/NETworkManager.Models/Network/IPScanner.cs @@ -142,12 +142,13 @@ 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) - var hostDuration = Math.Max(options.ICMPAttempts * options.ICMPTimeout, + // 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, Math.Max( options.PortScanEnabled - ? options.PortScanTimeout * options.PortScanAttempts * - (int)Math.Ceiling(options.PortScanPorts.Length / (double)options.MaxPortThreads) + ? (long)options.PortScanTimeout * options.PortScanAttempts * + (long)Math.Ceiling(options.PortScanPorts.Length / (double)options.MaxPortThreads) : 0, options.NetBIOSEnabled ? options.NetBIOSTimeout : 0)); From e255a039e897a9b96a6ba7e9ab6aededb627072d Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:15:23 +0200 Subject: [PATCH 106/146] Fix: Web Console URL with the trailing dot of a single-label hostname (review #5) Co-Authored-By: Claude Opus 5.5 --- .../Network/WebConsoleUrlHelper.cs | 15 ++++++++++++--- .../Network/WebConsoleUrlHelperTests.cs | 2 ++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/Source/NETworkManager.Models/Network/WebConsoleUrlHelper.cs b/Source/NETworkManager.Models/Network/WebConsoleUrlHelper.cs index 3f0b0376c7..c522430a83 100644 --- a/Source/NETworkManager.Models/Network/WebConsoleUrlHelper.cs +++ b/Source/NETworkManager.Models/Network/WebConsoleUrlHelper.cs @@ -43,7 +43,7 @@ public static string CreateUrl(string host, IEnumerable openPorts) /// /// Creates the URL for a host and a web port: https for 443 / 8443, otherwise http; default ports (80 / 443) - /// are left out, IPv6 addresses in brackets. + /// are left out, IPv6 addresses in brackets, the trailing dot of a hostname is removed. /// /// Hostname or IP address. /// Port number. @@ -53,8 +53,17 @@ public static string CreateUrl(string host, int port) 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.TryParse(host, out var ipAddress) && ipAddress.AddressFamily == AddressFamily.InterNetworkV6) - host = $"[{ipAddress.ToString().Replace("%", "%25")}]"; + if (IPAddress.TryParse(host, out var ipAddress)) + { + if (ipAddress.AddressFamily == AddressFamily.InterNetworkV6) + host = $"[{ipAddress.ToString().Replace("%", "%25")}]"; + } + else + { + // Trim the dot a single-label name still has (e.g. "gateway."), so Windows resolves it like a name typed + // by the user (DNS suffix search, LLMNR / NetBIOS) and the name matches the certificate (https) + host = host.TrimEnd('.'); + } return port is 80 or 443 ? $"{scheme}://{host}/" : $"{scheme}://{host}:{port}/"; } diff --git a/Source/NETworkManager.Tests/Network/WebConsoleUrlHelperTests.cs b/Source/NETworkManager.Tests/Network/WebConsoleUrlHelperTests.cs index 1676eb98ad..e31eb70866 100644 --- a/Source/NETworkManager.Tests/Network/WebConsoleUrlHelperTests.cs +++ b/Source/NETworkManager.Tests/Network/WebConsoleUrlHelperTests.cs @@ -12,6 +12,8 @@ public class WebConsoleUrlHelperTests [InlineData("10.0.0.1", 8443, "https://10.0.0.1:8443/")] [InlineData("2001:db8::1", 443, "https://[2001:db8::1]/")] [InlineData("fe80::1%12", 8080, "http://[fe80::1%2512]:8080/")] + [InlineData("gateway.", 80, "http://gateway/")] + [InlineData("gateway.", 8443, "https://gateway:8443/")] public void CreatesUrlForPort(string host, int port, string expected) { Assert.Equal(expected, WebConsoleUrlHelper.CreateUrl(host, port)); From f5964ce1172688de5228409edc2ea339ba892ef6 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:15:33 +0200 Subject: [PATCH 107/146] Fix: IP Scanner reads the neighbor table for an unreachable host if it was not read yet (review #6) Co-Authored-By: Claude Opus 5.5 --- Source/NETworkManager.Models/Network/IPScanner.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Source/NETworkManager.Models/Network/IPScanner.cs b/Source/NETworkManager.Models/Network/IPScanner.cs index 19fe7bf6f0..bd22e0e480 100644 --- a/Source/NETworkManager.Models/Network/IPScanner.cs +++ b/Source/NETworkManager.Models/Network/IPScanner.cs @@ -512,8 +512,9 @@ private async Task GetMACAddressFromNeighborTableAsync(IPAddress ipAddre if (_neighborTable.TryGetValue(ipAddress, out var macAddress)) return macAddress; - // Already read after the host answered -> no entry - if (_neighborTableTimestamp > discoveredAt) + // Already read after the host answered -> no entry. An unreachable host (MinValue) uses the cache only, + // also if the table was not read yet (MinValue > MinValue is false) + if (discoveredAt == DateTime.MinValue || _neighborTableTimestamp > discoveredAt) return string.Empty; // Timestamp before reading the table, so entries created while reading are covered by the next read From 9cf6f06440344aecb594a3aaabbd2db38ecd32e4 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:15:57 +0200 Subject: [PATCH 108/146] Fix: Port Scanner message of the tab still shown after the ports were changed (review #7) Co-Authored-By: Claude Opus 5.5 --- .../ViewModels/PortScannerViewModel.cs | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs index 623c43ef9f..0c2055d72c 100644 --- a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs @@ -67,7 +67,7 @@ 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; - // Message of the tab, shown until the first scan has started (see constructor) + // Message of the tab, shown until the first scan has started or the ports are changed (see constructor) private string _message; // Rescan of shown hosts (see RescanSelectedHosts): their rows are removed and added again by the rescan. The @@ -129,6 +129,16 @@ public string Ports field = value; OnPropertyChanged(); + + // 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 + if (_message != null) + { + _message = null; + + IsStatusMessageDisplayed = false; + StatusMessage = string.Empty; + } } } @@ -434,6 +444,13 @@ public PortScannerViewModel(Guid tabId, string host, string port, bool start = t { _firstLoad = start; + ConfigurationManager.Current.PortScannerTabCount++; + + _tabId = tabId; + Host = host; + Ports = port; + + // After the ports, changing them removes the message (see Ports) if (!string.IsNullOrEmpty(message)) { _message = message; @@ -442,12 +459,6 @@ public PortScannerViewModel(Guid tabId, string host, string port, bool start = t IsStatusMessageDisplayed = true; } - ConfigurationManager.Current.PortScannerTabCount++; - - _tabId = tabId; - Host = host; - Ports = port; - // Set collection view HostHistoryView = CollectionViewSource.GetDefaultView(SettingsManager.Current.PortScanner_HostHistory); From 6456eed7b134d505a7d51c1e3f92ba729638d4b9 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:17:14 +0200 Subject: [PATCH 109/146] Improvement: Port Scanner pings hosts before the scan with their own concurrency if threads are adjusted automatically (review #2) Co-Authored-By: Claude Opus 5.5 --- .../Network/PortScanner.cs | 130 +++++++++++++++--- Website/docs/application/port-scanner.md | 2 +- 2 files changed, 114 insertions(+), 18 deletions(-) diff --git a/Source/NETworkManager.Models/Network/PortScanner.cs b/Source/NETworkManager.Models/Network/PortScanner.cs index 590a68af84..b332b55f8f 100644 --- a/Source/NETworkManager.Models/Network/PortScanner.cs +++ b/Source/NETworkManager.Models/Network/PortScanner.cs @@ -5,6 +5,7 @@ using System.Net; using System.Net.NetworkInformation; using System.Threading; +using System.Threading.Channels; using System.Threading.Tasks; using NETworkManager.Models.Lookup; using NETworkManager.Utilities; @@ -50,6 +51,12 @@ public PortScanner(PortScannerOptions options) ///
private const int AutoMaxPortThreads = 128; + /// + /// Threads adjusted automatically: pings before the scan at the same time (see + /// ), like the default host threads of the IP Scanner. + /// + private const int AutoMaxPingThreads = 128; + private int _progressValue; private readonly InterlockedCounter _portsOpen = new(); private readonly InterlockedCounter _portsClosed = new(); @@ -182,23 +189,22 @@ public void ScanAsync(IEnumerable<(IPAddress ipAddress, string hostname)> hosts, var portStartScheduler = new StartScheduler(StartScheduler.GetInterval(MaxPortStartInterval, TimeSpan.FromMilliseconds(_options.Timeout * _options.Attempts), portConcurrency), portConcurrency); - await Parallel.ForEachAsync(hosts, hostParallelOptions, async (host, hostCt) => + // Only hosts that answer a ping are scanned (option). Adjusted automatically, the pings run ahead of + // the port scan with their own concurrency - otherwise an unused address would take one of the few + // hosts of the port scan (e.g. 4 for 1-1024) until its ping timed out. With the threads of the + // settings, every host is pinged in its slot of the port scan, so Max. concurrent host threads also + // limits the pings (e.g. to slow down a scan). + if (_options.PingHostsBeforeScan && _options.AutoAdjustThreads) + await ScanReachableHostsAsync(hosts, portCount, hostParallelOptions, ScanHostAsync, + cancellationToken).ConfigureAwait(false); + else + await Parallel.ForEachAsync(hosts, hostParallelOptions, ScanHostAsync).ConfigureAwait(false); + + async ValueTask ScanHostAsync((IPAddress ipAddress, string hostname) host, CancellationToken hostCt) { - // Only hosts that answer a ping are scanned (option) - the ports of the other hosts are counted - // as scanned, so the progress is complete - if (_options.PingHostsBeforeScan) - { - var pingInfo = await PingProbe.PingAsync(host.ipAddress, _options.Timeout, _options.Attempts, - PingBuffer, hostCt).ConfigureAwait(false); - - if (pingInfo.Status != IPStatus.Success) - { - _hostsSkipped.Increment(); - IncreaseProgress(portCount); - - return; - } - } + if (_options.PingHostsBeforeScan && !_options.AutoAdjustThreads && + !await IsReachableAsync(host.ipAddress, portCount, hostCt).ConfigureAwait(false)) + return; // Resolve Hostname (PTR) while the ports are scanned. Started with the first port probe of the // host, so the lookups of many hosts are spread over time like the probes instead of all at once @@ -252,7 +258,7 @@ await Parallel.ForEachAsync(ports, portParallelOptions, async (port, portCt) => IncreaseProgress(); }).ConfigureAwait(false); - }).ConfigureAwait(false); + } } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { @@ -269,6 +275,96 @@ await Parallel.ForEachAsync(ports, portParallelOptions, async (port, portCt) => }); } + /// + /// Pings the hosts with at the same time and scans the hosts that answer (as + /// soon as they answer) with . Returns when all pings and scans are finished, also on + /// cancellation or an error. + /// + /// Hosts to ping and scan. + /// Number of ports per host (counted as scanned for a host that doesn't answer). + /// Options for the hosts that are scanned at the same time. + /// Scans the ports of a host. + /// Token to monitor for cancellation requests. + private async Task ScanReachableHostsAsync(IEnumerable<(IPAddress ipAddress, string hostname)> hosts, + int portCount, ParallelOptions hostParallelOptions, + Func<(IPAddress ipAddress, string hostname), CancellationToken, ValueTask> scanHost, + CancellationToken cancellationToken) + { + var reachableHosts = Channel.CreateUnbounded<(IPAddress ipAddress, string hostname)>(); + + // Also stops the pings if the port scan fails + using var pingCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + var pingTask = Task.Run(async () => + { + try + { + var pingParallelOptions = new ParallelOptions + { + CancellationToken = pingCts.Token, + MaxDegreeOfParallelism = AutoMaxPingThreads + }; + + await Parallel.ForEachAsync(hosts, pingParallelOptions, async (host, ct) => + { + if (await IsReachableAsync(host.ipAddress, portCount, ct).ConfigureAwait(false)) + reachableHosts.Writer.TryWrite(host); // Unbounded, always written + }).ConfigureAwait(false); + } + finally + { + // Also on cancellation or an error (raised below), the port scan ends with the hosts written so far + reachableHosts.Writer.TryComplete(); + } + }, CancellationToken.None); + + try + { + await Parallel.ForEachAsync(reachableHosts.Reader.ReadAllAsync(cancellationToken), hostParallelOptions, + scanHost).ConfigureAwait(false); + } + finally + { + // Never return while pings are still running (e.g. the port scan failed or was canceled), they would + // change the progress after ScanComplete + await pingCts.CancelAsync().ConfigureAwait(false); + + try + { + await pingTask.ConfigureAwait(false); + } + catch + { + // Raised below if the port scan was finished + } + } + + // Error of the pings + await pingTask.ConfigureAwait(false); + } + + /// + /// Pings a host before the scan (see ). If it doesn't + /// answer, it is counted as skipped and its ports as scanned, so the progress is complete. + /// + /// IP address of the host. + /// Number of ports per host. + /// Token to monitor for cancellation requests. + /// True if the host answered the ping. + private async Task IsReachableAsync(IPAddress ipAddress, int portCount, CancellationToken cancellationToken) + { + var pingInfo = await PingProbe.PingAsync(ipAddress, _options.Timeout, _options.Attempts, PingBuffer, + cancellationToken).ConfigureAwait(false); + + if (pingInfo.Status == IPStatus.Success) + return true; + + _hostsSkipped.Increment(); + IncreaseProgress(portCount); + + return false; + } + /// /// Gets the number of hosts and the number of ports per host scanned at the same time: the settings, or - /// adjusted automatically - connections spread over as many hosts as needed diff --git a/Website/docs/application/port-scanner.md b/Website/docs/application/port-scanner.md index 3cc6929e23..f53fb79395 100644 --- a/Website/docs/application/port-scanner.md +++ b/Website/docs/application/port-scanner.md @@ -226,7 +226,7 @@ In a local network (LAN), `1` is enough. On an unreliable network (e.g. Wi-Fi, V ### Ping hosts before the scan -Pings every host before its ports are scanned (ICMP echo, with the [Timeout](#timeout-ms) and [Attempts](#attempts) of the port scan). Only hosts that answer are scanned, the number of hosts that were not scanned is shown below the input after the scan. Faster for ranges with many unused IP addresses (e.g. a `/24` with `1-1024`), because the ports of unused addresses are not scanned until their timeout. +Pings every host before its ports are scanned (ICMP echo, with the [Timeout](#timeout-ms) and [Attempts](#attempts) of the port scan). Only hosts that answer are scanned, the number of hosts that were not scanned is shown below the input after the scan. Faster for ranges with many unused IP addresses (e.g. a `/24` with `1-1024`), because the ports of unused addresses are not scanned until their timeout. With [Adjust threads automatically](#adjust-threads-automatically), up to `128` hosts are pinged at the same time and a host is scanned as soon as it answers; otherwise each host is pinged when it is scanned, so [Max. concurrent host threads](#max-concurrent-host-threads) also limits the pings. **Type:** `Boolean` From 0cc0435c002974637c172c270cef3dacc97e9261 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:19:01 +0200 Subject: [PATCH 110/146] Refactor: Shared name / ID reference input for port profiles and DNS server profiles (review #8) Co-Authored-By: Claude Opus 5.5 --- .../Network/DNSServer.cs | 60 +-------- .../Network/DNSServerConnectionInfoProfile.cs | 2 +- .../Network/NamedReferenceInput.cs | 122 ++++++++++++++++++ .../Network/PortProfile.cs | 63 ++------- .../Network/PortProfileInfo.cs | 2 +- .../Network/NamedReferenceInputTests.cs | 95 ++++++++++++++ 6 files changed, 233 insertions(+), 111 deletions(-) create mode 100644 Source/NETworkManager.Models/Network/NamedReferenceInput.cs create mode 100644 Source/NETworkManager.Tests/Network/NamedReferenceInputTests.cs diff --git a/Source/NETworkManager.Models/Network/DNSServer.cs b/Source/NETworkManager.Models/Network/DNSServer.cs index 495a85bcf9..034ca1ea08 100644 --- a/Source/NETworkManager.Models/Network/DNSServer.cs +++ b/Source/NETworkManager.Models/Network/DNSServer.cs @@ -14,7 +14,7 @@ public static class DNSServer /// /// Separator of the entries of a DNS server input (e.g. "Google Public DNS; 9.9.9.9"). /// - private const char EntrySeparator = ';'; + private const char EntrySeparator = NamedReferenceInput.EntrySeparator; /// /// Default port of a DNS server entered without a port. @@ -30,12 +30,7 @@ public static class DNSServer public static DNSServerConnectionInfoProfile FindByName(IEnumerable profiles, string name) { - var trimmedName = name?.Trim(); - - return string.IsNullOrEmpty(trimmedName) - ? null - : profiles.FirstOrDefault(x => - string.Equals(x.Name?.Trim(), trimmedName, StringComparison.OrdinalIgnoreCase)); + return NamedReferenceInput.FindByName(profiles, name); } /// @@ -137,28 +132,11 @@ public static List ResolveServers(string input, /// Input with references, null if it contains no DNS server profile with an ID. public static string EncodeReferences(string input, IEnumerable profiles) { - if (string.IsNullOrWhiteSpace(input)) - return null; - var profileList = profiles.ToList(); - var hasReference = false; - List entries = []; - - foreach (var entry in input.Split(EntrySeparator)) - { - var profile = FindByName(profileList, entry); - if (profile == null || profile.UseWindowsDNSServer || profile.Id == Guid.Empty) - { - entries.Add(entry.Trim()); - continue; - } - - entries.Add(profile.Id.ToString("B")); - hasReference = true; - } - - return hasReference ? string.Join("; ", entries) : null; + // The Windows DNS profile is kept by name + return NamedReferenceInput.EncodeReferences(input, + entry => FindByName(profileList, entry) is { UseWindowsDNSServer: false } profile ? profile : null); } /// @@ -172,33 +150,7 @@ public static string EncodeReferences(string input, IEnumerable profiles, out string input) { - input = null; - - if (string.IsNullOrWhiteSpace(inputWithReferences)) - return false; - - var profileList = profiles.ToList(); - List entries = []; - - foreach (var entry in inputWithReferences.Split(EntrySeparator)) - { - if (!Guid.TryParseExact(entry.Trim(), "B", out var id)) - { - entries.Add(entry.Trim()); - continue; - } - - var profile = profileList.FirstOrDefault(x => x.Id == id); - - if (profile == null) - return false; - - entries.Add(profile.Name); - } - - input = string.Join("; ", entries); - - return true; + return NamedReferenceInput.TryDecodeReferences(inputWithReferences, profiles, out input); } /// diff --git a/Source/NETworkManager.Models/Network/DNSServerConnectionInfoProfile.cs b/Source/NETworkManager.Models/Network/DNSServerConnectionInfoProfile.cs index 961a9fce2a..956f1db248 100644 --- a/Source/NETworkManager.Models/Network/DNSServerConnectionInfoProfile.cs +++ b/Source/NETworkManager.Models/Network/DNSServerConnectionInfoProfile.cs @@ -6,7 +6,7 @@ namespace NETworkManager.Models.Network; /// /// Class is used to store informations about a DNS server profile. /// -public class DNSServerConnectionInfoProfile : ServerConnectionInfoProfile +public class DNSServerConnectionInfoProfile : ServerConnectionInfoProfile, INamedReference { /// /// Create an instance of . This will use the diff --git a/Source/NETworkManager.Models/Network/NamedReferenceInput.cs b/Source/NETworkManager.Models/Network/NamedReferenceInput.cs new file mode 100644 index 0000000000..ff3c614ddb --- /dev/null +++ b/Source/NETworkManager.Models/Network/NamedReferenceInput.cs @@ -0,0 +1,122 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace NETworkManager.Models.Network; + +/// +/// Item that can be entered by its name in an input with several entries (e.g. a port profile in a ports input) +/// and is saved as a reference to its ID (see ). +/// +public interface INamedReference +{ + /// + /// Internal ID, kept when the item is renamed. + /// + Guid Id { get; } + + /// + /// Name that is entered in the input. + /// + string Name { get; } +} + +/// +/// Input with entries separated by ";" (e.g. "Webserver; 8080"), where an entry can be the name of an item (e.g. a +/// port profile or DNS server profile). The names can be replaced by references to the ID of the item (e.g. +/// "{8f1c...}; 8080"), so a profile still finds the item after it was renamed. +/// +public static class NamedReferenceInput +{ + /// + /// Separator of the entries of an input. + /// + public const char EntrySeparator = ';'; + + /// + /// Finds an item by its name (case-insensitive, leading / trailing spaces are ignored). + /// + /// Items. + /// Name of the item. + /// Item, or null if there is none with the name. + public static T FindByName(IEnumerable items, string name) where T : class, INamedReference + { + var trimmedName = name?.Trim(); + + return string.IsNullOrEmpty(trimmedName) + ? null + : items.FirstOrDefault(x => string.Equals(x.Name?.Trim(), trimmedName, StringComparison.OrdinalIgnoreCase)); + } + + /// + /// Replaces the names of items in an input by references to their ID (e.g. "Webserver; 8080" -> + /// "{8f1c...}; 8080"). Entries without an item (or an item without an ID) are kept, trimmed. + /// + /// Input. + /// Gets the item an entry is replaced by, null to keep the entry. + /// Input with references, null if it contains no item. + public static string EncodeReferences(string input, Func findItem) where T : class, INamedReference + { + if (string.IsNullOrWhiteSpace(input)) + return null; + + var hasReference = false; + List entries = []; + + foreach (var entry in input.Split(EntrySeparator)) + { + var item = findItem(entry); + + if (item == null || item.Id == Guid.Empty) + { + entries.Add(entry.Trim()); + continue; + } + + entries.Add(item.Id.ToString("B")); + hasReference = true; + } + + return hasReference ? string.Join("; ", entries) : null; + } + + /// + /// Replaces the references in an input (see ) by the current names of the + /// items (e.g. "{8f1c...}; 8080" -> "Webserver; 8080"). + /// + /// Input with references. + /// Items. + /// Input with the names of the items, null if an item was not found. + /// True if every referenced item was found. + public static bool TryDecodeReferences(string inputWithReferences, IEnumerable items, out string input) + where T : class, INamedReference + { + input = null; + + if (string.IsNullOrWhiteSpace(inputWithReferences)) + return false; + + var itemList = items as IList ?? [.. items]; + List entries = []; + + foreach (var entry in inputWithReferences.Split(EntrySeparator)) + { + if (!Guid.TryParseExact(entry.Trim(), "B", out var id)) + { + entries.Add(entry.Trim()); + continue; + } + + var item = itemList.FirstOrDefault(x => x.Id == id); + + if (item == null) + return false; + + entries.Add(item.Name); + } + + input = string.Join("; ", entries); + + return true; + } +} diff --git a/Source/NETworkManager.Models/Network/PortProfile.cs b/Source/NETworkManager.Models/Network/PortProfile.cs index 323113336c..bbc0297057 100644 --- a/Source/NETworkManager.Models/Network/PortProfile.cs +++ b/Source/NETworkManager.Models/Network/PortProfile.cs @@ -1,5 +1,4 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using NETworkManager.Utilities; @@ -10,7 +9,7 @@ public static class PortProfile /// /// Separator of the entries of a ports input (e.g. "Webserver; 8080"). /// - private const char EntrySeparator = ';'; + private const char EntrySeparator = NamedReferenceInput.EntrySeparator; /// /// Finds a port profile by its name (case-insensitive, leading / trailing spaces are ignored). @@ -20,12 +19,7 @@ public static class PortProfile /// Port profile, or null if there is none with the name. public static PortProfileInfo FindByName(IEnumerable portProfiles, string name) { - var trimmedName = name?.Trim(); - - return string.IsNullOrEmpty(trimmedName) - ? null - : portProfiles.FirstOrDefault(x => - string.Equals(x.Name?.Trim(), trimmedName, StringComparison.OrdinalIgnoreCase)); + return NamedReferenceInput.FindByName(portProfiles, name); } /// @@ -89,27 +83,11 @@ public static bool TryResolvePorts(string input, IEnumerable po /// Input with references, null if it contains no port profile. public static string EncodeReferences(string input, IEnumerable portProfiles) { - if (string.IsNullOrWhiteSpace(input)) - return null; - - var hasReference = false; - List entries = []; - - foreach (var entry in input.Split(EntrySeparator)) - { - var portProfile = PortRangeHelper.IsValidPortOrPortRange(entry) ? null : FindByName(portProfiles, entry); + var portProfileList = portProfiles.ToList(); - if (portProfile == null || portProfile.Id == Guid.Empty) - { - entries.Add(entry.Trim()); - continue; - } - - entries.Add(portProfile.Id.ToString("B")); - hasReference = true; - } - - return hasReference ? string.Join("; ", entries) : null; + // A port or port range is never a port profile (see TryResolvePorts) + return NamedReferenceInput.EncodeReferences(input, + entry => PortRangeHelper.IsValidPortOrPortRange(entry) ? null : FindByName(portProfileList, entry)); } /// @@ -123,32 +101,7 @@ public static string EncodeReferences(string input, IEnumerable public static bool TryDecodeReferences(string inputWithReferences, IEnumerable portProfiles, out string input) { - input = null; - - if (string.IsNullOrWhiteSpace(inputWithReferences)) - return false; - - List entries = []; - - foreach (var entry in inputWithReferences.Split(EntrySeparator)) - { - if (!Guid.TryParseExact(entry.Trim(), "B", out var id)) - { - entries.Add(entry.Trim()); - continue; - } - - var portProfile = portProfiles.FirstOrDefault(x => x.Id == id); - - if (portProfile == null) - return false; - - entries.Add(portProfile.Name); - } - - input = string.Join("; ", entries); - - return true; + return NamedReferenceInput.TryDecodeReferences(inputWithReferences, portProfiles, out input); } public static List GetDefaultList() diff --git a/Source/NETworkManager.Models/Network/PortProfileInfo.cs b/Source/NETworkManager.Models/Network/PortProfileInfo.cs index 4bae65a68d..8d25cd7bd4 100644 --- a/Source/NETworkManager.Models/Network/PortProfileInfo.cs +++ b/Source/NETworkManager.Models/Network/PortProfileInfo.cs @@ -2,7 +2,7 @@ namespace NETworkManager.Models.Network; -public class PortProfileInfo +public class PortProfileInfo : INamedReference { public PortProfileInfo() { diff --git a/Source/NETworkManager.Tests/Network/NamedReferenceInputTests.cs b/Source/NETworkManager.Tests/Network/NamedReferenceInputTests.cs new file mode 100644 index 0000000000..edf2fd06cc --- /dev/null +++ b/Source/NETworkManager.Tests/Network/NamedReferenceInputTests.cs @@ -0,0 +1,95 @@ +using System; +using NETworkManager.Models.Network; +using Xunit; + +namespace NETworkManager.Tests.Network; + +public class NamedReferenceInputTests +{ + private sealed class Item(Guid id, string name) : INamedReference + { + public Guid Id { get; } = id; + public string Name { get; } = name; + } + + private static readonly Item Alpha = new(Guid.Parse("11111111-1111-1111-1111-111111111111"), "Alpha"); + private static readonly Item Beta = new(Guid.Parse("22222222-2222-2222-2222-222222222222"), " Beta "); + private static readonly Item WithoutId = new(Guid.Empty, "Legacy"); + + private static readonly Item[] Items = [Alpha, Beta, WithoutId]; + + private static Item Find(string entry) => NamedReferenceInput.FindByName(Items, entry); + + [Theory] + [InlineData("Alpha", "Alpha")] + [InlineData("alpha", "Alpha")] + [InlineData(" ALPHA ", "Alpha")] + [InlineData("Beta", " Beta ")] + public void FindByName_IgnoresCaseAndSpaces(string name, string expected) + { + Assert.Equal(expected, NamedReferenceInput.FindByName(Items, name)?.Name); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("Gamma")] + public void FindByName_NullIfNotFound(string name) + { + Assert.Null(NamedReferenceInput.FindByName(Items, name)); + } + + [Fact] + public void EncodeReferences_ReplacesNamesAndTrimsOtherEntries() + { + Assert.Equal("{11111111-1111-1111-1111-111111111111}; 8080; {22222222-2222-2222-2222-222222222222}", + NamedReferenceInput.EncodeReferences(" alpha ;8080 ; Beta", Find)); + } + + [Theory] + [InlineData(null)] + [InlineData(" ")] + [InlineData("8080; Gamma")] + [InlineData("Legacy")] + public void EncodeReferences_NullWithoutReference(string input) + { + Assert.Null(NamedReferenceInput.EncodeReferences(input, Find)); + } + + [Fact] + public void EncodeReferences_KeepsEntriesSkippedByFindItem() + { + Assert.Equal("Alpha; {22222222-2222-2222-2222-222222222222}", + NamedReferenceInput.EncodeReferences("Alpha; Beta", entry => entry.Trim() == "Alpha" ? null : Find(entry))); + } + + [Fact] + public void DecodeReferences_RoundTrip() + { + var encoded = NamedReferenceInput.EncodeReferences("Alpha; 8080", Find); + + Assert.True(NamedReferenceInput.TryDecodeReferences(encoded, Items, out var input)); + Assert.Equal("Alpha; 8080", input); + } + + [Fact] + public void DecodeReferences_CurrentName() + { + var renamed = new Item(Alpha.Id, "Alpha (renamed)"); + + Assert.True(NamedReferenceInput.TryDecodeReferences("{11111111-1111-1111-1111-111111111111}; 8080 ", + [renamed], out var input)); + Assert.Equal("Alpha (renamed); 8080", input); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("{33333333-3333-3333-3333-333333333333}; 8080")] + public void DecodeReferences_FalseIfEmptyOrItemIsMissing(string inputWithReferences) + { + Assert.False(NamedReferenceInput.TryDecodeReferences(inputWithReferences, Items, out var input)); + Assert.Null(input); + } +} From 758568870f10af5fc239dba352bd2e8753aa3b0b Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:21:20 +0200 Subject: [PATCH 111/146] Refactor: Shared search delay instead of a search timer in every view model (review #9) Co-Authored-By: Claude Opus 5.5 --- .../SearchDelay.cs | 47 +++++++++++++++++++ .../ViewModels/ConnectionsViewModel.cs | 23 +++------ .../ViewModels/FirewallViewModel.cs | 23 +++------ .../ViewModels/HostsFileEditorViewModel.cs | 23 +++------ .../ViewModels/IPScannerViewModel.cs | 23 +++------ .../ImportProfilesResultViewModel.cs | 18 ++----- .../ViewModels/ListenersViewModel.cs | 23 +++------ .../ViewModels/NeighborTableViewModel.cs | 23 +++------ .../ViewModels/PortScannerViewModel.cs | 23 +++------ .../ViewModels/ProfileHostViewModelBase.cs | 26 ++++++---- .../ViewModels/ProfilesViewModel.cs | 19 ++------ .../ViewModels/WiFiViewModel.cs | 23 +++------ 12 files changed, 121 insertions(+), 173 deletions(-) create mode 100644 Source/NETworkManager.Utilities.WPF/SearchDelay.cs diff --git a/Source/NETworkManager.Utilities.WPF/SearchDelay.cs b/Source/NETworkManager.Utilities.WPF/SearchDelay.cs new file mode 100644 index 0000000000..b2cc01af35 --- /dev/null +++ b/Source/NETworkManager.Utilities.WPF/SearchDelay.cs @@ -0,0 +1,47 @@ +using System; +using System.Windows.Threading; + +namespace NETworkManager.Utilities.WPF; + +/// +/// Delays a search while the user is typing, so a large list is not filtered on every key press: +/// on every change of the search text, the search is applied once the user stopped typing +/// for the interval - or right away with (e.g. Enter in the search box, see +/// ). +/// +public sealed class SearchDelay +{ + private readonly DispatcherTimer _timer; + private readonly Action _applySearch; + + /// + /// Creates a new search delay. Must be created on the UI thread. + /// + /// Time without a change after which the search is applied. + /// Applies the search (e.g. refreshes the filtered view). + public SearchDelay(TimeSpan interval, Action applySearch) + { + _applySearch = applySearch; + + _timer = new DispatcherTimer { Interval = interval }; + _timer.Tick += (_, _) => Apply(); + } + + /// + /// Starts the delay again (the search text was changed). + /// + public void Restart() + { + _timer.Stop(); + _timer.Start(); + } + + /// + /// Applies the search right away, a pending delay is stopped. + /// + public void Apply() + { + _timer.Stop(); + _applySearch(); + } +} diff --git a/Source/NETworkManager/ViewModels/ConnectionsViewModel.cs b/Source/NETworkManager/ViewModels/ConnectionsViewModel.cs index 64362628a7..af0325273b 100644 --- a/Source/NETworkManager/ViewModels/ConnectionsViewModel.cs +++ b/Source/NETworkManager/ViewModels/ConnectionsViewModel.cs @@ -7,6 +7,7 @@ using NETworkManager.Models.Network; using NETworkManager.Settings; using NETworkManager.Utilities; +using NETworkManager.Utilities.WPF; using NETworkManager.Views; using System; using System.Collections; @@ -34,8 +35,7 @@ public class ConnectionsViewModel : ViewModelBase /// public ConnectionsViewModel() { - _searchDispatcherTimer.Interval = GlobalStaticConfiguration.SearchDispatcherTimerTimeSpan; - _searchDispatcherTimer.Tick += SearchDispatcherTimer_Tick; + _searchDelay = new SearchDelay(GlobalStaticConfiguration.SearchDispatcherTimerTimeSpan, ApplySearch); _isLoading = true; @@ -123,7 +123,7 @@ private async void AutoRefreshTimer_Tick(object sender, EventArgs e) /// Delays filtering while the user is typing (see ), so a large list is not filtered on /// every key press. /// - private readonly DispatcherTimer _searchDispatcherTimer = new(); + private readonly SearchDelay _searchDelay; /// /// Gets or sets the search text. @@ -138,9 +138,8 @@ public string Search field = value; - // Filter after a short delay while typing (see _searchDispatcherTimer) - _searchDispatcherTimer.Stop(); - _searchDispatcherTimer.Start(); + // Filter after a short delay while typing (see _searchDelay) + _searchDelay.Restart(); OnPropertyChanged(); } @@ -449,27 +448,17 @@ public void OnViewHide() _autoRefreshTimer.Stop(); } - /// - /// Filters once the user stopped typing (see ). - /// - private void SearchDispatcherTimer_Tick(object sender, EventArgs e) - { - ApplySearch(); - } - /// /// Gets the command to filter right away (Enter in the search box, see /// ). /// - public ICommand ApplySearchCommand => new RelayCommand(_ => ApplySearch()); + public ICommand ApplySearchCommand => new RelayCommand(_ => _searchDelay.Apply()); /// /// Filters with the current right away. /// private void ApplySearch() { - _searchDispatcherTimer.Stop(); - ResultsView.Refresh(); } diff --git a/Source/NETworkManager/ViewModels/FirewallViewModel.cs b/Source/NETworkManager/ViewModels/FirewallViewModel.cs index bfe5213efc..bf61b204af 100644 --- a/Source/NETworkManager/ViewModels/FirewallViewModel.cs +++ b/Source/NETworkManager/ViewModels/FirewallViewModel.cs @@ -10,6 +10,7 @@ using NETworkManager.Models.Firewall; using NETworkManager.Settings; using NETworkManager.Utilities; +using NETworkManager.Utilities.WPF; using NETworkManager.Views; using System; using System.Collections; @@ -108,11 +109,11 @@ public IList SelectedResults /// Delays filtering the rules while the user is typing (see ), /// so the view (can be 1000+ rules if all rules are shown) is not filtered on every key press. /// - private readonly DispatcherTimer _searchDispatcherTimer = new(); + private readonly SearchDelay _searchDelay; /// /// Gets or sets the search text for filtering rules. - /// The rules are filtered after a short delay (see ). + /// The rules are filtered after a short delay (see ). /// public string RulesSearch { @@ -124,8 +125,7 @@ public string RulesSearch field = value; - _searchDispatcherTimer.Stop(); - _searchDispatcherTimer.Start(); + _searchDelay.Restart(); OnPropertyChanged(); } @@ -272,8 +272,7 @@ public FirewallViewModel() Matches(rule.Description); }; - _searchDispatcherTimer.Interval = GlobalStaticConfiguration.SearchDispatcherTimerTimeSpan; - _searchDispatcherTimer.Tick += SearchDispatcherTimer_Tick; + _searchDelay = new SearchDelay(GlobalStaticConfiguration.SearchDispatcherTimerTimeSpan, ApplySearch); // Load firewall rules _ = Refresh(true); @@ -912,27 +911,17 @@ private static List ParseAddressesString(string value) return [.. value.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)]; } - /// - /// Filters the rules once the user stopped typing (see ). - /// - private void SearchDispatcherTimer_Tick(object sender, EventArgs e) - { - ApplySearch(); - } - /// /// Gets the command to filter right away (Enter in the search box, see /// ). /// - public ICommand ApplySearchCommand => new RelayCommand(_ => ApplySearch()); + public ICommand ApplySearchCommand => new RelayCommand(_ => _searchDelay.Apply()); /// /// Filters with the current right away. /// private void ApplySearch() { - _searchDispatcherTimer.Stop(); - ResultsView.Refresh(); } diff --git a/Source/NETworkManager/ViewModels/HostsFileEditorViewModel.cs b/Source/NETworkManager/ViewModels/HostsFileEditorViewModel.cs index 04dc7a026d..0973cdb821 100644 --- a/Source/NETworkManager/ViewModels/HostsFileEditorViewModel.cs +++ b/Source/NETworkManager/ViewModels/HostsFileEditorViewModel.cs @@ -7,6 +7,7 @@ using NETworkManager.Models.HostsFileEditor; using NETworkManager.Settings; using NETworkManager.Utilities; +using NETworkManager.Utilities.WPF; using NETworkManager.Views; using System; using System.Collections; @@ -40,7 +41,7 @@ public class HostsFileEditorViewModel : ViewModelBase /// Delays filtering while the user is typing (see ), so a large list is not filtered on /// every key press. /// - private readonly DispatcherTimer _searchDispatcherTimer = new(); + private readonly SearchDelay _searchDelay; /// /// Gets or sets the search text. @@ -55,9 +56,8 @@ public string Search field = value; - // Filter after a short delay while typing (see _searchDispatcherTimer) - _searchDispatcherTimer.Stop(); - _searchDispatcherTimer.Start(); + // Filter after a short delay while typing (see _searchDelay) + _searchDelay.Restart(); OnPropertyChanged(); } @@ -199,8 +199,7 @@ private set /// public HostsFileEditorViewModel() { - _searchDispatcherTimer.Interval = GlobalStaticConfiguration.SearchDispatcherTimerTimeSpan; - _searchDispatcherTimer.Tick += SearchDispatcherTimer_Tick; + _searchDelay = new SearchDelay(GlobalStaticConfiguration.SearchDispatcherTimerTimeSpan, ApplySearch); _isLoading = true; @@ -769,27 +768,17 @@ public void OnViewHide() { } - /// - /// Filters once the user stopped typing (see ). - /// - private void SearchDispatcherTimer_Tick(object sender, EventArgs e) - { - ApplySearch(); - } - /// /// Gets the command to filter right away (Enter in the search box, see /// ). /// - public ICommand ApplySearchCommand => new RelayCommand(_ => ApplySearch()); + public ICommand ApplySearchCommand => new RelayCommand(_ => _searchDelay.Apply()); /// /// Filters with the current right away. /// private void ApplySearch() { - _searchDispatcherTimer.Stop(); - ResultsView.Refresh(); } diff --git a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs index 52542a5e78..5454deca1a 100644 --- a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs @@ -10,6 +10,7 @@ using NETworkManager.Profiles; using NETworkManager.Settings; using NETworkManager.Utilities; +using NETworkManager.Utilities.WPF; using NETworkManager.Views; using System; using System.Collections; @@ -173,11 +174,11 @@ public ObservableCollection Results /// Delays filtering the results while the user is typing (see ), so a large result is not /// filtered on every key press. /// - private readonly DispatcherTimer _searchDispatcherTimer = new(); + private readonly SearchDelay _searchDelay; /// /// Gets or sets the search text for filtering the results (IP address, hostname, MAC address, vendor, ports). - /// The results are filtered after a short delay (see ). + /// The results are filtered after a short delay (see ). /// public string Search { @@ -189,8 +190,7 @@ public string Search field = value; - _searchDispatcherTimer.Stop(); - _searchDispatcherTimer.Start(); + _searchDelay.Restart(); OnPropertyChanged(); } @@ -437,8 +437,7 @@ public IPScannerViewModel(Guid tabId, string hostOrIPRange, bool start = true) liveShaping.LiveFilteringProperties.Add(nameof(IPScannerHostInfo.Vendor)); liveShaping.LiveFilteringProperties.Add(nameof(IPScannerHostInfo.Ports)); - _searchDispatcherTimer.Interval = GlobalStaticConfiguration.SearchDispatcherTimerTimeSpan; - _searchDispatcherTimer.Tick += SearchDispatcherTimer_Tick; + _searchDelay = new SearchDelay(GlobalStaticConfiguration.SearchDispatcherTimerTimeSpan, ApplySearch); } /// @@ -1159,27 +1158,17 @@ public void OnClose() #region Events - /// - /// Filters the results once the user stopped typing (see ). - /// - private void SearchDispatcherTimer_Tick(object sender, EventArgs e) - { - ApplySearch(); - } - /// /// Gets the command to filter the results right away (Enter in the search box, see /// ). /// - public ICommand ApplySearchCommand => new RelayCommand(_ => ApplySearch()); + public ICommand ApplySearchCommand => new RelayCommand(_ => _searchDelay.Apply()); /// /// Filters the results with the current right away (e.g. Enter in the search box). /// private void ApplySearch() { - _searchDispatcherTimer.Stop(); - // Live filtering only while searching - without a search every host is shown anyway, but it would still // listen to the filtered properties of every row and filter a row again on every update var liveShaping = (ICollectionViewLiveShaping)ResultsView; diff --git a/Source/NETworkManager/ViewModels/ImportProfilesResultViewModel.cs b/Source/NETworkManager/ViewModels/ImportProfilesResultViewModel.cs index d5d220b440..1c022e123c 100644 --- a/Source/NETworkManager/ViewModels/ImportProfilesResultViewModel.cs +++ b/Source/NETworkManager/ViewModels/ImportProfilesResultViewModel.cs @@ -5,13 +5,13 @@ using System.Linq; using System.Windows.Data; using System.Windows.Input; -using System.Windows.Threading; using log4net; using NETworkManager.Localization; using NETworkManager.Models.RDAP; using NETworkManager.Profiles; using NETworkManager.Settings; using NETworkManager.Utilities; +using NETworkManager.Utilities.WPF; namespace NETworkManager.ViewModels; @@ -20,7 +20,7 @@ public sealed class ImportProfilesResultViewModel : ViewModelBase private static readonly ILog Log = LogManager.GetLogger(typeof(ImportProfilesResultViewModel)); private readonly Action _completedCallback; - private readonly DispatcherTimer _searchDispatcherTimer = new(); + private readonly SearchDelay _searchDelay; public ImportProfilesResultViewModel(IReadOnlyList candidates, ProfileImportSource importSource, string targetGroup, Action backCallback, Action cancelCallback, Action completedCallback) { @@ -50,8 +50,7 @@ public ImportProfilesResultViewModel(IReadOnlyList candi CandidatesView = CollectionViewSource.GetDefaultView(Candidates); CandidatesView.Filter = FilterCandidate; - _searchDispatcherTimer.Interval = GlobalStaticConfiguration.SearchDispatcherTimerTimeSpan; - _searchDispatcherTimer.Tick += SearchDispatcherTimer_Tick; + _searchDelay = new SearchDelay(GlobalStaticConfiguration.SearchDispatcherTimerTimeSpan, ApplySearch); ApplicationOptions = BuildApplicationOptions(); foreach (var tool in ApplicationOptions) @@ -78,8 +77,7 @@ public string Search field = value; IsSearching = true; - _searchDispatcherTimer.Stop(); - _searchDispatcherTimer.Start(); + _searchDelay.Restart(); OnPropertyChanged(); } @@ -308,23 +306,17 @@ private void OnApplicationOptionChanged(object sender, PropertyChangedEventArgs HasAnyEnabledApplication = ApplicationOptions.Any(t => t.IsEnabled); } - private void SearchDispatcherTimer_Tick(object sender, EventArgs e) - { - ApplySearch(); - } - /// /// Gets the command to filter right away (Enter in the search box, see /// ). /// - public ICommand ApplySearchCommand => new RelayCommand(_ => ApplySearch()); + public ICommand ApplySearchCommand => new RelayCommand(_ => _searchDelay.Apply()); /// /// Filters with the current right away. /// private void ApplySearch() { - _searchDispatcherTimer.Stop(); CandidatesView.Refresh(); IsSearching = false; } diff --git a/Source/NETworkManager/ViewModels/ListenersViewModel.cs b/Source/NETworkManager/ViewModels/ListenersViewModel.cs index 8d20adc07f..3494da8d3f 100644 --- a/Source/NETworkManager/ViewModels/ListenersViewModel.cs +++ b/Source/NETworkManager/ViewModels/ListenersViewModel.cs @@ -6,6 +6,7 @@ using NETworkManager.Models.Network; using NETworkManager.Settings; using NETworkManager.Utilities; +using NETworkManager.Utilities.WPF; using NETworkManager.Views; using System; using System.Collections; @@ -33,8 +34,7 @@ public class ListenersViewModel : ViewModelBase /// public ListenersViewModel() { - _searchDispatcherTimer.Interval = GlobalStaticConfiguration.SearchDispatcherTimerTimeSpan; - _searchDispatcherTimer.Tick += SearchDispatcherTimer_Tick; + _searchDelay = new SearchDelay(GlobalStaticConfiguration.SearchDispatcherTimerTimeSpan, ApplySearch); _isLoading = true; @@ -114,7 +114,7 @@ private async void AutoRefreshTimer_Tick(object sender, EventArgs e) /// Delays filtering while the user is typing (see ), so a large list is not filtered on /// every key press. /// - private readonly DispatcherTimer _searchDispatcherTimer = new(); + private readonly SearchDelay _searchDelay; /// /// Gets or sets the search text. @@ -129,9 +129,8 @@ public string Search field = value; - // Filter after a short delay while typing (see _searchDispatcherTimer) - _searchDispatcherTimer.Stop(); - _searchDispatcherTimer.Start(); + // Filter after a short delay while typing (see _searchDelay) + _searchDelay.Restart(); OnPropertyChanged(); } @@ -437,27 +436,17 @@ public void OnViewHide() _autoRefreshTimer.Stop(); } - /// - /// Filters once the user stopped typing (see ). - /// - private void SearchDispatcherTimer_Tick(object sender, EventArgs e) - { - ApplySearch(); - } - /// /// Gets the command to filter right away (Enter in the search box, see /// ). /// - public ICommand ApplySearchCommand => new RelayCommand(_ => ApplySearch()); + public ICommand ApplySearchCommand => new RelayCommand(_ => _searchDelay.Apply()); /// /// Filters with the current right away. /// private void ApplySearch() { - _searchDispatcherTimer.Stop(); - ResultsView.Refresh(); } diff --git a/Source/NETworkManager/ViewModels/NeighborTableViewModel.cs b/Source/NETworkManager/ViewModels/NeighborTableViewModel.cs index 7fd8efe303..9e5ac0359c 100644 --- a/Source/NETworkManager/ViewModels/NeighborTableViewModel.cs +++ b/Source/NETworkManager/ViewModels/NeighborTableViewModel.cs @@ -6,6 +6,7 @@ using NETworkManager.Models.Network; using NETworkManager.Settings; using NETworkManager.Utilities; +using NETworkManager.Utilities.WPF; using NETworkManager.Views; using System; using System.Collections; @@ -32,15 +33,14 @@ public class NeighborTableViewModel : ViewModelBase /// Delays filtering while the user is typing (see ), so a large list is not filtered on /// every key press. /// - private readonly DispatcherTimer _searchDispatcherTimer = new(); + private readonly SearchDelay _searchDelay; /// /// Initializes a new instance of the class. /// public NeighborTableViewModel() { - _searchDispatcherTimer.Interval = GlobalStaticConfiguration.SearchDispatcherTimerTimeSpan; - _searchDispatcherTimer.Tick += SearchDispatcherTimer_Tick; + _searchDelay = new SearchDelay(GlobalStaticConfiguration.SearchDispatcherTimerTimeSpan, ApplySearch); _isLoading = true; @@ -110,9 +110,8 @@ public string Search field = value; - // Filter after a short delay while typing (see _searchDispatcherTimer) - _searchDispatcherTimer.Stop(); - _searchDispatcherTimer.Start(); + // Filter after a short delay while typing (see _searchDelay) + _searchDelay.Restart(); OnPropertyChanged(); } @@ -515,27 +514,17 @@ private async void AutoRefreshTimer_Tick(object sender, EventArgs e) _autoRefreshTimer.Start(); } - /// - /// Filters once the user stopped typing (see ). - /// - private void SearchDispatcherTimer_Tick(object sender, EventArgs e) - { - ApplySearch(); - } - /// /// Gets the command to filter right away (Enter in the search box, see /// ). /// - public ICommand ApplySearchCommand => new RelayCommand(_ => ApplySearch()); + public ICommand ApplySearchCommand => new RelayCommand(_ => _searchDelay.Apply()); /// /// Filters with the current right away. /// private void ApplySearch() { - _searchDispatcherTimer.Stop(); - ResultsView.Refresh(); } diff --git a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs index 0c2055d72c..8062b9145c 100644 --- a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs @@ -10,6 +10,7 @@ using NETworkManager.Profiles; using NETworkManager.Settings; using NETworkManager.Utilities; +using NETworkManager.Utilities.WPF; using NETworkManager.Views; using System; using System.Collections; @@ -369,11 +370,11 @@ public bool IsStatusMessageDisplayed /// Delays filtering the results while the user is typing (see ), so a large result is not /// filtered on every key press. /// - private readonly DispatcherTimer _searchDispatcherTimer = new(); + private readonly SearchDelay _searchDelay; /// /// Gets or sets the search text for filtering the results (IP address, hostname, port, protocol, service, status). - /// The results are filtered after a short delay (see ). + /// The results are filtered after a short delay (see ). /// public string Search { @@ -385,8 +386,7 @@ public string Search field = value; - _searchDispatcherTimer.Stop(); - _searchDispatcherTimer.Start(); + _searchDelay.Restart(); OnPropertyChanged(); } @@ -501,8 +501,7 @@ public PortScannerViewModel(Guid tabId, string host, string port, bool start = t Matches(ResourceTranslator.Translate(ResourceIdentifier.PortState, info.State)); }; - _searchDispatcherTimer.Interval = GlobalStaticConfiguration.SearchDispatcherTimerTimeSpan; - _searchDispatcherTimer.Tick += SearchDispatcherTimer_Tick; + _searchDelay = new SearchDelay(GlobalStaticConfiguration.SearchDispatcherTimerTimeSpan, ApplySearch); LoadSettings(); } @@ -1116,27 +1115,17 @@ await DialogHelper.ShowMessageAsync(window, Strings.Error, #region Events - /// - /// Filters the results once the user stopped typing (see ). - /// - private void SearchDispatcherTimer_Tick(object sender, EventArgs e) - { - ApplySearch(); - } - /// /// Gets the command to filter the results right away (Enter in the search box, see /// ). /// - public ICommand ApplySearchCommand => new RelayCommand(_ => ApplySearch()); + public ICommand ApplySearchCommand => new RelayCommand(_ => _searchDelay.Apply()); /// /// Filters the results with the current right away (e.g. Enter in the search box). /// private void ApplySearch() { - _searchDispatcherTimer.Stop(); - ResultsView.Refresh(); } diff --git a/Source/NETworkManager/ViewModels/ProfileHostViewModelBase.cs b/Source/NETworkManager/ViewModels/ProfileHostViewModelBase.cs index 1cf7e58328..ad719ff64e 100644 --- a/Source/NETworkManager/ViewModels/ProfileHostViewModelBase.cs +++ b/Source/NETworkManager/ViewModels/ProfileHostViewModelBase.cs @@ -3,6 +3,7 @@ using NETworkManager.Profiles; using NETworkManager.Settings; using NETworkManager.Utilities; +using NETworkManager.Utilities.WPF; using System; using System.Collections.Generic; using System.Collections.ObjectModel; @@ -11,7 +12,6 @@ using System.Windows; using System.Windows.Data; using System.Windows.Input; -using System.Windows.Threading; namespace NETworkManager.ViewModels; @@ -24,7 +24,7 @@ public abstract class ProfileHostViewModelBase : ViewModelBase, IProfileHostView { #region Variables - private readonly DispatcherTimer _searchDispatcherTimer = new(); + private readonly SearchDelay _searchDelay; private bool _searchDisabled; private bool _isViewActive = true; @@ -95,8 +95,7 @@ public string Search if (!_searchDisabled) { IsSearching = true; - _searchDispatcherTimer.Stop(); - _searchDispatcherTimer.Start(); + _searchDelay.Restart(); } OnPropertyChanged(); @@ -199,6 +198,15 @@ public bool IsProfileFilterSet #region Constructor + /// + /// Initializes the search delay, before the constructor of the derived class (which may already set + /// ). + /// + protected ProfileHostViewModelBase() + { + _searchDelay = new SearchDelay(GlobalStaticConfiguration.SearchDispatcherTimerTimeSpan, ApplySearch); + } + /// /// Initializes the shared profile-panel state. Must be called by derived constructors after their own /// tool-specific initialization (and while _isLoading-equivalent guards, if any, are still active). @@ -217,9 +225,6 @@ protected void InitializeProfileHost() ProfileManager.OnProfilesUpdated += ProfileManager_OnProfilesUpdated; GroupExpanderStateStore.GroupExpandedChanged += GroupExpanderStateStore_GroupExpandedChanged; - - _searchDispatcherTimer.Interval = GlobalStaticConfiguration.SearchDispatcherTimerTimeSpan; - _searchDispatcherTimer.Tick += SearchDispatcherTimer_Tick; } #endregion @@ -505,10 +510,11 @@ private void GroupExpanderStateStore_GroupExpandedChanged(object sender, GroupEx SettingsManager.Current.SettingsChanged = true; } - private void SearchDispatcherTimer_Tick(object sender, EventArgs e) + /// + /// Filters the profiles with the current . + /// + private void ApplySearch() { - _searchDispatcherTimer.Stop(); - RefreshProfiles(); IsSearching = false; diff --git a/Source/NETworkManager/ViewModels/ProfilesViewModel.cs b/Source/NETworkManager/ViewModels/ProfilesViewModel.cs index 69399a1f4c..ca71abe814 100644 --- a/Source/NETworkManager/ViewModels/ProfilesViewModel.cs +++ b/Source/NETworkManager/ViewModels/ProfilesViewModel.cs @@ -1,6 +1,7 @@ using NETworkManager.Profiles; using NETworkManager.Settings; using NETworkManager.Utilities; +using NETworkManager.Utilities.WPF; using System; using System.Collections; using System.Collections.Generic; @@ -10,7 +11,6 @@ using System.Windows; using System.Windows.Data; using System.Windows.Input; -using System.Windows.Threading; namespace NETworkManager.ViewModels; @@ -28,15 +28,14 @@ public ProfilesViewModel() ProfileManager.OnProfilesUpdated += ProfileManager_OnProfilesUpdated; - _searchDispatcherTimer.Interval = GlobalStaticConfiguration.SearchDispatcherTimerTimeSpan; - _searchDispatcherTimer.Tick += SearchDispatcherTimer_Tick; + _searchDelay = new SearchDelay(GlobalStaticConfiguration.SearchDispatcherTimerTimeSpan, ApplySearch); } #endregion #region Variables - private readonly DispatcherTimer _searchDispatcherTimer = new(); + private readonly SearchDelay _searchDelay; private bool _searchDisabled; private bool _isViewActive = true; @@ -150,8 +149,7 @@ public string Search if (!_searchDisabled) { IsSearching = true; - _searchDispatcherTimer.Stop(); - _searchDispatcherTimer.Start(); + _searchDelay.Restart(); } OnPropertyChanged(); @@ -442,24 +440,17 @@ private void ProfileManager_OnProfilesUpdated(object sender, EventArgs e) RefreshProfiles(); } - private void SearchDispatcherTimer_Tick(object sender, EventArgs e) - { - ApplySearch(); - } - /// /// Gets the command to filter right away (Enter in the search box, see /// ). /// - public ICommand ApplySearchCommand => new RelayCommand(_ => ApplySearch()); + public ICommand ApplySearchCommand => new RelayCommand(_ => _searchDelay.Apply()); /// /// Filters with the current right away. /// private void ApplySearch() { - _searchDispatcherTimer.Stop(); - RefreshProfiles(); IsSearching = false; diff --git a/Source/NETworkManager/ViewModels/WiFiViewModel.cs b/Source/NETworkManager/ViewModels/WiFiViewModel.cs index 55579ef76f..842f712b1f 100644 --- a/Source/NETworkManager/ViewModels/WiFiViewModel.cs +++ b/Source/NETworkManager/ViewModels/WiFiViewModel.cs @@ -13,6 +13,7 @@ using NETworkManager.Models.Network; using NETworkManager.Settings; using NETworkManager.Utilities; +using NETworkManager.Utilities.WPF; using NETworkManager.Views; using SkiaSharp; using System; @@ -187,7 +188,7 @@ public AutoRefreshTimeInfo SelectedAutoRefreshTime /// Delays filtering while the user is typing (see ), so a large list is not filtered on /// every key press. /// - private readonly DispatcherTimer _searchDispatcherTimer = new(); + private readonly SearchDelay _searchDelay; public string Search { @@ -199,9 +200,8 @@ public string Search field = value; - // Filter after a short delay while typing (see _searchDispatcherTimer) - _searchDispatcherTimer.Stop(); - _searchDispatcherTimer.Start(); + // Filter after a short delay while typing (see _searchDelay) + _searchDelay.Restart(); OnPropertyChanged(); } @@ -486,8 +486,7 @@ private set public WiFiViewModel() { - _searchDispatcherTimer.Interval = GlobalStaticConfiguration.SearchDispatcherTimerTimeSpan; - _searchDispatcherTimer.Tick += SearchDispatcherTimer_Tick; + _searchDelay = new SearchDelay(GlobalStaticConfiguration.SearchDispatcherTimerTimeSpan, ApplySearch); _isLoading = true; @@ -1356,27 +1355,17 @@ private void HideConnectionStatusMessageTimer_Tick(object sender, EventArgs e) IsConnectionStatusMessageDisplayed = false; } - /// - /// Filters once the user stopped typing (see ). - /// - private void SearchDispatcherTimer_Tick(object sender, EventArgs e) - { - ApplySearch(); - } - /// /// Gets the command to filter right away (Enter in the search box, see /// ). /// - public ICommand ApplySearchCommand => new RelayCommand(_ => ApplySearch()); + public ICommand ApplySearchCommand => new RelayCommand(_ => _searchDelay.Apply()); /// /// Filters with the current right away. /// private void ApplySearch() { - _searchDispatcherTimer.Stop(); - NetworksView.Refresh(); } From 56c1399f362d0ede488b1dd5bc704f949713895b Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:36:38 +0200 Subject: [PATCH 112/146] Revert: IP Scanner reads the neighbor table for an unreachable host if it was not read yet (final review #1) An unreachable host that blocks ICMP still has an ARP entry from the ping, the first read of the table finds its MAC address. Comments describe this now. Co-Authored-By: Claude Opus 5.5 --- Source/NETworkManager.Models/Network/IPScanner.cs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/Source/NETworkManager.Models/Network/IPScanner.cs b/Source/NETworkManager.Models/Network/IPScanner.cs index bd22e0e480..f62f11742b 100644 --- a/Source/NETworkManager.Models/Network/IPScanner.cs +++ b/Source/NETworkManager.Models/Network/IPScanner.cs @@ -331,7 +331,8 @@ private async Task ScanHostAsync(IPAddress ipAddress, ParallelOptions portScanPa OnHostFound(new IPScannerHostFoundArgs(hostInfo)); - // An unreachable host never needs a new snapshot of the neighbor table + // An unreachable host doesn't need a new snapshot of the neighbor table - the table is only read + // if it was not read yet (e.g. a host that blocks ICMP still has an ARP entry from the ping) enrichTask = EnrichAsync(ipAddress, hostInfo, DateTime.MinValue, networkInterfaces, cancellationToken); } @@ -459,7 +460,7 @@ await Task.WhenAll( var macAddress = string.Empty; // 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 never needs a new snapshot. + // 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); @@ -496,7 +497,10 @@ await Task.WhenAll( /// that finish at the same time share one read. /// /// IP address of the host (in a local subnet, see ). - /// Time the host answered, to use the cache only. + /// + /// Time the host answered, to use the cache - or to read the table if it was not + /// read yet. + /// /// Token to monitor for cancellation requests. /// MAC address or an empty string. private async Task GetMACAddressFromNeighborTableAsync(IPAddress ipAddress, DateTime discoveredAt, @@ -512,9 +516,8 @@ private async Task GetMACAddressFromNeighborTableAsync(IPAddress ipAddre if (_neighborTable.TryGetValue(ipAddress, out var macAddress)) return macAddress; - // Already read after the host answered -> no entry. An unreachable host (MinValue) uses the cache only, - // also if the table was not read yet (MinValue > MinValue is false) - if (discoveredAt == DateTime.MinValue || _neighborTableTimestamp > discoveredAt) + // Already read after the host answered -> no entry + if (_neighborTableTimestamp > discoveredAt) return string.Empty; // Timestamp before reading the table, so entries created while reading are covered by the next read From f083c47d867653444e1d9430739a5c7539cede32 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:36:47 +0200 Subject: [PATCH 113/146] Fix: Port Scanner hid an error of a scan that didn't start when the ports were changed (final review #2) Co-Authored-By: Claude Opus 5.5 --- .../ViewModels/PortScannerViewModel.cs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs index 8062b9145c..2ef5997c8f 100644 --- a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs @@ -132,13 +132,17 @@ public string Ports OnPropertyChanged(); // 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 + // 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) if (_message != null) { - _message = null; + if (StatusMessage == _message) + { + IsStatusMessageDisplayed = false; + StatusMessage = string.Empty; + } - IsStatusMessageDisplayed = false; - StatusMessage = string.Empty; + _message = null; } } } From 34853469d9ea63296db5ea2fa7ac7ead470817d7 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:38:02 +0200 Subject: [PATCH 114/146] Refactor: Port Scanner ping mode computed once, ping pipeline without Task.Run and a second token (final review #5, #6) Co-Authored-By: Claude Opus 5.5 --- .../Network/PortScanner.cs | 73 ++++++++++--------- 1 file changed, 39 insertions(+), 34 deletions(-) diff --git a/Source/NETworkManager.Models/Network/PortScanner.cs b/Source/NETworkManager.Models/Network/PortScanner.cs index b332b55f8f..bdc436e87f 100644 --- a/Source/NETworkManager.Models/Network/PortScanner.cs +++ b/Source/NETworkManager.Models/Network/PortScanner.cs @@ -194,16 +194,18 @@ public void ScanAsync(IEnumerable<(IPAddress ipAddress, string hostname)> hosts, // hosts of the port scan (e.g. 4 for 1-1024) until its ping timed out. With the threads of the // settings, every host is pinged in its slot of the port scan, so Max. concurrent host threads also // limits the pings (e.g. to slow down a scan). - if (_options.PingHostsBeforeScan && _options.AutoAdjustThreads) - await ScanReachableHostsAsync(hosts, portCount, hostParallelOptions, ScanHostAsync, - cancellationToken).ConfigureAwait(false); + var pingAhead = _options.PingHostsBeforeScan && _options.AutoAdjustThreads; + var pingInSlot = _options.PingHostsBeforeScan && !pingAhead; + + if (pingAhead) + await ScanReachableHostsAsync(hosts, portCount, hostParallelOptions, ScanHostAsync) + .ConfigureAwait(false); else await Parallel.ForEachAsync(hosts, hostParallelOptions, ScanHostAsync).ConfigureAwait(false); async ValueTask ScanHostAsync((IPAddress ipAddress, string hostname) host, CancellationToken hostCt) { - if (_options.PingHostsBeforeScan && !_options.AutoAdjustThreads && - !await IsReachableAsync(host.ipAddress, portCount, hostCt).ConfigureAwait(false)) + if (pingInSlot && !await IsReachableAsync(host.ipAddress, portCount, hostCt).ConfigureAwait(false)) return; // Resolve Hostname (PTR) while the ports are scanned. Started with the first port probe of the @@ -282,46 +284,25 @@ await Parallel.ForEachAsync(ports, portParallelOptions, async (port, portCt) => /// /// Hosts to ping and scan. /// Number of ports per host (counted as scanned for a host that doesn't answer). - /// Options for the hosts that are scanned at the same time. + /// + /// Options for the hosts that are scanned at the same time, with the token to monitor for cancellation requests. + /// /// Scans the ports of a host. - /// Token to monitor for cancellation requests. private async Task ScanReachableHostsAsync(IEnumerable<(IPAddress ipAddress, string hostname)> hosts, int portCount, ParallelOptions hostParallelOptions, - Func<(IPAddress ipAddress, string hostname), CancellationToken, ValueTask> scanHost, - CancellationToken cancellationToken) + Func<(IPAddress ipAddress, string hostname), CancellationToken, ValueTask> scanHost) { var reachableHosts = Channel.CreateUnbounded<(IPAddress ipAddress, string hostname)>(); // Also stops the pings if the port scan fails - using var pingCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - - var pingTask = Task.Run(async () => - { - try - { - var pingParallelOptions = new ParallelOptions - { - CancellationToken = pingCts.Token, - MaxDegreeOfParallelism = AutoMaxPingThreads - }; + using var pingCts = CancellationTokenSource.CreateLinkedTokenSource(hostParallelOptions.CancellationToken); - await Parallel.ForEachAsync(hosts, pingParallelOptions, async (host, ct) => - { - if (await IsReachableAsync(host.ipAddress, portCount, ct).ConfigureAwait(false)) - reachableHosts.Writer.TryWrite(host); // Unbounded, always written - }).ConfigureAwait(false); - } - finally - { - // Also on cancellation or an error (raised below), the port scan ends with the hosts written so far - reachableHosts.Writer.TryComplete(); - } - }, CancellationToken.None); + var pingTask = PingHostsAsync(); try { - await Parallel.ForEachAsync(reachableHosts.Reader.ReadAllAsync(cancellationToken), hostParallelOptions, - scanHost).ConfigureAwait(false); + await Parallel.ForEachAsync(reachableHosts.Reader.ReadAllAsync(hostParallelOptions.CancellationToken), + hostParallelOptions, scanHost).ConfigureAwait(false); } finally { @@ -341,6 +322,30 @@ await Parallel.ForEachAsync(reachableHosts.Reader.ReadAllAsync(cancellationToken // Error of the pings await pingTask.ConfigureAwait(false); + + // Writes the hosts that answer to the channel, the port scan reads them while the other hosts are pinged + async Task PingHostsAsync() + { + try + { + var pingParallelOptions = new ParallelOptions + { + CancellationToken = pingCts.Token, + MaxDegreeOfParallelism = AutoMaxPingThreads + }; + + await Parallel.ForEachAsync(hosts, pingParallelOptions, async (host, ct) => + { + if (await IsReachableAsync(host.ipAddress, portCount, ct).ConfigureAwait(false)) + reachableHosts.Writer.TryWrite(host); // Unbounded, always written + }).ConfigureAwait(false); + } + finally + { + // Also on cancellation or an error (raised above), the port scan ends with the hosts written so far + reachableHosts.Writer.TryComplete(); + } + } } /// From 211c099bff886243e2a30bbcd707b88d6ce6aa00 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:38:25 +0200 Subject: [PATCH 115/146] Refactor: Search delay provides the command to apply the search right away (final review #7) Co-Authored-By: Claude Opus 5.5 --- Source/NETworkManager.Utilities.WPF/DataGridHelper.cs | 4 ++-- .../NETworkManager.Utilities.WPF.csproj | 3 +++ Source/NETworkManager.Utilities.WPF/SearchDelay.cs | 9 +++++++++ Source/NETworkManager/ViewModels/ConnectionsViewModel.cs | 2 +- Source/NETworkManager/ViewModels/FirewallViewModel.cs | 2 +- .../ViewModels/HostsFileEditorViewModel.cs | 2 +- Source/NETworkManager/ViewModels/IPScannerViewModel.cs | 2 +- .../ViewModels/ImportProfilesResultViewModel.cs | 2 +- Source/NETworkManager/ViewModels/ListenersViewModel.cs | 2 +- .../NETworkManager/ViewModels/NeighborTableViewModel.cs | 2 +- Source/NETworkManager/ViewModels/PortScannerViewModel.cs | 2 +- Source/NETworkManager/ViewModels/ProfilesViewModel.cs | 2 +- Source/NETworkManager/ViewModels/WiFiViewModel.cs | 2 +- 13 files changed, 24 insertions(+), 12 deletions(-) diff --git a/Source/NETworkManager.Utilities.WPF/DataGridHelper.cs b/Source/NETworkManager.Utilities.WPF/DataGridHelper.cs index a18add1162..4d8c51df23 100644 --- a/Source/NETworkManager.Utilities.WPF/DataGridHelper.cs +++ b/Source/NETworkManager.Utilities.WPF/DataGridHelper.cs @@ -283,9 +283,9 @@ private double GetAvailableWidth() private static ScrollViewer FindScrollViewer(DependencyObject parent) { - for (var i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++) + for (var i = 0; i < System.Windows.Media.VisualTreeHelper.GetChildrenCount(parent); i++) { - var child = VisualTreeHelper.GetChild(parent, i); + var child = System.Windows.Media.VisualTreeHelper.GetChild(parent, i); if (child is ScrollViewer scrollViewer) return scrollViewer; diff --git a/Source/NETworkManager.Utilities.WPF/NETworkManager.Utilities.WPF.csproj b/Source/NETworkManager.Utilities.WPF/NETworkManager.Utilities.WPF.csproj index 34ffbec737..59c1349ae6 100644 --- a/Source/NETworkManager.Utilities.WPF/NETworkManager.Utilities.WPF.csproj +++ b/Source/NETworkManager.Utilities.WPF/NETworkManager.Utilities.WPF.csproj @@ -16,4 +16,7 @@ + + + diff --git a/Source/NETworkManager.Utilities.WPF/SearchDelay.cs b/Source/NETworkManager.Utilities.WPF/SearchDelay.cs index b2cc01af35..d4b02977c1 100644 --- a/Source/NETworkManager.Utilities.WPF/SearchDelay.cs +++ b/Source/NETworkManager.Utilities.WPF/SearchDelay.cs @@ -1,4 +1,5 @@ using System; +using System.Windows.Input; using System.Windows.Threading; namespace NETworkManager.Utilities.WPF; @@ -25,8 +26,16 @@ public SearchDelay(TimeSpan interval, Action applySearch) _timer = new DispatcherTimer { Interval = interval }; _timer.Tick += (_, _) => Apply(); + + ApplyCommand = new RelayCommand(_ => Apply()); } + /// + /// Gets the command to apply the search right away (see ), e.g. bound to Enter in the search + /// box. + /// + public ICommand ApplyCommand { get; } + /// /// Starts the delay again (the search text was changed). /// diff --git a/Source/NETworkManager/ViewModels/ConnectionsViewModel.cs b/Source/NETworkManager/ViewModels/ConnectionsViewModel.cs index af0325273b..8f94813720 100644 --- a/Source/NETworkManager/ViewModels/ConnectionsViewModel.cs +++ b/Source/NETworkManager/ViewModels/ConnectionsViewModel.cs @@ -452,7 +452,7 @@ public void OnViewHide() /// Gets the command to filter right away (Enter in the search box, see /// ). /// - public ICommand ApplySearchCommand => new RelayCommand(_ => _searchDelay.Apply()); + public ICommand ApplySearchCommand => _searchDelay.ApplyCommand; /// /// Filters with the current right away. diff --git a/Source/NETworkManager/ViewModels/FirewallViewModel.cs b/Source/NETworkManager/ViewModels/FirewallViewModel.cs index bf61b204af..1a55443650 100644 --- a/Source/NETworkManager/ViewModels/FirewallViewModel.cs +++ b/Source/NETworkManager/ViewModels/FirewallViewModel.cs @@ -915,7 +915,7 @@ private static List ParseAddressesString(string value) /// Gets the command to filter right away (Enter in the search box, see /// ). /// - public ICommand ApplySearchCommand => new RelayCommand(_ => _searchDelay.Apply()); + public ICommand ApplySearchCommand => _searchDelay.ApplyCommand; /// /// Filters with the current right away. diff --git a/Source/NETworkManager/ViewModels/HostsFileEditorViewModel.cs b/Source/NETworkManager/ViewModels/HostsFileEditorViewModel.cs index 0973cdb821..ab7bcc42ee 100644 --- a/Source/NETworkManager/ViewModels/HostsFileEditorViewModel.cs +++ b/Source/NETworkManager/ViewModels/HostsFileEditorViewModel.cs @@ -772,7 +772,7 @@ public void OnViewHide() /// Gets the command to filter right away (Enter in the search box, see /// ). /// - public ICommand ApplySearchCommand => new RelayCommand(_ => _searchDelay.Apply()); + public ICommand ApplySearchCommand => _searchDelay.ApplyCommand; /// /// Filters with the current right away. diff --git a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs index 5454deca1a..346c15df04 100644 --- a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs @@ -1162,7 +1162,7 @@ public void OnClose() /// Gets the command to filter the results right away (Enter in the search box, see /// ). /// - public ICommand ApplySearchCommand => new RelayCommand(_ => _searchDelay.Apply()); + public ICommand ApplySearchCommand => _searchDelay.ApplyCommand; /// /// Filters the results with the current right away (e.g. Enter in the search box). diff --git a/Source/NETworkManager/ViewModels/ImportProfilesResultViewModel.cs b/Source/NETworkManager/ViewModels/ImportProfilesResultViewModel.cs index 1c022e123c..7e454280fd 100644 --- a/Source/NETworkManager/ViewModels/ImportProfilesResultViewModel.cs +++ b/Source/NETworkManager/ViewModels/ImportProfilesResultViewModel.cs @@ -310,7 +310,7 @@ private void OnApplicationOptionChanged(object sender, PropertyChangedEventArgs /// Gets the command to filter right away (Enter in the search box, see /// ). /// - public ICommand ApplySearchCommand => new RelayCommand(_ => _searchDelay.Apply()); + public ICommand ApplySearchCommand => _searchDelay.ApplyCommand; /// /// Filters with the current right away. diff --git a/Source/NETworkManager/ViewModels/ListenersViewModel.cs b/Source/NETworkManager/ViewModels/ListenersViewModel.cs index 3494da8d3f..0430f71f6c 100644 --- a/Source/NETworkManager/ViewModels/ListenersViewModel.cs +++ b/Source/NETworkManager/ViewModels/ListenersViewModel.cs @@ -440,7 +440,7 @@ public void OnViewHide() /// Gets the command to filter right away (Enter in the search box, see /// ). /// - public ICommand ApplySearchCommand => new RelayCommand(_ => _searchDelay.Apply()); + public ICommand ApplySearchCommand => _searchDelay.ApplyCommand; /// /// Filters with the current right away. diff --git a/Source/NETworkManager/ViewModels/NeighborTableViewModel.cs b/Source/NETworkManager/ViewModels/NeighborTableViewModel.cs index 9e5ac0359c..1c227e2682 100644 --- a/Source/NETworkManager/ViewModels/NeighborTableViewModel.cs +++ b/Source/NETworkManager/ViewModels/NeighborTableViewModel.cs @@ -518,7 +518,7 @@ private async void AutoRefreshTimer_Tick(object sender, EventArgs e) /// Gets the command to filter right away (Enter in the search box, see /// ). /// - public ICommand ApplySearchCommand => new RelayCommand(_ => _searchDelay.Apply()); + public ICommand ApplySearchCommand => _searchDelay.ApplyCommand; /// /// Filters with the current right away. diff --git a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs index 2ef5997c8f..abd2b0a3e0 100644 --- a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs @@ -1123,7 +1123,7 @@ await DialogHelper.ShowMessageAsync(window, Strings.Error, /// Gets the command to filter the results right away (Enter in the search box, see /// ). /// - public ICommand ApplySearchCommand => new RelayCommand(_ => _searchDelay.Apply()); + public ICommand ApplySearchCommand => _searchDelay.ApplyCommand; /// /// Filters the results with the current right away (e.g. Enter in the search box). diff --git a/Source/NETworkManager/ViewModels/ProfilesViewModel.cs b/Source/NETworkManager/ViewModels/ProfilesViewModel.cs index ca71abe814..821e88860b 100644 --- a/Source/NETworkManager/ViewModels/ProfilesViewModel.cs +++ b/Source/NETworkManager/ViewModels/ProfilesViewModel.cs @@ -444,7 +444,7 @@ private void ProfileManager_OnProfilesUpdated(object sender, EventArgs e) /// Gets the command to filter right away (Enter in the search box, see /// ). /// - public ICommand ApplySearchCommand => new RelayCommand(_ => _searchDelay.Apply()); + public ICommand ApplySearchCommand => _searchDelay.ApplyCommand; /// /// Filters with the current right away. diff --git a/Source/NETworkManager/ViewModels/WiFiViewModel.cs b/Source/NETworkManager/ViewModels/WiFiViewModel.cs index 842f712b1f..d43735014b 100644 --- a/Source/NETworkManager/ViewModels/WiFiViewModel.cs +++ b/Source/NETworkManager/ViewModels/WiFiViewModel.cs @@ -1359,7 +1359,7 @@ private void HideConnectionStatusMessageTimer_Tick(object sender, EventArgs e) /// Gets the command to filter right away (Enter in the search box, see /// ). /// - public ICommand ApplySearchCommand => new RelayCommand(_ => _searchDelay.Apply()); + public ICommand ApplySearchCommand => _searchDelay.ApplyCommand; /// /// Filters with the current right away. From 3374afde24bb98225b5119572140915a4fa95f43 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:42:19 +0200 Subject: [PATCH 116/146] Fix: Hostname without the trailing dot only for applications that resolve it via Windows, shared helper (final review #3, #4) DNSClientHelper.SanitizeHostname removes the trailing dot (also of a single-label name like "gateway."). ApplicationManager.GetHostForApplication uses it for Remote Desktop, PuTTY, TigerVNC, PowerShell and the Web Console; tools that resolve via NETworkManager keep "gateway." so the DNS suffix is not appended. Used by the IP Scanner, Port Scanner and Traceroute, the Web Console URL, custom commands and the profile name. WebConsoleUrlHelper.CreateUrl throws for a missing host instead of a NullReferenceException. Co-Authored-By: Claude Opus 5.5 --- .../ApplicationManager.cs | 18 ++++++++++ .../Network/WebConsoleUrlHelper.cs | 11 +++--- .../ApplicationManagerTests.cs | 34 +++++++++++++++++++ .../Network/WebConsoleUrlHelperTests.cs | 12 ++++++- .../Utilities/DNSClientHelperTests.cs | 12 +++++++ .../DNSClientHelper.cs | 21 +++++++++++- .../ViewModels/IPScannerViewModel.cs | 12 ++++--- .../ViewModels/PortScannerViewModel.cs | 7 ++-- .../ViewModels/TracerouteViewModel.cs | 5 ++- 9 files changed, 118 insertions(+), 14 deletions(-) create mode 100644 Source/NETworkManager.Tests/ApplicationManagerTests.cs diff --git a/Source/NETworkManager.Models/ApplicationManager.cs b/Source/NETworkManager.Models/ApplicationManager.cs index 8e7220793b..1fa2ba8297 100644 --- a/Source/NETworkManager.Models/ApplicationManager.cs +++ b/Source/NETworkManager.Models/ApplicationManager.cs @@ -1,4 +1,5 @@ using MahApps.Metro.IconPacks; +using NETworkManager.Utilities; using System; using System.Collections.Generic; using System.Linq; @@ -32,6 +33,23 @@ public static IEnumerable GetDefaultList() #pragma warning restore CS0618 } + /// + /// Gets the host to open in an application from the hostname or IP address of a result (e.g. IP Scanner): for an + /// application that resolves it via Windows (Remote Desktop, PuTTY, TigerVNC, PowerShell, Web Console) without + /// the trailing dot (see ). The other applications resolve it via + /// NETworkManager and keep the dot of a single-label name (see ). + /// + /// Application to open. + /// Hostname or IP address. + /// Host for the application. + public static string GetHostForApplication(ApplicationName name, string host) + { + return name is ApplicationName.RemoteDesktop or ApplicationName.PuTTY or ApplicationName.TigerVNC + or ApplicationName.PowerShell or ApplicationName.WebConsole + ? DNSClientHelper.SanitizeHostname(host) + : host; + } + /// /// Method will return the icon based on . /// diff --git a/Source/NETworkManager.Models/Network/WebConsoleUrlHelper.cs b/Source/NETworkManager.Models/Network/WebConsoleUrlHelper.cs index c522430a83..93368c39ad 100644 --- a/Source/NETworkManager.Models/Network/WebConsoleUrlHelper.cs +++ b/Source/NETworkManager.Models/Network/WebConsoleUrlHelper.cs @@ -1,7 +1,9 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; using System.Net; using System.Net.Sockets; +using NETworkManager.Utilities; namespace NETworkManager.Models.Network; @@ -50,6 +52,8 @@ public static string CreateUrl(string host, IEnumerable openPorts) /// URL. public static string CreateUrl(string host, int port) { + 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) @@ -60,9 +64,8 @@ public static string CreateUrl(string host, int port) } else { - // Trim the dot a single-label name still has (e.g. "gateway."), so Windows resolves it like a name typed - // by the user (DNS suffix search, LLMNR / NetBIOS) and the name matches the certificate (https) - host = host.TrimEnd('.'); + // The browser resolves it via Windows (e.g. "gateway." -> "gateway") + host = DNSClientHelper.SanitizeHostname(host); } return port is 80 or 443 ? $"{scheme}://{host}/" : $"{scheme}://{host}:{port}/"; diff --git a/Source/NETworkManager.Tests/ApplicationManagerTests.cs b/Source/NETworkManager.Tests/ApplicationManagerTests.cs new file mode 100644 index 0000000000..34bcb9f75a --- /dev/null +++ b/Source/NETworkManager.Tests/ApplicationManagerTests.cs @@ -0,0 +1,34 @@ +using NETworkManager.Models; +using Xunit; + +namespace NETworkManager.Tests; + +public class ApplicationManagerTests +{ + [Theory] + [InlineData(ApplicationName.RemoteDesktop)] + [InlineData(ApplicationName.PuTTY)] + [InlineData(ApplicationName.TigerVNC)] + [InlineData(ApplicationName.PowerShell)] + [InlineData(ApplicationName.WebConsole)] + public void HostForApplicationResolvedViaWindowsWithoutTrailingDot(ApplicationName name) + { + Assert.Equal("gateway", ApplicationManager.GetHostForApplication(name, "gateway.")); + } + + [Theory] + [InlineData(ApplicationName.PingMonitor)] + [InlineData(ApplicationName.Traceroute)] + [InlineData(ApplicationName.DNSLookup)] + [InlineData(ApplicationName.PortScanner)] + public void HostForApplicationResolvedViaNETworkManagerKeepsTrailingDot(ApplicationName name) + { + Assert.Equal("gateway.", ApplicationManager.GetHostForApplication(name, "gateway.")); + } + + [Fact] + public void HostForApplicationKeepsIPAddress() + { + Assert.Equal("10.0.0.1", ApplicationManager.GetHostForApplication(ApplicationName.RemoteDesktop, "10.0.0.1")); + } +} diff --git a/Source/NETworkManager.Tests/Network/WebConsoleUrlHelperTests.cs b/Source/NETworkManager.Tests/Network/WebConsoleUrlHelperTests.cs index e31eb70866..9689b2435a 100644 --- a/Source/NETworkManager.Tests/Network/WebConsoleUrlHelperTests.cs +++ b/Source/NETworkManager.Tests/Network/WebConsoleUrlHelperTests.cs @@ -1,4 +1,5 @@ -using NETworkManager.Models.Network; +using System; +using NETworkManager.Models.Network; using Xunit; namespace NETworkManager.Tests.Network; @@ -19,6 +20,15 @@ public void CreatesUrlForPort(string host, int port, string expected) Assert.Equal(expected, WebConsoleUrlHelper.CreateUrl(host, port)); } + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void ThrowsWithoutHost(string host) + { + Assert.ThrowsAny(() => WebConsoleUrlHelper.CreateUrl(host, 80)); + } + [Theory] [InlineData(new[] { 22, 80, 443 }, "https://10.0.0.1/")] [InlineData(new[] { 80, 8443 }, "http://10.0.0.1/")] diff --git a/Source/NETworkManager.Tests/Utilities/DNSClientHelperTests.cs b/Source/NETworkManager.Tests/Utilities/DNSClientHelperTests.cs index 3edafdcd34..5fce1a4b6a 100644 --- a/Source/NETworkManager.Tests/Utilities/DNSClientHelperTests.cs +++ b/Source/NETworkManager.Tests/Utilities/DNSClientHelperTests.cs @@ -30,6 +30,18 @@ public void KeepsNullOrEmpty(string name) Assert.Equal(name, DNSClientHelper.TrimTrailingDot(name)); } + [Theory] + [InlineData("gateway.", "gateway")] + [InlineData("server.example.com.", "server.example.com")] + [InlineData("gateway", "gateway")] + [InlineData(".", ".")] + [InlineData("", "")] + [InlineData(null, null)] + public void SanitizesHostname(string hostname, string expected) + { + Assert.Equal(expected, DNSClientHelper.SanitizeHostname(hostname)); + } + [Fact] public void TrimmedNameIsNotBareHostname() { diff --git a/Source/NETworkManager.Utilities/DNSClientHelper.cs b/Source/NETworkManager.Utilities/DNSClientHelper.cs index 038f8439d4..326a424eff 100644 --- a/Source/NETworkManager.Utilities/DNSClientHelper.cs +++ b/Source/NETworkManager.Utilities/DNSClientHelper.cs @@ -25,7 +25,8 @@ public static bool IsBareHostname(string query) /// Removes the trailing dot of a fully qualified domain name (e.g. "server.example.com." -> /// "server.example.com"). A single-label name (e.g. "gateway.") keeps it: without the dot it would be /// a bare hostname (see ) and the DNS suffix would be appended when it is - /// resolved again (e.g. when opened in another tool). + /// resolved again by NETworkManager (e.g. when opened in Ping or Traceroute). For an application that + /// resolves it via Windows, see . /// /// Domain name, e.g. from a PTR record. /// Domain name without the trailing dot, unless it is a single-label name. @@ -39,6 +40,24 @@ public static string TrimTrailingDot(string name) return trimmed.Contains('.') ? trimmed : name; } + /// + /// Prepares a hostname (e.g. from a PTR record) for an application that resolves it via Windows (e.g. + /// Remote Desktop, PuTTY, the browser of the Web Console or a custom command): also removes the trailing dot of + /// a single-label name (e.g. "gateway." -> "gateway"), so Windows resolves it like a name typed by the user + /// (DNS suffix search, LLMNR / NetBIOS) and it matches the name of a certificate (https). + /// + /// Hostname. + /// Hostname without the trailing dot, the hostname itself if it is null, empty or only dots. + public static string SanitizeHostname(string hostname) + { + if (string.IsNullOrEmpty(hostname)) + return hostname; + + var trimmed = hostname.TrimEnd('.'); + + return trimmed.Length > 0 ? trimmed : hostname; + } + /// /// Detect the DNS suffix to use for suffix-appending. Prefers the Windows "Primary DNS Suffix" /// (), falling back to the connection-specific suffix diff --git a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs index 346c15df04..b4245aea3b 100644 --- a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs @@ -498,7 +498,10 @@ private void RedirectDataToApplicationAction(object name) if (name is not ApplicationName applicationName) return; - NavigationManager.Open(new ApplicationRequest(applicationName) { Target = GetSelectedHost() }); + NavigationManager.Open(new ApplicationRequest(applicationName) + { + Target = ApplicationManager.GetHostForApplication(applicationName, GetSelectedHost()) + }); } /// @@ -603,7 +606,7 @@ private async void AddProfileSelectedHostAction() // Only displayed - also trim the dot a single-label name still has (e.g. "gateway.") Name = string.IsNullOrEmpty(SelectedResult.Hostname) ? SelectedResult.PingInfo.IPAddress.ToString() - : SelectedResult.Hostname.TrimEnd('.'), + : DNSClientHelper.SanitizeHostname(SelectedResult.Hostname), Host = SelectedResult.PingInfo.IPAddress.ToString(), // Additional data @@ -1028,9 +1031,8 @@ private async Task CustomCommand(object guid) return; } - // Replace vars. Also trim the dot a single-label name still has (e.g. "gateway."), so Windows resolves - // it like a name typed by the user (DNS suffix search, LLMNR / NetBIOS) - var hostname = SelectedResult.Hostname?.TrimEnd('.') ?? ""; + // Replace vars. The command resolves the hostname via Windows (e.g. "gateway." -> "gateway") + var hostname = DNSClientHelper.SanitizeHostname(SelectedResult.Hostname) ?? ""; // The hostname comes from the network (PTR record or NetBIOS name) and could contain characters // like "&" that would run additional commands (e.g. "cmd.exe /c ... {{Hostname}}") - only use valid ones diff --git a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs index abd2b0a3e0..54fc1184d2 100644 --- a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs @@ -578,7 +578,10 @@ private void RedirectDataToApplicationAction(object name) if (name is not ApplicationName applicationName) return; - NavigationManager.Open(new ApplicationRequest(applicationName) { Target = GetSelectedHost() }); + NavigationManager.Open(new ApplicationRequest(applicationName) + { + Target = ApplicationManager.GetHostForApplication(applicationName, GetSelectedHost()) + }); } /// @@ -646,7 +649,7 @@ private async void AddProfileSelectedHostAction() // Only displayed - also trim the dot a single-label name still has (e.g. "gateway.") Name = string.IsNullOrEmpty(SelectedResult.Hostname) ? SelectedResult.IPAddress.ToString() - : SelectedResult.Hostname.TrimEnd('.'), + : DNSClientHelper.SanitizeHostname(SelectedResult.Hostname), Host = SelectedResult.IPAddress.ToString(), // Additional data diff --git a/Source/NETworkManager/ViewModels/TracerouteViewModel.cs b/Source/NETworkManager/ViewModels/TracerouteViewModel.cs index 1cb383e31e..193dd392a5 100644 --- a/Source/NETworkManager/ViewModels/TracerouteViewModel.cs +++ b/Source/NETworkManager/ViewModels/TracerouteViewModel.cs @@ -310,7 +310,10 @@ private void RedirectDataToApplicationAction(object name) ? SelectedResult.Hostname : SelectedResult.IPAddress.ToString(); - NavigationManager.Open(new ApplicationRequest(applicationName) { Target = host }); + NavigationManager.Open(new ApplicationRequest(applicationName) + { + Target = ApplicationManager.GetHostForApplication(applicationName, host) + }); } /// From 6f605b905236e9933780b2d3ffd4cc3b01ef7257 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:56:27 +0200 Subject: [PATCH 117/146] Fix: Port Scanner message of the tab replaced by a message of the first scan (final final review #1) Co-Authored-By: Claude Opus 5.5 --- .../ViewModels/PortScannerViewModel.cs | 40 +++++++++---------- 1 file changed, 19 insertions(+), 21 deletions(-) diff --git a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs index 54fc1184d2..4b384a1bef 100644 --- a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs @@ -714,6 +714,17 @@ private void ExportAction() #region Methods + /// + /// Shows a message below the input, after the message that is already shown (e.g. the message of the tab or + /// hostnames that could not be resolved). The messages are cleared when a scan starts. + /// + /// Message to show. + private void AddStatusMessage(string message) + { + StatusMessage = IsStatusMessageDisplayed ? StatusMessage + Environment.NewLine + message : message; + IsStatusMessageDisplayed = true; + } + private async Task Start() { // Port profiles in the input (e.g. "Webserver; 8080") are replaced by their ports. The input is validated, but @@ -848,11 +859,8 @@ private async Task Start() // Show error message if (some) hostnames could not be resolved if (hosts.hostnamesNotResolved.Count > 0) - { - StatusMessage = - $"{Strings.TheFollowingHostnamesCouldNotBeResolved} {string.Join(", ", hosts.hostnamesNotResolved)}"; - IsStatusMessageDisplayed = true; - } + AddStatusMessage( + $"{Strings.TheFollowingHostnamesCouldNotBeResolved} {string.Join(", ", hosts.hostnamesNotResolved)}"); PortsToScan = ports.Length * hosts.hosts.Count; @@ -1296,36 +1304,26 @@ private void ScanComplete(object sender, ScanCompleteArgs e) PortsToScan = _rescanPortsToScan; } + // Keep a message from the preparation (e.g. the message of the tab or hostnames that could not be resolved) if (e.IsCanceled) { - StatusMessage = Strings.CanceledByUserMessage; - IsStatusMessageDisplayed = true; + AddStatusMessage(Strings.CanceledByUserMessage); } else if (e.Exception != null) { Log.Error("Error while scanning", e.Exception); - StatusMessage = e.Exception.Message; - IsStatusMessageDisplayed = true; + AddStatusMessage(e.Exception.Message); } else if (Results.Count == 0) { - // Keep a message from the preparation (e.g. hostnames that could not be resolved) - StatusMessage = IsStatusMessageDisplayed - ? StatusMessage + Environment.NewLine + Strings.NoOpenPortsFound - : Strings.NoOpenPortsFound; - IsStatusMessageDisplayed = true; + AddStatusMessage(Strings.NoOpenPortsFound); } // Hosts that were not scanned because they didn't answer the ping before the scan (option) if (!e.IsCanceled && e.Exception == null && sender is PortScanner { HostsSkipped: > 0 } pingScanner) - { - var message = string.Format(Strings.XHostsDidNotAnswerThePingAndWereNotScanned, - pingScanner.HostsSkipped); - - StatusMessage = IsStatusMessageDisplayed ? StatusMessage + Environment.NewLine + message : message; - IsStatusMessageDisplayed = true; - } + AddStatusMessage(string.Format(Strings.XHostsDidNotAnswerThePingAndWereNotScanned, + pingScanner.HostsSkipped)); PreparingScan = false; IsCanceling = false; From 1537d16e76ba137d33e11863bddccd6de77c0da6 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:56:56 +0200 Subject: [PATCH 118/146] Refactor: Target prepared for the application in NavigationManager.Open instead of every caller (final final review #2) Co-Authored-By: Claude Opus 5.5 --- Source/NETworkManager.Models/ApplicationManager.cs | 11 ++++++----- Source/NETworkManager/NavigationManager.cs | 13 +++++++++++-- .../NETworkManager/ViewModels/IPScannerViewModel.cs | 5 +---- .../ViewModels/PortScannerViewModel.cs | 5 +---- .../ViewModels/TracerouteViewModel.cs | 5 +---- 5 files changed, 20 insertions(+), 19 deletions(-) diff --git a/Source/NETworkManager.Models/ApplicationManager.cs b/Source/NETworkManager.Models/ApplicationManager.cs index 1fa2ba8297..7230a86585 100644 --- a/Source/NETworkManager.Models/ApplicationManager.cs +++ b/Source/NETworkManager.Models/ApplicationManager.cs @@ -34,13 +34,14 @@ public static IEnumerable GetDefaultList() } /// - /// Gets the host to open in an application from the hostname or IP address of a result (e.g. IP Scanner): for an - /// application that resolves it via Windows (Remote Desktop, PuTTY, TigerVNC, PowerShell, Web Console) without - /// the trailing dot (see ). The other applications resolve it via - /// NETworkManager and keep the dot of a single-label name (see ). + /// Gets the host to open in an application (e.g. the hostname of an IP Scanner result or the target of the run + /// command): for an application that resolves it via Windows (Remote Desktop, PuTTY, TigerVNC, PowerShell) + /// without the trailing dot (see ). The other applications resolve + /// it via NETworkManager and keep the dot of a single-label name (see ). + /// Used by NavigationManager.Open for every request. /// /// Application to open. - /// Hostname or IP address. + /// Hostname or IP address (or another target, e.g. a URL or IP range). /// Host for the application. public static string GetHostForApplication(ApplicationName name, string host) { diff --git a/Source/NETworkManager/NavigationManager.cs b/Source/NETworkManager/NavigationManager.cs index 1212629d41..72a25b6734 100644 --- a/Source/NETworkManager/NavigationManager.cs +++ b/Source/NETworkManager/NavigationManager.cs @@ -1,4 +1,5 @@ -using System; +using NETworkManager.Models; +using System; namespace NETworkManager; @@ -18,11 +19,19 @@ public static class NavigationManager public static event EventHandler SettingsRequested; /// - /// Opens an application and passes the request (target, profile, options) to it. + /// Opens an application and passes the request (target, profile, options) to it. The target is prepared for + /// the application (see ), e.g. "gateway." from a PTR + /// record -> "gateway" for Remote Desktop. /// /// Request with the application to open. public static void Open(ApplicationRequest request) { + if (request.Target != null) + request = request with + { + Target = ApplicationManager.GetHostForApplication(request.Application, request.Target) + }; + ApplicationRequested?.Invoke(null, request); } diff --git a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs index b4245aea3b..711759d344 100644 --- a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs @@ -498,10 +498,7 @@ private void RedirectDataToApplicationAction(object name) if (name is not ApplicationName applicationName) return; - NavigationManager.Open(new ApplicationRequest(applicationName) - { - Target = ApplicationManager.GetHostForApplication(applicationName, GetSelectedHost()) - }); + NavigationManager.Open(new ApplicationRequest(applicationName) { Target = GetSelectedHost() }); } /// diff --git a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs index 4b384a1bef..3be5a548d2 100644 --- a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs @@ -578,10 +578,7 @@ private void RedirectDataToApplicationAction(object name) if (name is not ApplicationName applicationName) return; - NavigationManager.Open(new ApplicationRequest(applicationName) - { - Target = ApplicationManager.GetHostForApplication(applicationName, GetSelectedHost()) - }); + NavigationManager.Open(new ApplicationRequest(applicationName) { Target = GetSelectedHost() }); } /// diff --git a/Source/NETworkManager/ViewModels/TracerouteViewModel.cs b/Source/NETworkManager/ViewModels/TracerouteViewModel.cs index 193dd392a5..1cb383e31e 100644 --- a/Source/NETworkManager/ViewModels/TracerouteViewModel.cs +++ b/Source/NETworkManager/ViewModels/TracerouteViewModel.cs @@ -310,10 +310,7 @@ private void RedirectDataToApplicationAction(object name) ? SelectedResult.Hostname : SelectedResult.IPAddress.ToString(); - NavigationManager.Open(new ApplicationRequest(applicationName) - { - Target = ApplicationManager.GetHostForApplication(applicationName, host) - }); + NavigationManager.Open(new ApplicationRequest(applicationName) { Target = host }); } /// From cea23c974cdcc300816683ab412ba43ea2f5e30a Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:57:05 +0200 Subject: [PATCH 119/146] Docs: Changelog trailing dot of single-label names when opened in another feature (final final review #3) Co-Authored-By: Claude Opus 5.5 --- Website/docs/changelog/next-release.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index eee279f9ae..6194535e5f 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -208,7 +208,7 @@ Release date: **xx.xx.2026** **Traceroute** - Fixed the trace not working when the ICMP **Buffer** was set to more than `65500` bytes (Windows limit). The maximum is now `65500`, higher values are lowered to `65500`. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) -- Fixed hostnames of the hops resolved via DNS (`PTR`) being shown with a trailing dot (e.g. `server.example.com.`), also in the export and when opened in another feature (e.g. **RDAP**). [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) +- Fixed hostnames of the hops resolved via DNS (`PTR`) being shown with a trailing dot (e.g. `server.example.com.`), also in the export and when opened in another feature (e.g. **RDAP**). A single-label name (e.g. `gateway.`) keeps the dot, so the DNS suffix isn't appended, except for **Remote Desktop**, **PuTTY**, **TigerVNC** and **PowerShell**, which resolve it via Windows. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) - Fixed columns that couldn't be made wider than the window (by dragging or double-clicking the gripper), e.g. to read a long hostname. The grid scrolls horizontally now, the last (or main) column still fills the remaining width. [#3557](https://github.com/BornToBeRoot/NETworkManager/issues/3557) **Network Interface** @@ -274,7 +274,7 @@ Release date: **xx.xx.2026** - Fixed a hostname received from the network (DNS PTR record or NetBIOS name) with special characters like `&` being able to run additional commands through a custom command with `{{Hostname}}` (e.g. `cmd.exe /c ...`). Such a hostname is now replaced with an empty value. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) - Fixed **Edit** (`F2`) and **Delete** (`Del`) of a custom command in the settings being available without a selected command. The delete confirmation shows the name of the command. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) - Fixed columns, **Copy** menu entries and row details being shown or hidden according to the current settings instead of the settings the shown scan was started with (e.g. **MAC address** column shown, but empty, after enabling **Resolve MAC address and vendor** without a new scan). [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) -- Fixed hostnames resolved via DNS (`PTR`) being shown with a trailing dot (e.g. `server.example.com.`), also in the export and when opened in another feature (e.g. **DNS Lookup**, **Remote Desktop**). [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) +- Fixed hostnames resolved via DNS (`PTR`) being shown with a trailing dot (e.g. `server.example.com.`), also in the export and when opened in another feature (e.g. **DNS Lookup**, **Remote Desktop**). A single-label name (e.g. `gateway.`) keeps the dot, so the DNS suffix isn't appended, except for **Remote Desktop**, **PuTTY**, **TigerVNC** and **PowerShell**, which resolve it via Windows. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) - Fixed a NetBIOS name (shown as hostname if there is no DNS hostname) being passed to other features (e.g. **Remote Desktop**, **Port Scanner**, **DNS Lookup**, **RDAP**), where it may not be resolvable via DNS. The DNS hostname is used, otherwise the IP address; **DNS lookup...** / **RDAP...** > **Hostname** need a DNS hostname. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) - Fixed the progress and the up / down counters possibly staying below the final values (e.g. 255 of 256 hosts scanned) when the results of several hosts arrived at the same time. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) - Fixed the application crashing when the results were sorted by **MAC address** and a host had no MAC address (e.g. a host behind a router, or while the MAC address was still resolved). Hosts without a MAC address are shown at the end. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) @@ -299,7 +299,7 @@ Release date: **xx.xx.2026** - Fixed the application crashing when the results of multiple hosts were sorted by IP address. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) - Results are sorted by IP address and port (before, in the order the ports were finished), and the hostname is resolved while the ports are scanned instead of before. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) - Fixed the **Hostname** column and **Copy** > **Hostname** being shown or hidden according to the current settings instead of the settings the shown scan was started with. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) -- Fixed hostnames resolved via DNS (`PTR`) being shown with a trailing dot (e.g. `server.example.com.`), also in the group header and the export. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) +- Fixed hostnames resolved via DNS (`PTR`) being shown with a trailing dot (e.g. `server.example.com.`), also in the group header, the export and when opened in another feature (e.g. **DNS Lookup**, **Remote Desktop**). A single-label name (e.g. `gateway.`) keeps the dot, so the DNS suffix isn't appended, except for **Remote Desktop**, **PuTTY**, **TigerVNC** and **PowerShell**, which resolve it via Windows. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) - Fixed the progress and the open / closed counters possibly staying below the final values (e.g. 1023 of 1024 ports scanned) when the results of several ports arrived at the same time. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) - Fixed columns that couldn't be made wider than the window (by dragging or double-clicking the gripper) in the result and the port profiles (settings). The grid scrolls horizontally now, the last (or main) column still fills the remaining width. [#3557](https://github.com/BornToBeRoot/NETworkManager/issues/3557) - Fixed the ports of different hosts with the same hostname (DNS `PTR`, e.g. a generic name of a provider or a server with two IP addresses) being shown in one group. The results are grouped per IP address, the group header shows the hostname and the IP address. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660) From 3a75d8db3f8ed4ec21cba1e246e7a7e99c1c3905 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:57:12 +0200 Subject: [PATCH 120/146] Refactor: Web Console not in the applications whose host is sanitized, its target is a URL (final final review #4) Co-Authored-By: Claude Opus 5.5 --- Source/NETworkManager.Models/ApplicationManager.cs | 3 ++- Source/NETworkManager.Tests/ApplicationManagerTests.cs | 1 - 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Source/NETworkManager.Models/ApplicationManager.cs b/Source/NETworkManager.Models/ApplicationManager.cs index 7230a86585..0e76c60306 100644 --- a/Source/NETworkManager.Models/ApplicationManager.cs +++ b/Source/NETworkManager.Models/ApplicationManager.cs @@ -45,8 +45,9 @@ public static IEnumerable GetDefaultList() /// Host for the application. public static string GetHostForApplication(ApplicationName name, string host) { + // Not the Web Console: its target is a URL, created by WebConsoleUrlHelper (which also removes the dot) return name is ApplicationName.RemoteDesktop or ApplicationName.PuTTY or ApplicationName.TigerVNC - or ApplicationName.PowerShell or ApplicationName.WebConsole + or ApplicationName.PowerShell ? DNSClientHelper.SanitizeHostname(host) : host; } diff --git a/Source/NETworkManager.Tests/ApplicationManagerTests.cs b/Source/NETworkManager.Tests/ApplicationManagerTests.cs index 34bcb9f75a..41dc818a99 100644 --- a/Source/NETworkManager.Tests/ApplicationManagerTests.cs +++ b/Source/NETworkManager.Tests/ApplicationManagerTests.cs @@ -10,7 +10,6 @@ public class ApplicationManagerTests [InlineData(ApplicationName.PuTTY)] [InlineData(ApplicationName.TigerVNC)] [InlineData(ApplicationName.PowerShell)] - [InlineData(ApplicationName.WebConsole)] public void HostForApplicationResolvedViaWindowsWithoutTrailingDot(ApplicationName name) { Assert.Equal("gateway", ApplicationManager.GetHostForApplication(name, "gateway.")); From 22c3c9d6773aef89d4bffab3cc19471376829370 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:57:40 +0200 Subject: [PATCH 121/146] Fix: Sanitized hostname of only dots is empty, profile name falls back to the IP address (final final review #5) Co-Authored-By: Claude Opus 5.5 --- .../Network/WebConsoleUrlHelper.cs | 17 +++++++---------- .../Network/WebConsoleUrlHelperTests.cs | 1 + .../Utilities/DNSClientHelperTests.cs | 2 +- .../NETworkManager.Utilities/DNSClientHelper.cs | 9 ++------- .../ViewModels/IPScannerViewModel.cs | 8 ++++---- .../ViewModels/PortScannerViewModel.cs | 8 ++++---- 6 files changed, 19 insertions(+), 26 deletions(-) diff --git a/Source/NETworkManager.Models/Network/WebConsoleUrlHelper.cs b/Source/NETworkManager.Models/Network/WebConsoleUrlHelper.cs index 93368c39ad..1740ae0da0 100644 --- a/Source/NETworkManager.Models/Network/WebConsoleUrlHelper.cs +++ b/Source/NETworkManager.Models/Network/WebConsoleUrlHelper.cs @@ -52,21 +52,18 @@ public static string CreateUrl(string host, IEnumerable openPorts) /// URL. public static string CreateUrl(string host, int port) { + // The browser resolves a hostname via Windows (e.g. "gateway." -> "gateway") + if (!IPAddress.TryParse(host, out var ipAddress)) + host = DNSClientHelper.SanitizeHostname(host); + + // 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.TryParse(host, out var ipAddress)) - { - if (ipAddress.AddressFamily == AddressFamily.InterNetworkV6) - host = $"[{ipAddress.ToString().Replace("%", "%25")}]"; - } - else - { - // The browser resolves it via Windows (e.g. "gateway." -> "gateway") - host = DNSClientHelper.SanitizeHostname(host); - } + if (ipAddress?.AddressFamily == AddressFamily.InterNetworkV6) + host = $"[{ipAddress.ToString().Replace("%", "%25")}]"; return port is 80 or 443 ? $"{scheme}://{host}/" : $"{scheme}://{host}:{port}/"; } diff --git a/Source/NETworkManager.Tests/Network/WebConsoleUrlHelperTests.cs b/Source/NETworkManager.Tests/Network/WebConsoleUrlHelperTests.cs index 9689b2435a..2837c800d4 100644 --- a/Source/NETworkManager.Tests/Network/WebConsoleUrlHelperTests.cs +++ b/Source/NETworkManager.Tests/Network/WebConsoleUrlHelperTests.cs @@ -24,6 +24,7 @@ public void CreatesUrlForPort(string host, int port, string expected) [InlineData(null)] [InlineData("")] [InlineData(" ")] + [InlineData(".")] public void ThrowsWithoutHost(string host) { Assert.ThrowsAny(() => WebConsoleUrlHelper.CreateUrl(host, 80)); diff --git a/Source/NETworkManager.Tests/Utilities/DNSClientHelperTests.cs b/Source/NETworkManager.Tests/Utilities/DNSClientHelperTests.cs index 5fce1a4b6a..e098986362 100644 --- a/Source/NETworkManager.Tests/Utilities/DNSClientHelperTests.cs +++ b/Source/NETworkManager.Tests/Utilities/DNSClientHelperTests.cs @@ -34,7 +34,7 @@ public void KeepsNullOrEmpty(string name) [InlineData("gateway.", "gateway")] [InlineData("server.example.com.", "server.example.com")] [InlineData("gateway", "gateway")] - [InlineData(".", ".")] + [InlineData(".", "")] [InlineData("", "")] [InlineData(null, null)] public void SanitizesHostname(string hostname, string expected) diff --git a/Source/NETworkManager.Utilities/DNSClientHelper.cs b/Source/NETworkManager.Utilities/DNSClientHelper.cs index 326a424eff..6f4b9484ae 100644 --- a/Source/NETworkManager.Utilities/DNSClientHelper.cs +++ b/Source/NETworkManager.Utilities/DNSClientHelper.cs @@ -47,15 +47,10 @@ public static string TrimTrailingDot(string name) /// (DNS suffix search, LLMNR / NetBIOS) and it matches the name of a certificate (https). /// /// Hostname. - /// Hostname without the trailing dot, the hostname itself if it is null, empty or only dots. + /// Hostname without the trailing dot (empty if it is only dots), null if it is null. public static string SanitizeHostname(string hostname) { - if (string.IsNullOrEmpty(hostname)) - return hostname; - - var trimmed = hostname.TrimEnd('.'); - - return trimmed.Length > 0 ? trimmed : hostname; + return hostname?.TrimEnd('.'); } /// diff --git a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs index 711759d344..9861d6d216 100644 --- a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs @@ -598,12 +598,12 @@ private void CustomCommandAction(object guid) private async void AddProfileSelectedHostAction() { + // Only displayed - also trim the dot a single-label name still has (e.g. "gateway.") + var hostname = DNSClientHelper.SanitizeHostname(SelectedResult.Hostname); + ProfileInfo profileInfo = new() { - // Only displayed - also trim the dot a single-label name still has (e.g. "gateway.") - Name = string.IsNullOrEmpty(SelectedResult.Hostname) - ? SelectedResult.PingInfo.IPAddress.ToString() - : DNSClientHelper.SanitizeHostname(SelectedResult.Hostname), + Name = string.IsNullOrEmpty(hostname) ? SelectedResult.PingInfo.IPAddress.ToString() : hostname, Host = SelectedResult.PingInfo.IPAddress.ToString(), // Additional data diff --git a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs index 3be5a548d2..1d367afef7 100644 --- a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs @@ -641,12 +641,12 @@ private void PerformRDAPQueryHostnameAction() private async void AddProfileSelectedHostAction() { + // Only displayed - also trim the dot a single-label name still has (e.g. "gateway.") + var hostname = DNSClientHelper.SanitizeHostname(SelectedResult.Hostname); + ProfileInfo profileInfo = new() { - // Only displayed - also trim the dot a single-label name still has (e.g. "gateway.") - Name = string.IsNullOrEmpty(SelectedResult.Hostname) - ? SelectedResult.IPAddress.ToString() - : DNSClientHelper.SanitizeHostname(SelectedResult.Hostname), + Name = string.IsNullOrEmpty(hostname) ? SelectedResult.IPAddress.ToString() : hostname, Host = SelectedResult.IPAddress.ToString(), // Additional data From 62f1c9c464f0bbda8948ee67ea44b864ca7e1d5c Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:57:50 +0200 Subject: [PATCH 122/146] Refactor: Ping Monitor uses the shared helper to remove the trailing dot of the hostname (final final review #6) Co-Authored-By: Claude Opus 5.5 --- Source/NETworkManager/ViewModels/PingMonitorViewModel.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Source/NETworkManager/ViewModels/PingMonitorViewModel.cs b/Source/NETworkManager/ViewModels/PingMonitorViewModel.cs index 55ddb9fd08..ab7395266f 100644 --- a/Source/NETworkManager/ViewModels/PingMonitorViewModel.cs +++ b/Source/NETworkManager/ViewModels/PingMonitorViewModel.cs @@ -106,7 +106,7 @@ private set /// "gateway." or a hostname entered with a dot). /// private string NotificationHostDisplay => - string.IsNullOrEmpty(Hostname) ? IPAddress.ToString() : Hostname.TrimEnd('.'); + DNSClientHelper.SanitizeHostname(Hostname) is { Length: > 0 } hostname ? hostname : IPAddress.ToString(); /// /// Gets the hostname of the monitored host. @@ -803,7 +803,7 @@ private void Ping_HostnameResolved(object sender, HostnameArgs e) { // Update title if name was not set in the constructor if (string.IsNullOrEmpty(Hostname)) - Title = $"{e.Hostname.TrimEnd('.')} # {IPAddress}"; + Title = $"{DNSClientHelper.SanitizeHostname(e.Hostname)} # {IPAddress}"; Hostname = e.Hostname; } From a333adde6fef4a3b59759ff486e3247267295914 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:58:07 +0200 Subject: [PATCH 123/146] Refactor: TrimTrailingDot builds on SanitizeHostname (final final review #7) Co-Authored-By: Claude Opus 5.5 --- Source/NETworkManager.Utilities/DNSClientHelper.cs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/Source/NETworkManager.Utilities/DNSClientHelper.cs b/Source/NETworkManager.Utilities/DNSClientHelper.cs index 6f4b9484ae..90e0fa659d 100644 --- a/Source/NETworkManager.Utilities/DNSClientHelper.cs +++ b/Source/NETworkManager.Utilities/DNSClientHelper.cs @@ -32,12 +32,9 @@ public static bool IsBareHostname(string query) /// Domain name without the trailing dot, unless it is a single-label name. public static string TrimTrailingDot(string name) { - if (string.IsNullOrEmpty(name) || !name.EndsWith('.')) - return name; + var trimmed = SanitizeHostname(name); - var trimmed = name.TrimEnd('.'); - - return trimmed.Contains('.') ? trimmed : name; + return trimmed != null && trimmed.Contains('.') ? trimmed : name; } /// From c7c9783e2ee0062fcdb5c70b22a807200a2ecd5a Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:58:24 +0200 Subject: [PATCH 124/146] Fix: Web Console URL checks the host before the open ports (final final review #8) Co-Authored-By: Claude Opus 5.5 --- Source/NETworkManager.Models/Network/WebConsoleUrlHelper.cs | 3 +++ .../NETworkManager.Tests/Network/WebConsoleUrlHelperTests.cs | 1 + 2 files changed, 4 insertions(+) diff --git a/Source/NETworkManager.Models/Network/WebConsoleUrlHelper.cs b/Source/NETworkManager.Models/Network/WebConsoleUrlHelper.cs index 1740ae0da0..a82ddb1e71 100644 --- a/Source/NETworkManager.Models/Network/WebConsoleUrlHelper.cs +++ b/Source/NETworkManager.Models/Network/WebConsoleUrlHelper.cs @@ -36,6 +36,9 @@ public static bool IsWebPort(int port) /// URL. public static string CreateUrl(string host, IEnumerable openPorts) { + ArgumentException.ThrowIfNullOrWhiteSpace(host); + ArgumentNullException.ThrowIfNull(openPorts); + var ports = openPorts.ToHashSet(); var port = WebPorts.FirstOrDefault(ports.Contains); diff --git a/Source/NETworkManager.Tests/Network/WebConsoleUrlHelperTests.cs b/Source/NETworkManager.Tests/Network/WebConsoleUrlHelperTests.cs index 2837c800d4..20f87c4a5f 100644 --- a/Source/NETworkManager.Tests/Network/WebConsoleUrlHelperTests.cs +++ b/Source/NETworkManager.Tests/Network/WebConsoleUrlHelperTests.cs @@ -28,6 +28,7 @@ public void CreatesUrlForPort(string host, int port, string expected) public void ThrowsWithoutHost(string host) { Assert.ThrowsAny(() => WebConsoleUrlHelper.CreateUrl(host, 80)); + Assert.ThrowsAny(() => WebConsoleUrlHelper.CreateUrl(host, [80])); } [Theory] From 2cf57477d3ad442f876dca7fba1d15245c46ef5a Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:02:49 +0200 Subject: [PATCH 125/146] Fix: IP Scanner / Port Scanner don't start the scan if no host is left after resolving the hostnames (B8) Only "The following hostnames could not be resolved" is shown instead of also "No reachable hosts found" / "No open ports found", and the input is not added to the history. Co-Authored-By: Claude Opus 5.5 --- Source/NETworkManager/ViewModels/IPScannerViewModel.cs | 9 ++++++++- Source/NETworkManager/ViewModels/PortScannerViewModel.cs | 9 ++++++++- Website/docs/changelog/next-release.md | 4 ++-- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs index 9861d6d216..c51439abb6 100644 --- a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs @@ -788,6 +788,13 @@ private async Task Start() HostsToScan = hosts.hosts.Count; + // No host left (e.g. only hostnames that could not be resolved): don't start the scan, only the message above + if (HostsToScan == 0) + { + ScanComplete(this, new ScanCompleteArgs(false)); + return; + } + PreparingScan = false; // Add host(s) to the history @@ -1418,7 +1425,7 @@ private void ScanComplete(object sender, ScanCompleteArgs e) StatusMessage = e.Exception.Message; IsStatusMessageDisplayed = true; } - else if (Results.Count == 0) + else if (Results.Count == 0 && HostsToScan > 0) { // Keep a message from the preparation (e.g. hostnames that could not be resolved) StatusMessage = IsStatusMessageDisplayed diff --git a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs index 1d367afef7..25ea0070a1 100644 --- a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs @@ -861,6 +861,13 @@ private async Task Start() PortsToScan = ports.Length * hosts.hosts.Count; + // No host left (e.g. only hostnames that could not be resolved): don't start the scan, only the message above + if (PortsToScan == 0) + { + ScanComplete(this, new ScanCompleteArgs(false)); + return; + } + PreparingScan = false; // Add host(s) to the history @@ -1312,7 +1319,7 @@ private void ScanComplete(object sender, ScanCompleteArgs e) AddStatusMessage(e.Exception.Message); } - else if (Results.Count == 0) + else if (Results.Count == 0 && PortsToScan > 0) { AddStatusMessage(Strings.NoOpenPortsFound); } diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index 6194535e5f..20ffbd5c42 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -261,7 +261,7 @@ Release date: **xx.xx.2026** - Fixed the ICMP **Attempts** setting being ignored: a host was shown as unreachable after the first lost ping. Another ping is now sent if the previous one timed out. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) - Fixed the scan getting stuck (button disabled, scan never ends) when it was canceled right before the scan started, and when an unexpected error occurred. Errors are now shown as status message. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) - Fixed the message "Canceled by user" being replaced by "No reachable hosts found" when the scan was canceled before the first result, and a new scan started right after canceling showing no progress or stopping too early. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) -- Fixed the message "The following hostnames could not be resolved" being replaced by "No reachable hosts found". Both messages are shown now. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) +- Fixed the message "The following hostnames could not be resolved" being replaced by "No reachable hosts found". Both messages are shown now if other hosts were scanned; if no host is left, the scan is not started. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) - Fixed the scan getting stuck when an error occurred while the hosts or ports were prepared, and the subnet detection button staying disabled after an error. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) - Fixed ports being shown as closed when there was no answer at all (e.g. host or network unreachable). Closed now means the host refused the connection, otherwise the port is timed out. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) - Fixed large scans using up the local (ephemeral) ports: every open port left a connection in the `TIME_WAIT` state for several minutes, so later connections failed (also in other applications). Connections are now closed with a reset. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) @@ -288,7 +288,7 @@ Release date: **xx.xx.2026** - Fixed the application becoming unresponsive (including the window not reacting to input) during a large scan. Scan results and progress were both being pushed to the UI one item/update at a time via a dispatcher call per port, which could flood the UI thread's message queue on large scans. Both are now batched and flushed periodically (every 150ms) instead. [#3564](https://github.com/BornToBeRoot/NETworkManager/pull/3564) - Fixed the scan getting stuck (button disabled, scan never ends) when it was canceled right before the scan started, and when an unexpected error occurred. Errors are now shown as status message. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) - Fixed the message "Canceled by user" being replaced by "No open ports found" when the scan was canceled before the first result, and a new scan started right after canceling showing no progress or stopping too early. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) -- Fixed the message "The following hostnames could not be resolved" being replaced by "No open ports found". Both messages are shown now. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) +- Fixed the message "The following hostnames could not be resolved" being replaced by "No open ports found". Both messages are shown now if other hosts were scanned; if no host is left, the scan is not started. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) - Fixed the scan getting stuck when an error occurred while the hosts or ports were prepared. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) - Fixed ports being shown as closed when there was no answer at all (e.g. host or network unreachable). Closed now means the host refused the connection, otherwise the port is timed out. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) - Fixed large scans using up the local (ephemeral) ports: every open port left a connection in the `TIME_WAIT` state for several minutes, so later connections failed (also in other applications). Connections are now closed with a reset. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) From a47e4b14aeca554334aed6ae22c93feead7568b9 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:13:38 +0200 Subject: [PATCH 126/146] Fix: ICMP buffer above 65500 lowered on every load of the settings, not only on an upgrade A settings file edited (or restored) after the upgrade to the current version kept a buffer of e.g. 65535, which makes .NET's Ping throw. Co-Authored-By: Claude Opus 5.5 --- .../SettingsManager.cs | 52 ++++++++++++------- 1 file changed, 33 insertions(+), 19 deletions(-) diff --git a/Source/NETworkManager.Settings/SettingsManager.cs b/Source/NETworkManager.Settings/SettingsManager.cs index 2dc5a326ff..f73abbe2fa 100644 --- a/Source/NETworkManager.Settings/SettingsManager.cs +++ b/Source/NETworkManager.Settings/SettingsManager.cs @@ -223,6 +223,9 @@ public static void Load() // Reset change tracking Current.SettingsChanged = false; + // After the reset, so lowered values are saved + ApplyLimits(); + return; } catch (Exception ex) @@ -247,6 +250,8 @@ public static void Load() Current.SettingsChanged = false; + ApplyLimits(); + // Save in new JSON format Save(); @@ -331,6 +336,34 @@ public static void Save() // Reset change tracking Current.SettingsChanged = false; } + /// + /// Lowers values above a technical limit (e.g. edited in the settings file or from an older version that allowed + /// them), on every load - not only on an upgrade, which runs only once per version. + /// + private static void ApplyLimits() + { + // ICMP buffer - .NET's Ping throws for more than 65500 bytes, older versions allowed up to 65535 + const int maxICMPBuffer = 65500; + + if (Current.IPScanner_ICMPBuffer > maxICMPBuffer) + { + Log.Info($"Update \"IPScanner_ICMPBuffer\" from {Current.IPScanner_ICMPBuffer} to {maxICMPBuffer}..."); + Current.IPScanner_ICMPBuffer = maxICMPBuffer; + } + + if (Current.PingMonitor_Buffer > maxICMPBuffer) + { + Log.Info($"Update \"PingMonitor_Buffer\" from {Current.PingMonitor_Buffer} to {maxICMPBuffer}..."); + Current.PingMonitor_Buffer = maxICMPBuffer; + } + + if (Current.Traceroute_Buffer > maxICMPBuffer) + { + Log.Info($"Update \"Traceroute_Buffer\" from {Current.Traceroute_Buffer} to {maxICMPBuffer}..."); + Current.Traceroute_Buffer = maxICMPBuffer; + } + } + #endregion #region Backup @@ -862,25 +895,6 @@ private static void UpgradeToLatest(Version version) Current.IPScanner_PortScanPorts = GlobalStaticConfiguration.IPScanner_PortScanPorts; } - // ICMP buffer - .NET's Ping throws for more than 65500 bytes, the settings allowed up to 65535 - if (Current.IPScanner_ICMPBuffer > 65500) - { - Log.Info($"Update \"IPScanner_ICMPBuffer\" from {Current.IPScanner_ICMPBuffer} to 65500..."); - Current.IPScanner_ICMPBuffer = 65500; - } - - if (Current.PingMonitor_Buffer > 65500) - { - Log.Info($"Update \"PingMonitor_Buffer\" from {Current.PingMonitor_Buffer} to 65500..."); - Current.PingMonitor_Buffer = 65500; - } - - if (Current.Traceroute_Buffer > 65500) - { - Log.Info($"Update \"Traceroute_Buffer\" from {Current.Traceroute_Buffer} to 65500..."); - Current.Traceroute_Buffer = 65500; - } - // Add new Port Scanner port profiles foreach (var portProfile in PortProfile.GetDefaultList()) { From 0f9be91acfb249bab5f0601426bffe5c66bef177 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:17:35 +0200 Subject: [PATCH 127/146] Revert "Fix: ICMP buffer above 65500 lowered on every load of the settings, not only on an upgrade" This reverts commit a47e4b14a. The buffer is lowered inline in UpgradeToLatest again. Co-Authored-By: Claude Opus 5.5 --- .../SettingsManager.cs | 52 +++++++------------ 1 file changed, 19 insertions(+), 33 deletions(-) diff --git a/Source/NETworkManager.Settings/SettingsManager.cs b/Source/NETworkManager.Settings/SettingsManager.cs index f73abbe2fa..2dc5a326ff 100644 --- a/Source/NETworkManager.Settings/SettingsManager.cs +++ b/Source/NETworkManager.Settings/SettingsManager.cs @@ -223,9 +223,6 @@ public static void Load() // Reset change tracking Current.SettingsChanged = false; - // After the reset, so lowered values are saved - ApplyLimits(); - return; } catch (Exception ex) @@ -250,8 +247,6 @@ public static void Load() Current.SettingsChanged = false; - ApplyLimits(); - // Save in new JSON format Save(); @@ -336,34 +331,6 @@ public static void Save() // Reset change tracking Current.SettingsChanged = false; } - /// - /// Lowers values above a technical limit (e.g. edited in the settings file or from an older version that allowed - /// them), on every load - not only on an upgrade, which runs only once per version. - /// - private static void ApplyLimits() - { - // ICMP buffer - .NET's Ping throws for more than 65500 bytes, older versions allowed up to 65535 - const int maxICMPBuffer = 65500; - - if (Current.IPScanner_ICMPBuffer > maxICMPBuffer) - { - Log.Info($"Update \"IPScanner_ICMPBuffer\" from {Current.IPScanner_ICMPBuffer} to {maxICMPBuffer}..."); - Current.IPScanner_ICMPBuffer = maxICMPBuffer; - } - - if (Current.PingMonitor_Buffer > maxICMPBuffer) - { - Log.Info($"Update \"PingMonitor_Buffer\" from {Current.PingMonitor_Buffer} to {maxICMPBuffer}..."); - Current.PingMonitor_Buffer = maxICMPBuffer; - } - - if (Current.Traceroute_Buffer > maxICMPBuffer) - { - Log.Info($"Update \"Traceroute_Buffer\" from {Current.Traceroute_Buffer} to {maxICMPBuffer}..."); - Current.Traceroute_Buffer = maxICMPBuffer; - } - } - #endregion #region Backup @@ -895,6 +862,25 @@ private static void UpgradeToLatest(Version version) Current.IPScanner_PortScanPorts = GlobalStaticConfiguration.IPScanner_PortScanPorts; } + // ICMP buffer - .NET's Ping throws for more than 65500 bytes, the settings allowed up to 65535 + if (Current.IPScanner_ICMPBuffer > 65500) + { + Log.Info($"Update \"IPScanner_ICMPBuffer\" from {Current.IPScanner_ICMPBuffer} to 65500..."); + Current.IPScanner_ICMPBuffer = 65500; + } + + if (Current.PingMonitor_Buffer > 65500) + { + Log.Info($"Update \"PingMonitor_Buffer\" from {Current.PingMonitor_Buffer} to 65500..."); + Current.PingMonitor_Buffer = 65500; + } + + if (Current.Traceroute_Buffer > 65500) + { + Log.Info($"Update \"Traceroute_Buffer\" from {Current.Traceroute_Buffer} to 65500..."); + Current.Traceroute_Buffer = 65500; + } + // Add new Port Scanner port profiles foreach (var portProfile in PortProfile.GetDefaultList()) { From effca61a80cae49d52b1e8c52657200a816e9de0 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:33:17 +0200 Subject: [PATCH 128/146] Fix: Column that fills the remaining width could switch between two widths in a loop and hang the window DataGridHelper.FillRemainingWidth: a horizontal scrollbar can make the vertical one appear, the narrower fill column removes both, the wider one shows the horizontal scrollbar again ... Each update is queued with Loaded priority (before input), so the window hangs without an exception. Going back to the wider width of two updates ago within 500 ms (without a size change of the grid) keeps the narrower width. Seen once in a Port Scanner run 1-65535 (stuck in the middle, no exception); cause not confirmed with a stack trace. Co-Authored-By: Claude Opus 5.5 --- .../DataGridHelper.cs | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/Source/NETworkManager.Utilities.WPF/DataGridHelper.cs b/Source/NETworkManager.Utilities.WPF/DataGridHelper.cs index 4d8c51df23..906a0253e1 100644 --- a/Source/NETworkManager.Utilities.WPF/DataGridHelper.cs +++ b/Source/NETworkManager.Utilities.WPF/DataGridHelper.cs @@ -1,5 +1,6 @@ using System; using System.ComponentModel; +using System.Diagnostics; using System.Linq; using System.Windows; using System.Windows.Controls; @@ -123,6 +124,14 @@ private sealed class FillState(DataGrid dataGrid) private bool _isUpdating; private bool _isUpdateQueued; + // Width set before the last one and when, to detect switching between two widths (see Update) + private double _secondLastSetWidth = double.NaN; + private long _secondLastSetTimestamp; + private bool _isSizeChanged; + + // Switching back to the second last width within this time is a loop, not a change of the user + private static readonly TimeSpan OscillationTime = TimeSpan.FromMilliseconds(500); + public void OnLoaded(object sender, RoutedEventArgs e) => Attach(); public void OnUnloaded(object sender, RoutedEventArgs e) => Detach(); @@ -167,7 +176,12 @@ public void Detach() _scrollViewer = null; } - private void OnSizeChanged(object sender, SizeChangedEventArgs e) => QueueUpdate(); + private void OnSizeChanged(object sender, SizeChangedEventArgs e) + { + _isSizeChanged = true; + + QueueUpdate(); + } private void OnScrollChanged(object sender, ScrollChangedEventArgs e) { @@ -207,6 +221,9 @@ private void QueueUpdate() private void Update() { + var isSizeChanged = _isSizeChanged; + _isSizeChanged = false; + if (!dataGrid.IsLoaded) return; @@ -230,6 +247,7 @@ private void Update() _fillColumn = fillColumn; _lastSetWidth = double.NaN; + _secondLastSetWidth = double.NaN; if (_fillColumn == null) return; @@ -255,10 +273,20 @@ private void Update() width = Math.Min(Math.Max(width, _fillColumn.MinWidth), _fillColumn.MaxWidth); + // Back to the wider width of two updates ago right away, without a size change of the grid: the + // widths switch in a loop (e.g. a horizontal scrollbar makes the vertical one appear, the narrower + // column removes both, the wider column shows the horizontal scrollbar again ...) - each update is + // queued before input, the window would hang. Keep the narrower width (no horizontal scrollbar). + if (!isSizeChanged && width > _lastSetWidth && Math.Abs(width - _secondLastSetWidth) < 0.5 && + Stopwatch.GetElapsedTime(_secondLastSetTimestamp) < OscillationTime) + width = _lastSetWidth; + if (!double.IsNaN(_lastSetWidth) && Math.Abs(width - _lastSetWidth) < 0.5 && Math.Abs(_fillColumn.ActualWidth - width) < 0.5) return; + _secondLastSetWidth = _lastSetWidth; + _secondLastSetTimestamp = Stopwatch.GetTimestamp(); _lastSetWidth = width; _fillColumn.Width = new DataGridLength(width); } From da769c06e48834a6b965c89d7415159a0ecdbde0 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:41:06 +0200 Subject: [PATCH 129/146] Fix: IP Scanner settings delete several selected custom commands at once The custom commands grid allowed selecting several rows, but Edit / Delete only used one of them. Like the profiles: MultiSelectDataGrid with the selected commands, a context menu with Delete for several rows, Del deletes all selected after a confirmation that lists them, Edit (F2, context menu) only with exactly one selected command. New strings DeleteCustomCommands / DeleteCustomCommandsMessage. Co-Authored-By: Claude Opus 5.5 --- .../Resources/Strings.Designer.cs | 18 +++++++ .../Resources/Strings.resx | 6 +++ .../ViewModels/IPScannerSettingsViewModel.cs | 52 +++++++++++++++---- .../Views/IPScannerSettingsView.xaml | 24 +++++++-- Website/docs/changelog/next-release.md | 1 + 5 files changed, 86 insertions(+), 15 deletions(-) diff --git a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs index b2d9f945a6..2af3f443c7 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs +++ b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs @@ -3029,6 +3029,24 @@ public static string DeleteCustomCommandMessage { } } + /// + /// Looks up a localized string similar to Delete custom commands. + /// + public static string DeleteCustomCommands { + get { + return ResourceManager.GetString("DeleteCustomCommands", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The selected custom commands will be deleted permanently.. + /// + public static string DeleteCustomCommandsMessage { + get { + return ResourceManager.GetString("DeleteCustomCommandsMessage", resourceCulture); + } + } + /// /// Looks up a localized string similar to Delete DNS server. /// diff --git a/Source/NETworkManager.Localization/Resources/Strings.resx b/Source/NETworkManager.Localization/Resources/Strings.resx index b4b95f2682..86ee7347a8 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.resx +++ b/Source/NETworkManager.Localization/Resources/Strings.resx @@ -2274,6 +2274,12 @@ is disabled! The selected custom command will be deleted permanently. + + Delete custom commands + + + The selected custom commands will be deleted permanently. + Restart diff --git a/Source/NETworkManager/ViewModels/IPScannerSettingsViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerSettingsViewModel.cs index bcd128c988..19a8870cf8 100644 --- a/Source/NETworkManager/ViewModels/IPScannerSettingsViewModel.cs +++ b/Source/NETworkManager/ViewModels/IPScannerSettingsViewModel.cs @@ -4,7 +4,9 @@ using NETworkManager.Utilities; using NETworkManager.Views; using System; +using System.Collections; using System.ComponentModel; +using System.Linq; using System.Threading.Tasks; using System.Windows; using System.Windows.Data; @@ -292,6 +294,22 @@ public CustomCommandInfo SelectedCustomCommand } } = new(); + /// + /// Gets or sets the selected custom commands (several can be deleted at once). + /// + public IList SelectedCustomCommands + { + get; + set + { + if (Equals(value, field)) + return; + + field = value; + OnPropertyChanged(); + } + } = new ArrayList(); + /// /// Gets or sets the maximum number of host threads. /// @@ -393,14 +411,14 @@ private void AddCustomCommandAction() /// Gets the command to edit a custom command. /// public ICommand EditCustomCommandCommand => - new RelayCommand(_ => EditCustomCommandAction(), CustomCommandSelected_CanExecute); + new RelayCommand(_ => EditCustomCommandAction(), EditCustomCommand_CanExecute); /// - /// Checks if a custom command is selected (edit and delete need one, e.g. F2 / Del without a selection). + /// Checks if exactly one custom command is selected (e.g. F2 without a selection or with several selected). /// - private bool CustomCommandSelected_CanExecute(object parameter) + private bool EditCustomCommand_CanExecute(object parameter) { - return SelectedCustomCommand != null; + return SelectedCustomCommand != null && SelectedCustomCommands.Count == 1; } /// @@ -412,10 +430,18 @@ private void EditCustomCommandAction() } /// - /// Gets the command to delete a custom command. + /// Gets the command to delete the selected custom commands. /// public ICommand DeleteCustomCommandCommand => - new RelayCommand(_ => DeleteCustomCommandAction(), CustomCommandSelected_CanExecute); + new RelayCommand(_ => DeleteCustomCommandAction(), DeleteCustomCommand_CanExecute); + + /// + /// Checks if at least one custom command is selected (e.g. Del without a selection). + /// + private bool DeleteCustomCommand_CanExecute(object parameter) + { + return SelectedCustomCommands.Count > 0; + } /// /// Action to delete a custom command. @@ -494,21 +520,25 @@ public Task EditCustomCommand() } /// - /// Deletes the selected custom command. + /// Deletes the selected custom commands (after a confirmation that lists them). /// private async Task DeleteCustomCommand() { + var customCommands = SelectedCustomCommands.Cast().ToList(); + var result = await DialogHelper.ShowConfirmationMessageAsync(Application.Current.MainWindow, - Strings.DeleteCustomCommand, - Strings.DeleteCustomCommandMessage + Environment.NewLine + Environment.NewLine + - DialogHelper.FormatItemList([SelectedCustomCommand.Name]), + customCommands.Count == 1 ? Strings.DeleteCustomCommand : Strings.DeleteCustomCommands, + (customCommands.Count == 1 ? Strings.DeleteCustomCommandMessage : Strings.DeleteCustomCommandsMessage) + + Environment.NewLine + Environment.NewLine + + DialogHelper.FormatItemList([.. customCommands.Select(x => x.Name)]), ChildWindowIcon.Info, Strings.Delete); if (!result) return; - SettingsManager.Current.IPScanner_CustomCommands.Remove(SelectedCustomCommand); + foreach (var customCommand in customCommands) + SettingsManager.Current.IPScanner_CustomCommands.Remove(customCommand); } #endregion diff --git a/Source/NETworkManager/Views/IPScannerSettingsView.xaml b/Source/NETworkManager/Views/IPScannerSettingsView.xaml index 6435fc8e01..7b4b5572b2 100644 --- a/Source/NETworkManager/Views/IPScannerSettingsView.xaml +++ b/Source/NETworkManager/Views/IPScannerSettingsView.xaml @@ -8,6 +8,7 @@ xmlns:viewModels="clr-namespace:NETworkManager.ViewModels" xmlns:iconPacks="http://metro.mahapps.com/winfx/xaml/iconpacks" xmlns:utilities="clr-namespace:NETworkManager.Utilities;assembly=NETworkManager.Utilities" + xmlns:controls="clr-namespace:NETworkManager.Controls;assembly=NETworkManager.Controls" xmlns:wpfHelpers="clr-namespace:NETworkManager.Utilities.WPF;assembly=NETworkManager.Utilities.WPF" xmlns:localization="clr-namespace:NETworkManager.Localization.Resources;assembly=NETworkManager.Localization" mc:Ignorable="d" d:DataContext="{d:DesignInstance viewModels:IPScannerSettingsViewModel}"> @@ -73,10 +74,24 @@ - + + + + + + + + + + + + @@ -116,6 +131,7 @@ - + + Date: Tue, 27 Oct 2026 23:48:20 +0200 Subject: [PATCH 132/146] Feature: Port Scanner settings delete several selected port profiles at once Like the custom commands of the IP Scanner: MultiSelectDataGrid, context menu with Delete for several rows, Del deletes all selected after a confirmation that lists them, Edit only with exactly one. The first port profile is still selected on open and after deleting. New strings DeletePortProfiles / DeletePortProfilesMessage. Co-Authored-By: Claude Opus 5.5 --- .../Resources/Strings.Designer.cs | 18 ++++++ .../Resources/Strings.resx | 6 ++ .../PortScannerSettingsViewModel.cs | 64 ++++++++++++++++--- .../Views/PortScannerSettingsView.xaml | 24 +++++-- Website/docs/application/port-scanner.md | 2 +- Website/docs/changelog/next-release.md | 1 + 6 files changed, 100 insertions(+), 15 deletions(-) diff --git a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs index 2af3f443c7..63f72cb9cd 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs +++ b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs @@ -3199,6 +3199,24 @@ public static string DeletePortProfileMessage { } } + /// + /// Looks up a localized string similar to Delete port profiles. + /// + public static string DeletePortProfiles { + get { + return ResourceManager.GetString("DeletePortProfiles", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The selected port profiles will be deleted permanently.. + /// + public static string DeletePortProfilesMessage { + get { + return ResourceManager.GetString("DeletePortProfilesMessage", resourceCulture); + } + } + /// /// Looks up a localized string similar to Delete profile. /// diff --git a/Source/NETworkManager.Localization/Resources/Strings.resx b/Source/NETworkManager.Localization/Resources/Strings.resx index 86ee7347a8..bd6acb085d 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.resx +++ b/Source/NETworkManager.Localization/Resources/Strings.resx @@ -2877,6 +2877,12 @@ is disabled! The selected port profile will be deleted permanently. + + Delete port profiles + + + The selected port profiles will be deleted permanently. + Show SplashScreen on start diff --git a/Source/NETworkManager/ViewModels/PortScannerSettingsViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerSettingsViewModel.cs index 2d991dc575..66b7e11f67 100644 --- a/Source/NETworkManager/ViewModels/PortScannerSettingsViewModel.cs +++ b/Source/NETworkManager/ViewModels/PortScannerSettingsViewModel.cs @@ -5,6 +5,7 @@ using NETworkManager.Utilities; using NETworkManager.Views; using System; +using System.Collections; using System.ComponentModel; using System.Linq; using System.Threading.Tasks; @@ -44,6 +45,22 @@ public PortProfileInfo SelectedPortProfile } } + /// + /// Gets or sets the selected port profiles (several can be deleted at once). + /// + public IList SelectedPortProfiles + { + get; + set + { + if (Equals(value, field)) + return; + + field = value; + OnPropertyChanged(); + } + } = new ArrayList(); + /// /// Gets or sets a value indicating whether to show all results (open and closed ports). /// @@ -229,7 +246,7 @@ public PortScannerSettingsViewModel() PortProfiles.SortDescriptions.Add( new SortDescription(nameof(PortProfileInfo.Name), ListSortDirection.Ascending)); - SelectedPortProfile = PortProfiles.Cast().FirstOrDefault(); + SelectFirstPortProfile(); LoadSettings(); @@ -259,14 +276,26 @@ private void AddPortProfileAction() _ = AddPortProfile(); } - public ICommand EditPortProfileCommand => new RelayCommand(_ => EditPortProfileAction(), PortProfileSelected_CanExecute); + public ICommand EditPortProfileCommand => + new RelayCommand(_ => EditPortProfileAction(), EditPortProfile_CanExecute); /// /// Checks if a port profile is selected (edit and delete need one, e.g. F2 / Del without a selection). /// - private bool PortProfileSelected_CanExecute(object parameter) + /// + /// Checks if exactly one port profile is selected (e.g. F2 without a selection or with several selected). + /// + private bool EditPortProfile_CanExecute(object parameter) { - return SelectedPortProfile != null; + return SelectedPortProfile != null && SelectedPortProfiles.Count == 1; + } + + /// + /// Checks if at least one port profile is selected (e.g. Del without a selection). + /// + private bool DeletePortProfile_CanExecute(object parameter) + { + return SelectedPortProfiles.Count > 0; } private void EditPortProfileAction() @@ -275,7 +304,7 @@ private void EditPortProfileAction() } public ICommand DeletePortProfileCommand => - new RelayCommand(_ => DeletePortProfileAction(), PortProfileSelected_CanExecute); + new RelayCommand(_ => DeletePortProfileAction(), DeletePortProfile_CanExecute); private void DeletePortProfileAction() { @@ -346,22 +375,37 @@ public Task EditPortProfile() return Application.Current.MainWindow.ShowChildWindowAsync(childWindow); } + /// + /// Deletes the selected port profiles (after a confirmation that lists them). + /// private async Task DeletePortProfile() { + var portProfiles = SelectedPortProfiles.Cast().ToList(); + var result = await DialogHelper.ShowConfirmationMessageAsync(Application.Current.MainWindow, - Strings.DeletePortProfile, - Strings.DeletePortProfileMessage + Environment.NewLine + Environment.NewLine + - DialogHelper.FormatItemList([SelectedPortProfile.Name]), + portProfiles.Count == 1 ? Strings.DeletePortProfile : Strings.DeletePortProfiles, + (portProfiles.Count == 1 ? Strings.DeletePortProfileMessage : Strings.DeletePortProfilesMessage) + + Environment.NewLine + Environment.NewLine + + DialogHelper.FormatItemList([.. portProfiles.Select(x => x.Name)]), ChildWindowIcon.Info, Strings.Delete); if (!result) return; - SettingsManager.Current.PortScanner_PortProfiles.Remove(SelectedPortProfile); + foreach (var portProfile in portProfiles) + SettingsManager.Current.PortScanner_PortProfiles.Remove(portProfile); + + SelectFirstPortProfile(); + } - // Select first item after deletion + /// + /// Selects the first port profile (also in the list of selected port profiles, which the grid applies). + /// + private void SelectFirstPortProfile() + { SelectedPortProfile = PortProfiles.Cast().FirstOrDefault(); + SelectedPortProfiles = SelectedPortProfile != null ? new ArrayList { SelectedPortProfile } : new ArrayList(); } #endregion diff --git a/Source/NETworkManager/Views/PortScannerSettingsView.xaml b/Source/NETworkManager/Views/PortScannerSettingsView.xaml index e44742383d..25dc62677d 100644 --- a/Source/NETworkManager/Views/PortScannerSettingsView.xaml +++ b/Source/NETworkManager/Views/PortScannerSettingsView.xaml @@ -7,6 +7,7 @@ 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:controls="clr-namespace:NETworkManager.Controls;assembly=NETworkManager.Controls" xmlns:wpfHelpers="clr-namespace:NETworkManager.Utilities.WPF;assembly=NETworkManager.Utilities.WPF" xmlns:localization="clr-namespace:NETworkManager.Localization.Resources;assembly=NETworkManager.Localization" xmlns:converters="clr-namespace:NETworkManager.Converters;assembly=NETworkManager.Converters" @@ -18,10 +19,24 @@ - + + + + + + + + + + + + @@ -63,6 +78,7 @@ - +