diff --git a/.claude/skills/feature-review/SKILL.md b/.claude/skills/feature-review/SKILL.md index 78d9d3a8bd..5f67157b3b 100644 --- a/.claude/skills/feature-review/SKILL.md +++ b/.claude/skills/feature-review/SKILL.md @@ -1,6 +1,6 @@ --- name: feature-review -description: In-depth review of one NETworkManager feature/tool (e.g. Port Scanner, Hosts File Editor, Firewall), one settings page, or one cross-cutting area (profiles, export, status window, dashboard). First builds an understanding of how the feature works and how it integrates (models, view models, views, settings, profiles, docs), then looks for bugs and logic errors, inconsistencies within the feature and against the rest of the application (UI, icons, spacing, context menus, load/refresh/scan logic, texts), performance problems (scan logic and UI responsiveness), and missing essential functions. Use this whenever the user wants to review, audit, check, or go through a NETworkManager tool, feature, settings page or area, or asks what is wrong/missing/inconsistent in a specific part of the app, even if they don't say "review". Not for reviewing a diff/PR (use code-review for that). +description: In-depth review of one NETworkManager feature/tool (e.g. Port Scanner, Hosts File Editor, Firewall), one settings page, or one cross-cutting area (profiles, export, status window, dashboard). First builds an understanding of how the feature works and how it integrates (models, view models, views, settings, profiles, docs), then looks for bugs and logic errors, inconsistencies within the feature and against the rest of the application (UI, icons, spacing, context menus, load/refresh/scan logic, texts), performance problems (scan logic and UI responsiveness), missing essential functions, and refactorings that reduce duplication or complexity while keeping the code simple (no over-engineering). Use this whenever the user wants to review, audit, check, or go through a NETworkManager tool, feature, settings page or area, or asks what is wrong/missing/inconsistent in a specific part of the app, even if they don't say "review". Not for reviewing a diff/PR (use code-review for that). --- # NETworkManager feature review @@ -14,8 +14,9 @@ If no scope is given, ask which one, and offer the list of review units from [references/integration-map.md](references/integration-map.md). The goal is a short, trustworthy list of findings the user can act on — not a long list of maybes. -The application deliberately keeps its code simple and similar across tools; fixes should follow the -existing patterns and not introduce new abstractions unless the same problem exists in several places. +The application deliberately keeps its code simple and similar across tools: fixes and refactorings follow the +existing patterns, and the code after a change should be as simple or simpler than before — never more abstract than +the problem needs. ## Workflow @@ -67,7 +68,10 @@ Go through these areas. Skip an area if it doesn't apply, but say so in one line ranges, invalid/duplicate/overlapping entries, no network adapter, no admin rights), wrong disposal / event handlers that are never unsubscribed (static events!), status messages overwritten by a later event (e.g. a completion event after cancel or after an input error), exceptions in fire-and-forget `async Task` methods (`_ = Start()`) - that leave flags set. + that leave flags set, a failing optional step (one lookup, one host, a system API call) that ends the whole action + instead of only leaving that value empty, state carried over from the previous loop iteration / item (e.g. a value + of the first redirect kept for the last one), caches or snapshots used without checking their age, and conditions + where an unrelated case suppresses a message or action (e.g. "show X if nothing else failed"). 2. **Consistency within the feature** — the same action behaving differently depending on where it is triggered (button vs. context menu vs. keyboard shortcut vs. profile vs. run command / other tool), single vs. multi selection, texts/terms that differ inside the feature. @@ -95,6 +99,50 @@ Go through these areas. Skip an area if it doesn't apply, but say so in one line placeholders (`{0}`) that translations may break, missing `...` on items that open a dialog. 8. **Documentation and changelog** — does `Website/docs/application/.md` (and settings/profile docs) match the actual behaviour, options, defaults, context menu and shortcuts? +9. **Refactoring and simplification** — duplicated or needlessly complex code that makes the feature harder to + change, see the rules below. Look at it last, when you know how the feature works. Hints: a file much larger + than the same kind of file in the comparison features (`wc -l`), a file that grew a lot (`git log --stat`), the same + lines found twice with `git grep`, a helper from the "Shared helpers" list in + [references/conventions.md](references/conventions.md) re-implemented in the feature. + +#### Refactoring: only if the code gets simpler + +Report a refactoring only if it removes real duplication or complexity and the result is simpler than the code +today. Good reasons: + +- **Duplicated logic that must change together**: the same parsing, validation, mapping or field list in two or more + places (e.g. an input format parsed by the validator, the model and a count method; CSV, XML and JSON export each + listing the same fields; the same command code in several view models). Strongest when the copies already drifted + (a case handled in one copy only) — then it is also a bug finding. +- **Copy-pasted XAML**: the same block repeated for every section or column (cell templates, section headers with a + status icon, label/value rows) → a `Style`, `DataTemplate` or small `UserControl`, like the existing ones. +- **A class or method with several jobs** that are hard to follow together (e.g. a cache with its own locking and + timing inside a scanner, a 300-line `Start()`): extract the separate job into a method or a small class when that + makes fixes and tests local. +- **Dead or needless code**: unused settings, parameters, properties and events, branches that can't be reached, + interfaces or base classes with one implementation, helpers with one caller that only wrap another call. Removing + code is the simplest refactoring. + +Don't report: + +- Style preferences, renames for taste, "modernizing" code that works, or rewriting a feature. +- New layers or patterns for a single use: interfaces, base classes, factories, generic frameworks, dependency + injection, event aggregators, settings nobody needs, extension points "for later". +- Merging code that only looks similar but changes for different reasons (e.g. the ping loops of the scanners, the + Ping Monitor and the Traceroute): share the small pieces, not the loops. + +Rules for the proposal: + +- **Name the gain**: fewer lines and fewer places to touch for the next change (e.g. "3 field lists → 1, about −150 + lines", "a new input format is added in one place"). If the result isn't shorter or clearer, drop the finding. +- **Smallest construct that works**: a private method, a static helper, a record or a table/list that drives a loop + before a new class; a new class before a project-wide abstraction. Reuse the helpers that already exist + (`Utilities`, `Converters`, `Validators`, styles). +- **Behaviour stays identical**: name what must not change (export column order and names, settings names, file + formats, texts, public behaviour) and how it is proven (existing unit tests, or a test written *before* the + refactoring that compares the old and the new output). Never mix a behaviour change into a refactoring commit. +- **Order**: after the bug fixes; code-only refactorings first, XAML refactorings only after the current UI was tested + on Windows (so visual differences can be attributed), performance refactorings only after measuring. ### 5. Verify every finding @@ -136,6 +184,9 @@ Compared with: ### Other (security, localization, docs) | # | Finding | Proposed fix | +### Refactoring / simplification +| # | Where | Problem (duplication / complexity) | Proposal | Gain | Must stay identical / test | + ## Not reviewed / unverified @@ -143,8 +194,9 @@ Compared with: ``` -Reference code as `path:line`. Rate each finding (High / Medium / Low) in the finding text when it helps -prioritize. Proposed fixes describe the smallest change that follows the existing patterns. +Reference code as `path:line`. Give every finding a short ID that stays the same until it is done (e.g. `B3` for a +bug, `R2` for a refactoring) — commits and handover files reference it. Rate each finding (High / Medium / Low) in the +finding text when it helps 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 form (`.github/ISSUE_TEMPLATE/feature_request.yml`): a short title, the affected feature, "Problem / motivation", @@ -156,14 +208,18 @@ Don't change code during the review. Ask the user which findings to fix. When the user wants the work handed over (to another agent or for later), write the result to `.claude/reviews/.md` (kebab-case, e.g. `ip-scanner.md`; the folder is not committed). Write it as an -implementation plan, not as a report: verified findings with `path:line`, the concrete fix, the work packages / PR -order, decisions taken and still open, and what must be tested on Windows. Read existing files in `.claude/reviews/` -and `.claude/plans/` for the area first — they record earlier decisions. +implementation plan, not as a report: verified findings with ID, severity, `path:line`, scenario and the concrete fix +(bugs and refactorings in separate tables, refactorings after the bugs), the work packages / PR order, decisions taken +and still open, and what must be tested on Windows. Read existing files in `.claude/reviews/` and `.claude/plans/` +for the area first — they record earlier decisions. These files only track **open** work: when an item is done, +remove it (and its manual tests once they passed); the history is in git and the PRs. When fixing: - Follow the conventions and keep the change minimal; extract shared helpers only when the same code - already exists in two or more places. + already exists in two or more places. Keep the code simple: no new abstraction for a single use. +- One commit per finding, with its ID in the subject (e.g. `Fix: Port Scanner: ... (B3)`); a refactoring gets its own + commit without behaviour changes. - Add strings to `Strings.resx` and, in alphabetical order, to `Strings.Designer.cs`. - Update the feature documentation and add a changelog entry in `Website/docs/changelog/next-release.md`. - Never modify `Source/3rdparty/`. diff --git a/.claude/skills/feature-review/references/conventions.md b/.claude/skills/feature-review/references/conventions.md index fdfb50a29a..c8090b97f1 100644 --- a/.claude/skills/feature-review/references/conventions.md +++ b/.claude/skills/feature-review/references/conventions.md @@ -19,6 +19,7 @@ report that too. - Settings and profiles - Navigation and run command - PowerShell and external processes +- Shared helpers (reuse before writing new code) - Strings, docs and changelog ## View model basics @@ -221,6 +222,24 @@ Reference: `Source/NETworkManager.Models/Firewall/Firewall.cs`. untrusted: with `cmd.exe /c ...` characters like `&` or `|` run other commands. Only insert values that match the expected format (IP address, hostname regex). +## Shared helpers (reuse before writing new code) + +Duplicated code often re-implements one of these. A refactoring proposal should use them (or extend them) before it +adds a new helper. Check the class for the exact methods. + +- Host and port input: `HostRangeHelper` (Models: `CreateListFromInput`, `GetHostCount`, `ResolveAsync` with `!` + exclusions - IP Scanner, Port Scanner, Ping Monitor), `PortRangeHelper` (Utilities), validators in + `NETworkManager.Validators` (one per input format, used by tool, profile dialog and run command). +- Addresses and names: `IPAddressHelper` (compare / sort, private range, scope id), `MACAddressHelper` (format, + compare), `DNSClientHelper` / `DNSClient` (resolve A / AAAA / PTR with the app's DNS settings), `RegexHelper`. +- Lists and texts: `ListHelper.Modify` (history), `PlaceholderHelper` (`{{Name}}` placeholders), `DateTimeHelper`, + `ClipboardHelper`, `DialogHelper` (confirmations, `FormatItemList`). +- WPF: `BindingProxy`, `DataGridHelper`, `SearchTextBoxHelper`, `PermissionToolTipHelper` (Utilities.WPF), + `ObservableRangeCollection` (Controls), converters in `NETworkManager.Converters` (e.g. `PendingToStringConverter`), + shared styles in `Source/NETworkManager/Resources/Styles/`. +- PowerShell: `PowerShellHelper.EscapeSingleQuotes`; arrays (`ToPsArray`) and the shared runspace are still private to + `Firewall.cs` - a second user of them is a reason to move them to `PowerShellHelper`. + ## Strings, docs and changelog - User-visible text comes from `Strings.resx` (English only; other languages via Transifex), added in diff --git a/Source/NETworkManager.Converters/IPScannerDetectionMethodToStringConverter.cs b/Source/NETworkManager.Converters/IPScannerDetectionMethodToStringConverter.cs new file mode 100644 index 0000000000..12ba34d9e0 --- /dev/null +++ b/Source/NETworkManager.Converters/IPScannerDetectionMethodToStringConverter.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Windows.Data; +using NETworkManager.Localization.Resources; +using NETworkManager.Models.Network; + +namespace NETworkManager.Converters; + +/// +/// Convert the of a host to a translated +/// (e.g. "Ping, TCP"). +/// +public sealed class IPScannerDetectionMethodToStringConverter : IValueConverter +{ + /// + /// Convert the of a host to a translated . + /// + /// Probes the host answered as . + /// + /// "Status" to get the status of the host ("Up (detected by Ping, TCP)" / "Down"). + /// + /// Translated, comma-separated probes, "-/-" if the host didn't answer any probe. + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + var detectedBy = value is IPScannerDetectionMethod method ? method : IPScannerDetectionMethod.None; + + var names = GetNames(detectedBy); + + if (parameter as string == "Status") + return detectedBy == IPScannerDetectionMethod.None + ? Strings.Down + : string.Format(Strings.UpDetectedByX, string.Join(", ", names)); + + return detectedBy == IPScannerDetectionMethod.None ? "-/-" : string.Join(", ", names); + } + + /// + /// Gets the translated names of the probes a host answered. + /// + /// Probes the host answered. + /// Translated names, empty if the host didn't answer any probe. + private static List GetNames(IPScannerDetectionMethod detectedBy) + { + var names = new List(5); + + if (detectedBy.HasFlag(IPScannerDetectionMethod.ICMP)) + names.Add(Strings.Ping); + + if (detectedBy.HasFlag(IPScannerDetectionMethod.TCP)) + names.Add(Strings.TCP); + + if (detectedBy.HasFlag(IPScannerDetectionMethod.NetBIOS)) + names.Add(Strings.NetBIOS); + + if (detectedBy.HasFlag(IPScannerDetectionMethod.SNMP)) + names.Add(Strings.SNMP); + + if (detectedBy.HasFlag(IPScannerDetectionMethod.ARP)) + names.Add(Strings.ARPNDP); + + return names; + } + + /// + /// !!! Method not implemented !!! + /// + /// + /// + /// + /// + /// + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} diff --git a/Source/NETworkManager.Converters/IPScannerHostChangeToStringConverter.cs b/Source/NETworkManager.Converters/IPScannerHostChangeToStringConverter.cs new file mode 100644 index 0000000000..c7538054c1 --- /dev/null +++ b/Source/NETworkManager.Converters/IPScannerHostChangeToStringConverter.cs @@ -0,0 +1,56 @@ +using System; +using System.Globalization; +using System.Windows.Data; +using NETworkManager.Localization.Resources; +using NETworkManager.Models.Network; + +namespace NETworkManager.Converters; + +/// +/// Convert the of a host (since the previous scan) to a translated +/// (e.g. "New"). +/// +public sealed class IPScannerHostChangeToStringConverter : IValueConverter +{ + /// + /// Convert the of a host to a translated . + /// + /// Change of the host as . + /// + /// + /// + /// Translated change, empty if there is none. + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + return value is IPScannerHostChange change ? ToLocalizedString(change) : string.Empty; + } + + /// + /// Gets the translated change of a host. + /// + /// Change of the host. + /// Translated change, empty for . + public static string ToLocalizedString(IPScannerHostChange change) + { + return change switch + { + IPScannerHostChange.New => Strings.HostChange_New, + IPScannerHostChange.Gone => Strings.HostChange_Gone, + IPScannerHostChange.MACChanged => Strings.HostChange_MACChanged, + _ => string.Empty + }; + } + + /// + /// !!! Method not implemented !!! + /// + /// + /// + /// + /// + /// + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} diff --git a/Source/NETworkManager.Converters/IPScannerHostRoleToStringConverter.cs b/Source/NETworkManager.Converters/IPScannerHostRoleToStringConverter.cs new file mode 100644 index 0000000000..d6c683246a --- /dev/null +++ b/Source/NETworkManager.Converters/IPScannerHostRoleToStringConverter.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Windows.Data; +using NETworkManager.Localization.Resources; +using NETworkManager.Models.Network; + +namespace NETworkManager.Converters; + +/// +/// Convert the of a host to a translated +/// (e.g. "Default gateway, DNS server"). +/// +public sealed class IPScannerHostRoleToStringConverter : IValueConverter +{ + /// + /// Convert the of a host to a translated . + /// + /// Roles of the host as . + /// + /// + /// + /// Translated, comma-separated roles, null if the host has none (no tooltip). + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is not IPScannerHostRole role || role == IPScannerHostRole.None) + return null; + + return ToLocalizedString(role); + } + + /// + /// Gets the translated, comma-separated roles of a host. + /// + /// Roles of the host. + /// Translated roles, empty if the host has none. + public static string ToLocalizedString(IPScannerHostRole role) + { + var names = new List(4); + + if (role.HasFlag(IPScannerHostRole.LocalComputer)) + names.Add(Strings.ThisComputer); + + if (role.HasFlag(IPScannerHostRole.DefaultGateway)) + names.Add(Strings.DefaultGateway); + + if (role.HasFlag(IPScannerHostRole.DNSServer)) + names.Add(Strings.DNSServer); + + if (role.HasFlag(IPScannerHostRole.DHCPServer)) + names.Add(Strings.DHCPServer); + + return string.Join(", ", names); + } + + /// + /// !!! Method not implemented !!! + /// + /// + /// + /// + /// + /// + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} diff --git a/Source/NETworkManager.Converters/IPScannerOSGuessToStringConverter.cs b/Source/NETworkManager.Converters/IPScannerOSGuessToStringConverter.cs new file mode 100644 index 0000000000..e2713d565f --- /dev/null +++ b/Source/NETworkManager.Converters/IPScannerOSGuessToStringConverter.cs @@ -0,0 +1,67 @@ +using System; +using System.Globalization; +using System.Windows.Data; +using NETworkManager.Localization.Resources; +using NETworkManager.Models.Network; + +namespace NETworkManager.Converters; + +/// +/// Convert the and the hops of a host to a translated . +/// +public sealed class IPScannerOSGuessToStringConverter : IMultiValueConverter +{ + /// + /// Convert the and the hops of a host to a translated . + /// + /// + /// , the hops () and optionally whether the ping is still + /// running (, "..." then). + /// + /// + /// "ToolTip" to get the tooltip ("Based on the TTL of the ping reply (2 hops)"). + /// + /// Translated operating system ("-/-" without guess), or the tooltip (null without guess). + public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture) + { + var osGuess = values.Length > 0 && values[0] is IPScannerOSGuess value ? value : IPScannerOSGuess.None; + var isPending = values.Length > 2 && values[2] is true; + + if (parameter as string != "ToolTip") + return isPending ? "..." : osGuess == IPScannerOSGuess.None ? "-/-" : ToLocalizedString(osGuess); + + if (osGuess == IPScannerOSGuess.None) + return null; + + return string.Format(Strings.OSGuessBasedOnTheTTLXHops, values.Length > 1 && values[1] is int hops ? hops : 0); + } + + /// + /// Gets the translated name of a guessed operating system. + /// + /// Guessed operating system. + /// Translated name, empty without guess. + public static string ToLocalizedString(IPScannerOSGuess osGuess) + { + return osGuess switch + { + IPScannerOSGuess.LinuxUnix => Strings.LinuxUnix, + IPScannerOSGuess.Windows => Strings.Windows, + IPScannerOSGuess.NetworkDevice => Strings.NetworkDevice, + _ => string.Empty + }; + } + + /// + /// !!! Method not implemented !!! + /// + /// + /// + /// + /// + /// + public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} diff --git a/Source/NETworkManager.Converters/IPScannerStatusToolTipConverter.cs b/Source/NETworkManager.Converters/IPScannerStatusToolTipConverter.cs new file mode 100644 index 0000000000..02d6e98d09 --- /dev/null +++ b/Source/NETworkManager.Converters/IPScannerStatusToolTipConverter.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Net.NetworkInformation; +using System.Windows.Data; +using NETworkManager.Localization; +using NETworkManager.Localization.Resources; +using NETworkManager.Models.Network; + +namespace NETworkManager.Converters; + +/// +/// Convert a result of the IP Scanner to the tooltip of its status icon: ping status with time and TTL, open ports, +/// NetBIOS name and workgroup / domain - or whether the host answered a probe. +/// +public sealed class IPScannerStatusToolTipConverter : IValueConverter +{ + /// + /// Maximum number of open ports listed in the tooltip. + /// + private const int MaxPorts = 10; + + /// + /// Convert a result of the IP Scanner to the tooltip of its status icon. + /// + /// + /// , ports (), , or the + /// of a host with the probe as . + /// + /// + /// Probe as name of (e.g. "ICMP"). + /// + /// Tooltip text. + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + switch (value) + { + // Success (2 ms, TTL 64) / Timed out + case PingInfo pingInfo: + var status = ResourceTranslator.Translate(ResourceIdentifier.IPStatus, pingInfo.Status); + + if (pingInfo.Status != IPStatus.Success) + return status; + + var time = Models.Network.Ping.TimeToString(pingInfo.Status, pingInfo.Time); + + // No TTL for IPv6 + return pingInfo.TTL > 0 ? $"{status} ({time}, {Strings.TTL} {pingInfo.TTL})" : $"{status} ({time})"; + + // Open: 22 (ssh), 80 (http) / No open ports found! (closed or timed out - the ports that are not open may + // be hidden, see IPScanner.GetShownPorts) + case List ports: + var openPorts = ports.Where(x => x.State == PortState.Open).ToList(); + + if (openPorts.Count == 0) + return Strings.NoOpenPortsFound; + + var list = string.Join(", ", openPorts.Take(MaxPorts).Select(x => + string.IsNullOrEmpty(x.LookupInfo?.Service) ? $"{x.Port}" : $"{x.Port} ({x.LookupInfo.Service})")); + + if (openPorts.Count > MaxPorts) + list += " " + string.Format(Strings.PlusXMore, openPorts.Count - MaxPorts); + + return $"{ResourceTranslator.Translate(ResourceIdentifier.PortState, PortState.Open)}: {list}"; + + // Yes (PC01, WORKGROUP) / No + case NetBIOSInfo netBIOSInfo: + if (!netBIOSInfo.IsReachable) + return Strings.No; + + var names = new[] { netBIOSInfo.ComputerName, netBIOSInfo.GroupName } + .Where(x => !string.IsNullOrEmpty(x)).ToList(); + + return names.Count == 0 ? Strings.Yes : $"{Strings.Yes} ({string.Join(", ", names)})"; + + // Answered / No answer + case IPScannerDetectionMethod detectedBy: + return Enum.TryParse(parameter as string, out var method) && + detectedBy.HasFlag(method) + ? Strings.Answered + : Strings.NoAnswer; + + default: + return null; + } + } + + /// + /// !!! Method not implemented !!! + /// + /// + /// + /// + /// + /// + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} diff --git a/Source/NETworkManager.Converters/PortScannerPortChangeToStringConverter.cs b/Source/NETworkManager.Converters/PortScannerPortChangeToStringConverter.cs new file mode 100644 index 0000000000..ebebccafc6 --- /dev/null +++ b/Source/NETworkManager.Converters/PortScannerPortChangeToStringConverter.cs @@ -0,0 +1,55 @@ +using System; +using System.Globalization; +using System.Windows.Data; +using NETworkManager.Localization.Resources; +using NETworkManager.Models.Network; + +namespace NETworkManager.Converters; + +/// +/// Convert the of a port (since the previous scan) to a translated +/// (e.g. "Now open"). +/// +public sealed class PortScannerPortChangeToStringConverter : IValueConverter +{ + /// + /// Convert the of a port to a translated . + /// + /// Change of the port as . + /// + /// + /// + /// Translated change, empty if there is none. + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + return value is PortScannerPortChange change ? ToLocalizedString(change) : string.Empty; + } + + /// + /// Gets the translated change of a port. + /// + /// Change of the port. + /// Translated change, empty for . + public static string ToLocalizedString(PortScannerPortChange change) + { + return change switch + { + PortScannerPortChange.Opened => Strings.PortChange_Opened, + PortScannerPortChange.Closed => Strings.PortChange_Closed, + _ => string.Empty + }; + } + + /// + /// !!! Method not implemented !!! + /// + /// + /// + /// + /// + /// + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} diff --git a/Source/NETworkManager.Converters/AutoRefreshTimeToStringConverter.cs b/Source/NETworkManager.Converters/SNMPV3ErrorCodeToStringConverter.cs similarity index 54% rename from Source/NETworkManager.Converters/AutoRefreshTimeToStringConverter.cs rename to Source/NETworkManager.Converters/SNMPV3ErrorCodeToStringConverter.cs index 559f5c6c2d..6325933a54 100644 --- a/Source/NETworkManager.Converters/AutoRefreshTimeToStringConverter.cs +++ b/Source/NETworkManager.Converters/SNMPV3ErrorCodeToStringConverter.cs @@ -1,29 +1,30 @@ -using System; +using System; using System.Globalization; using System.Windows.Data; using NETworkManager.Localization; -using NETworkManager.Utilities; +using NETworkManager.Models.Network; namespace NETworkManager.Converters; /// -/// Convert to translated or wise versa. +/// Convert a to a translated (e.g. "Authentication failed"). /// -public sealed class AutoRefreshTimeToStringConverter : IValueConverter +public sealed class SNMPV3ErrorCodeToStringConverter : IValueConverter { /// - /// Convert to translated . + /// Convert a to a translated . /// - /// Object from type . + /// Error code as . /// /// /// - /// Translated . + /// Translated error, empty for . public object Convert(object value, Type targetType, object parameter, CultureInfo culture) { - return value is not TimeUnit timeUnit - ? "-/-" - : ResourceTranslator.Translate(ResourceIdentifier.TimeUnit, timeUnit); + if (value is not SNMPV3ErrorCode errorCode || errorCode == SNMPV3ErrorCode.None) + return string.Empty; + + return ResourceTranslator.Translate(ResourceIdentifier.SNMPV3ErrorCode, errorCode); } /// @@ -38,4 +39,4 @@ public object ConvertBack(object value, Type targetType, object parameter, Cultu { throw new NotImplementedException(); } -} \ No newline at end of file +} diff --git a/Source/NETworkManager.Converters/SuccessStyleConverter.cs b/Source/NETworkManager.Converters/SuccessStyleConverter.cs new file mode 100644 index 0000000000..5bfbf77644 --- /dev/null +++ b/Source/NETworkManager.Converters/SuccessStyleConverter.cs @@ -0,0 +1,52 @@ +using System; +using System.Globalization; +using System.Windows; +using System.Windows.Data; +using NETworkManager.Models.Network; + +namespace NETworkManager.Converters; + +/// +/// Convert a result to the style of a status icon: a green check (CheckRectangle) or a red cross +/// (ErrorRectangle), like the action of a rule in the Firewall. +/// +public sealed class SuccessStyleConverter : IValueConverter +{ + /// + /// Convert a result to the style of a status icon. + /// + /// + /// (e.g. a port is open), or the of a host with + /// the probe as . + /// + /// + /// Probe as name of (e.g. "ICMP"). + /// + /// Style of the check for success / an answer of the probe, of the cross otherwise. + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + var isSuccess = value switch + { + bool boolValue => boolValue, + IPScannerDetectionMethod detectedBy => + Enum.TryParse(parameter as string, out var method) && + detectedBy.HasFlag(method), + _ => false + }; + + return Application.Current.FindResource(isSuccess ? "CheckRectangle" : "ErrorRectangle"); + } + + /// + /// !!! Method not implemented !!! + /// + /// + /// + /// + /// + /// + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} diff --git a/Source/NETworkManager.Converters/WebCertificateToStringConverter.cs b/Source/NETworkManager.Converters/WebCertificateToStringConverter.cs new file mode 100644 index 0000000000..282ae1f77f --- /dev/null +++ b/Source/NETworkManager.Converters/WebCertificateToStringConverter.cs @@ -0,0 +1,54 @@ +using System; +using System.Globalization; +using System.Windows.Data; +using NETworkManager.Localization.Resources; +using NETworkManager.Models.Network; +using NETworkManager.Utilities; + +namespace NETworkManager.Converters; + +/// +/// Convert a to a translated for the row details of the +/// IP Scanner (subject, issuer or end of the validity). +/// +public sealed class WebCertificateToStringConverter : IValueConverter +{ + /// + /// Convert a to a translated . + /// + /// Certificate as . + /// + /// + /// "Subject", "Issuer" ("Self-signed" for a self-signed certificate) or "NotAfter" (local time, "(expired)" if + /// it is expired). + /// + /// + /// Translated value, "-/-" without certificate (e.g. http). + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is not WebCertificateInfo certificate) + return "-/-"; + + var notAfter = DateTimeHelper.DateTimeToFullDateTimeString(certificate.NotAfter.ToLocalTime()); + + return parameter as string switch + { + "Issuer" => certificate.IsSelfSigned ? Strings.SelfSigned : certificate.Issuer, + "NotAfter" => certificate.IsExpired ? string.Format(Strings.XExpired, notAfter) : notAfter, + _ => certificate.Subject + }; + } + + /// + /// !!! Method not implemented !!! + /// + /// + /// + /// + /// + /// + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} diff --git a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs index f4e3005577..59c6116441 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs +++ b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs @@ -834,6 +834,24 @@ public static string All { } } + /// + /// Looks up a localized string similar to All IP addresses are excluded (inputs starting with !), there is nothing to scan.. + /// + public static string AllIPAddressesAreExcludedMessage { + get { + return ResourceManager.GetString("AllIPAddressesAreExcludedMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to All local subnets. + /// + public static string AllLocalSubnets { + get { + return ResourceManager.GetString("AllLocalSubnets", resourceCulture); + } + } + /// /// Looks up a localized string similar to Allow. /// @@ -879,6 +897,15 @@ public static string AnErrorOccurredWhileExportingTheData { } } + /// + /// Looks up a localized string similar to Answered. + /// + public static string Answered { + get { + return ResourceManager.GetString("Answered", resourceCulture); + } + } + /// /// Looks up a localized string similar to Answers. /// @@ -1275,6 +1302,15 @@ public static string ARP { } } + /// + /// Looks up a localized string similar to ARP / NDP. + /// + public static string ARPNDP { + get { + return ResourceManager.GetString("ARPNDP", resourceCulture); + } + } + /// /// Looks up a localized string similar to ASN. /// @@ -1356,6 +1392,15 @@ public static string Authorities { } } + /// + /// Looks up a localized string similar to Automatically scan every. + /// + public static string AutomaticallyScanEvery { + get { + return ResourceManager.GetString("AutomaticallyScanEvery", resourceCulture); + } + } + /// /// Looks up a localized string similar to Automatically update every. /// @@ -1689,6 +1734,15 @@ public static string CapturingNetworkPackagesDots { } } + /// + /// Looks up a localized string similar to Certificate. + /// + public static string Certificate { + get { + return ResourceManager.GetString("Certificate", resourceCulture); + } + } + /// /// Looks up a localized string similar to Change. /// @@ -3415,6 +3469,15 @@ public static string DetectLocalIPAddressAndSubnetmask { } } + /// + /// Looks up a localized string similar to Detect local subnet. + /// + public static string DetectLocalSubnet { + get { + return ResourceManager.GetString("DetectLocalSubnet", resourceCulture); + } + } + /// /// Looks up a localized string similar to Developed and maintained by {0} with the help of the. /// @@ -4055,6 +4118,15 @@ public static string Enable { } } + /// + /// Looks up a localized string similar to Enable at least one of ping, port scan, NetBIOS or SNMP in the settings. A host is only found if it answers one of them.. + /// + public static string EnableAtLeastOneProbeToFindHostsMessage { + get { + return ResourceManager.GetString("EnableAtLeastOneProbeToFindHostsMessage", resourceCulture); + } + } + /// /// Looks up a localized string similar to Enable Credential Security Support Provider. /// @@ -5111,6 +5183,15 @@ public static string FilterProfilesDots { } } + /// + /// Looks up a localized string similar to Find hosts in the local subnet. + /// + public static string FindHostsInTheLocalSubnet { + get { + return ResourceManager.GetString("FindHostsInTheLocalSubnet", resourceCulture); + } + } + /// /// Looks up a localized string similar to Firewall. /// @@ -5743,6 +5824,15 @@ public static string Groups { } } + /// + /// Looks up a localized string similar to Guess the operating system from the TTL. + /// + public static string GuessTheOperatingSystemFromTheTTL { + get { + return ResourceManager.GetString("GuessTheOperatingSystemFromTheTTL", resourceCulture); + } + } + /// /// Looks up a localized string similar to Handle. /// @@ -5793,7 +5883,7 @@ public static string HelpMessage_AdjustThreadsAutomatically { /// ///Only the PowerShell consoles configured in the PowerShell settings are modified. Both Windows PowerShell and PWSH (PowerShell 7 and higher) are supported. /// - ///Click in the upper right corner on the help / documentation button ( [rest of string was truncated]";. + ///Click in the upper right corner on the help / documentation button (left [rest of string was truncated]";. /// public static string HelpMessage_ApplyThemeToPowerShellConsole { get { @@ -5912,6 +6002,15 @@ public static string HelpMessage_ExperimentalFeatures { } } + /// + /// Looks up a localized string similar to Find hosts in a local subnet that don't answer the ping, a port or NetBIOS (e.g. a firewall drops everything) by their entry in the neighbor table (ARP / NDP). To reach a host in a local subnet, Windows has to resolve its MAC address first, and a host answers this request even if its firewall drops everything else. No additional packets are sent, except to check an entry from earlier traffic again. Hosts behind a router can't be found this way. Only decides if a host is up - the MAC address and vendor are s [rest of string was truncated]";. + /// + public static string HelpMessage_FindHostsInTheLocalSubnet { + get { + return ResourceManager.GetString("HelpMessage_FindHostsInTheLocalSubnet", resourceCulture); + } + } + /// /// Looks up a localized string similar to Show all active Windows Firewall rules (e.g. Windows built-in rules, rules created by other applications or from Group Policy) instead of only the rules managed by NETworkManager. Rules not managed by NETworkManager can be enabled, disabled, deleted or copied, but not edited. Rules from a policy (e.g. Group Policy or MDM) can only be copied.. /// @@ -5921,6 +6020,15 @@ public static string HelpMessage_FirewallShowAllRules { } } + /// + /// Looks up a localized string similar to Guess the operating system from the TTL of the ping reply: every operating system starts with a typical TTL, 64 (Linux, Unix, macOS, Android, iOS), 128 (Windows) or 255 (network devices like routers, switches and printers), and every router on the way reduces it by one. No additional packets are sent. It's a hint, not a detection: the TTL can be changed on the host, NAT and VPN can change it. Not available for IPv6 (the ping reply has no TTL).. + /// + public static string HelpMessage_GuessTheOperatingSystemFromTheTTL { + get { + return ResourceManager.GetString("HelpMessage_GuessTheOperatingSystemFromTheTTL", resourceCulture); + } + } + /// /// Looks up a localized string similar to Number of backups that are retained before the oldest one is deleted.. /// @@ -5984,6 +6092,15 @@ public static string HelpMessage_PasswordNotDisplayedCanBeOverwritten { } } + /// + /// Looks up a localized string similar to Send a ping (ICMP echo request) to every host. Disable it if ICMP is blocked or rate limited in the network, the scan is faster then. Hosts that only answer the ping are not found without it.. + /// + public static string HelpMessage_PingHosts { + get { + return ResourceManager.GetString("HelpMessage_PingHosts", resourceCulture); + } + } + /// /// Looks up a localized string similar to Only hosts that answer a ping (ICMP echo, with the attempts, timeout and buffer below) are scanned. Faster for ranges with many unused IP addresses. Hosts that block ICMP (e.g. Windows with the firewall enabled) are not scanned.. /// @@ -6086,6 +6203,42 @@ public static string HelpMessage_RDPAuthenticationLevel { } } + /// + /// Looks up a localized string similar to Read the name, description and location (sysName, sysDescr, sysLocation) of every IP address with an SNMP v1 / v2c request to UDP port 161, sent with the other probes. A host that answers is shown as up (detected by SNMP), also if it doesn't answer the ping, a port or NetBIOS. A host with another community usually doesn't answer (timeout). Many intrusion detection systems report requests with common communities.. + /// + public static string HelpMessage_ReadTheSNMPSystemInformation { + get { + return ResourceManager.GetString("HelpMessage_ReadTheSNMPSystemInformation", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Read the web page (GET /) of every reachable host with an open port of the list below, in this order (the port scan must be enabled and the ports must also be in its list): status code, title and the Server and X-Powered-By headers. The first port that answers is used (http on 80 and 8080, https and then http on other ports), redirects on the same host are followed. No cookies or credentials are sent, certificate errors are accepted. The request appears in the log of the web server.. + /// + public static string HelpMessage_ReadWebPages { + get { + return ResourceManager.GetString("HelpMessage_ReadWebPages", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Show the MAC address and vendor of every found host: read from the neighbor table (ARP / NDP) for hosts in a local subnet, otherwise from the NetBIOS answer (if NetBIOS is enabled). Doesn't find hosts - a host that doesn't answer the ping, a port or NetBIOS is only found with "Find hosts in the local subnet".. + /// + public static string HelpMessage_ResolveMACAddressAndVendor { + get { + return ResourceManager.GetString("HelpMessage_ResolveMACAddressAndVendor", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Resolve the mDNS hostname (e.g. macbook.local) of every reachable host with a reverse lookup sent to UDP port 5353 of the host. Answered by Apple devices, Linux (Avahi), Android, printers and many IoT devices, usually only in the local subnet. Used as hostname if there is no DNS hostname.. + /// + public static string HelpMessage_ResolveMDNSHostname { + get { + return ResourceManager.GetString("HelpMessage_ResolveMDNSHostname", resourceCulture); + } + } + /// /// Looks up a localized string similar to Displays a dialog to save credentials after a successful sign-in.. /// @@ -6095,6 +6248,15 @@ public static string HelpMessage_SaveCredentials { } } + /// + /// Looks up a localized string similar to Only when the scan is repeated automatically ("Automatically scan every"): one notification per scan with the changes since the previous scan.. + /// + public static string HelpMessage_ScanChangesNotification { + get { + return ResourceManager.GetString("HelpMessage_ScanChangesNotification", resourceCulture); + } + } + /// /// Looks up a localized string similar to Only IPv4 addresses are scanned for NetBIOS, NetBIOS doesn't exist over IPv6.. /// @@ -6213,29 +6375,38 @@ public static string Host { } /// - /// Looks up a localized string similar to Hosting. + /// Looks up a localized string similar to Gone. /// - public static string Hosting { + public static string HostChange_Gone { get { - return ResourceManager.GetString("Hosting", resourceCulture); + return ResourceManager.GetString("HostChange_Gone", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to MAC changed. + /// + public static string HostChange_MACChanged { + get { + return ResourceManager.GetString("HostChange_MACChanged", resourceCulture); } } /// - /// Looks up a localized string similar to Host is down. + /// Looks up a localized string similar to New. /// - public static string HostIsDown { + public static string HostChange_New { get { - return ResourceManager.GetString("HostIsDown", resourceCulture); + return ResourceManager.GetString("HostChange_New", resourceCulture); } } /// - /// Looks up a localized string similar to Host is up. + /// Looks up a localized string similar to Hosting. /// - public static string HostIsUp { + public static string Hosting { get { - return ResourceManager.GetString("HostIsUp", resourceCulture); + return ResourceManager.GetString("Hosting", resourceCulture); } } @@ -6266,6 +6437,24 @@ public static string HostnameOrIPAddress { } } + /// + /// Looks up a localized string similar to Hostname resolution is disabled!. + /// + public static string HostnameResolutionIsDisabled { + get { + return ResourceManager.GetString("HostnameResolutionIsDisabled", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Hostnames. + /// + public static string Hostnames { + get { + return ResourceManager.GetString("Hostnames", resourceCulture); + } + } + /// /// Looks up a localized string similar to Hosts. /// @@ -6942,6 +7131,15 @@ public static string ISP { } } + /// + /// Looks up a localized string similar to Issuer. + /// + public static string Issuer { + get { + return ResourceManager.GetString("Issuer", resourceCulture); + } + } + /// /// Looks up a localized string similar to Jitter. /// @@ -7365,6 +7563,15 @@ public static string Lime { } } + /// + /// Looks up a localized string similar to Linux / Unix. + /// + public static string LinuxUnix { + get { + return ResourceManager.GetString("LinuxUnix", resourceCulture); + } + } + /// /// Looks up a localized string similar to Listeners. /// @@ -7734,6 +7941,24 @@ public static string MaxPortThreads { } } + /// + /// Looks up a localized string similar to mDNS. + /// + public static string MDNS { + get { + return ResourceManager.GetString("MDNS", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to mDNS hostname. + /// + public static string MDNSHostname { + get { + return ResourceManager.GetString("MDNSHostname", resourceCulture); + } + } + /// /// Looks up a localized string similar to Measured time. /// @@ -8139,6 +8364,15 @@ public static string NetworkConnectionType { } } + /// + /// Looks up a localized string similar to Network device. + /// + public static string NetworkDevice { + get { + return ResourceManager.GetString("NetworkDevice", resourceCulture); + } + } + /// /// Looks up a localized string similar to Network Interface. /// @@ -8310,6 +8544,24 @@ public static string Next { } } + /// + /// Looks up a localized string similar to Next refresh in {0}. + /// + public static string NextRefreshInX { + get { + return ResourceManager.GetString("NextRefreshInX", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Next scan in {0}. + /// + public static string NextScanInX { + get { + return ResourceManager.GetString("NextScanInX", resourceCulture); + } + } + /// /// Looks up a localized string similar to No. /// @@ -8328,6 +8580,15 @@ public static string NoADNSRecordsResolvedForXXXMessage { } } + /// + /// Looks up a localized string similar to No answer. + /// + public static string NoAnswer { + get { + return ResourceManager.GetString("NoAnswer", resourceCulture); + } + } + /// /// Looks up a localized string similar to No discovery protocol packages received!. /// @@ -8878,6 +9139,24 @@ public static string Organization { } } + /// + /// Looks up a localized string similar to OS (guess). + /// + public static string OSGuess { + get { + return ResourceManager.GetString("OSGuess", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Based on the TTL of the ping reply ({0} hops). + /// + public static string OSGuessBasedOnTheTTLXHops { + get { + return ResourceManager.GetString("OSGuessBasedOnTheTTLXHops", resourceCulture); + } + } + /// /// Looks up a localized string similar to OUI. /// @@ -9130,6 +9409,15 @@ public static string PingDetails { } } + /// + /// Looks up a localized string similar to Ping hosts. + /// + public static string PingHosts { + get { + return ResourceManager.GetString("PingHosts", resourceCulture); + } + } + /// /// Looks up a localized string similar to Ping hosts before the scan. /// @@ -9139,6 +9427,15 @@ public static string PingHostsBeforeScan { } } + /// + /// Looks up a localized string similar to Ping is disabled!. + /// + public static string PingIsDisabled { + get { + return ResourceManager.GetString("PingIsDisabled", resourceCulture); + } + } + /// /// Looks up a localized string similar to Ping Monitor. /// @@ -9184,6 +9481,15 @@ public static string Placeholder { } } + /// + /// Looks up a localized string similar to Play sound on changes. + /// + public static string PlaySoundOnChanges { + get { + return ResourceManager.GetString("PlaySoundOnChanges", resourceCulture); + } + } + /// /// Looks up a localized string similar to Play sound on status change. /// @@ -9229,6 +9535,24 @@ public static string Portable { } } + /// + /// Looks up a localized string similar to No longer open. + /// + public static string PortChange_Closed { + get { + return ResourceManager.GetString("PortChange_Closed", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Now open. + /// + public static string PortChange_Opened { + get { + return ResourceManager.GetString("PortChange_Opened", resourceCulture); + } + } + /// /// Looks up a localized string similar to Port description. /// @@ -9391,6 +9715,15 @@ public static string Preview { } } + /// + /// Looks up a localized string similar to Previous MAC address: {0}. + /// + public static string PreviousMACAddressX { + get { + return ResourceManager.GetString("PreviousMACAddressX", resourceCulture); + } + } + /// /// Looks up a localized string similar to Primary DNS server. /// @@ -9475,7 +9808,7 @@ public static string Profile { /// /// Looks up a localized string similar to Profile files are encrypted on disk using AES with a key size of 256 bits and a block size of 128 bits in CBC mode. The encryption key is derived from a master password using Rfc2898DeriveBytes (PBKDF2) with 1,000,000 iterations. At runtime, passwords are stored as SecureString once the profile file is loaded. For some functions, the password must be converted to a normal string and remains unencrypted in memory until the garbage collector cleans them up. /// - ///First make a backup copy of your profile files be [rest of string was truncated]";. + ///First make a backup copy of your profile files befo [rest of string was truncated]";. /// public static string ProfileEncryptionDisclaimer { get { @@ -10095,6 +10428,42 @@ public static string RDAPXIsNotARegisteredDomainShowingY { } } + /// + /// Looks up a localized string similar to Reading web pages is disabled!. + /// + public static string ReadingWebPagesIsDisabled { + get { + return ResourceManager.GetString("ReadingWebPagesIsDisabled", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Read the SNMP system information. + /// + public static string ReadTheSNMPSystemInformation { + get { + return ResourceManager.GetString("ReadTheSNMPSystemInformation", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Read web pages. + /// + public static string ReadWebPages { + get { + return ResourceManager.GetString("ReadWebPages", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Realm. + /// + public static string Realm { + get { + return ResourceManager.GetString("Realm", resourceCulture); + } + } + /// /// Looks up a localized string similar to Received. /// @@ -10212,6 +10581,15 @@ public static string RedirectSmartcards { } } + /// + /// Looks up a localized string similar to Redirect to. + /// + public static string RedirectTo { + get { + return ResourceManager.GetString("RedirectTo", resourceCulture); + } + } + /// /// Looks up a localized string similar to Refresh. /// @@ -11463,6 +11841,15 @@ public static string ResolveMACAddressIsDisabled { } } + /// + /// Looks up a localized string similar to Resolve mDNS hostname. + /// + public static string ResolveMDNSHostname { + get { + return ResourceManager.GetString("ResolveMDNSHostname", resourceCulture); + } + } + /// /// Looks up a localized string similar to Resolve PTR. /// @@ -12398,6 +12785,15 @@ public static string SelectOIDProfile { } } + /// + /// Looks up a localized string similar to Self-signed. + /// + public static string SelfSigned { + get { + return ResourceManager.GetString("SelfSigned", resourceCulture); + } + } + /// /// Looks up a localized string similar to Send. /// @@ -12789,6 +13185,15 @@ public static string ShowMap { } } + /// + /// Looks up a localized string similar to Show notification popup on changes. + /// + public static string ShowNotificationPopupOnChanges { + get { + return ResourceManager.GetString("ShowNotificationPopupOnChanges", resourceCulture); + } + } + /// /// Looks up a localized string similar to Show notification popup on status change. /// @@ -12888,6 +13293,15 @@ public static string SignalStrength { } } + /// + /// Looks up a localized string similar to Since last scan. + /// + public static string SinceLastScan { + get { + return ResourceManager.GetString("SinceLastScan", resourceCulture); + } + } + /// /// Looks up a localized string similar to Size. /// @@ -13095,6 +13509,33 @@ public static string SNMPErrorCode_WrongValue { } } + /// + /// Looks up a localized string similar to SNMP is disabled!. + /// + public static string SNMPIsDisabled { + get { + return ResourceManager.GetString("SNMPIsDisabled", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Location. + /// + public static string SNMPLocation { + get { + return ResourceManager.GetString("SNMPLocation", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to SNMP name. + /// + public static string SNMPName { + get { + return ResourceManager.GetString("SNMPName", resourceCulture); + } + } + /// /// Looks up a localized string similar to Authentication failure (incorrect password, community or key).. /// @@ -13113,6 +13554,15 @@ public static string SNMPV3ErrorCode_UnknownUserName { } } + /// + /// Looks up a localized string similar to SNMP version and credentials of "{0}". + /// + public static string SNMPVersionAndCredentialsOfX { + get { + return ResourceManager.GetString("SNMPVersionAndCredentialsOfX", resourceCulture); + } + } + /// /// Looks up a localized string similar to SNTP Lookup. /// @@ -13357,6 +13807,15 @@ public static string StatusChange { } } + /// + /// Looks up a localized string similar to Status code. + /// + public static string StatusCode { + get { + return ResourceManager.GetString("StatusCode", resourceCulture); + } + } + /// /// Looks up a localized string similar to Status window. /// @@ -13429,6 +13888,15 @@ public static string SubnetMask { } } + /// + /// Looks up a localized string similar to Subnet of network interface. + /// + public static string SubnetOfNetworkInterface { + get { + return ResourceManager.GetString("SubnetOfNetworkInterface", resourceCulture); + } + } + /// /// Looks up a localized string similar to Subnetting. /// @@ -13510,6 +13978,15 @@ public static string Taupe { } } + /// + /// Looks up a localized string similar to TCP. + /// + public static string TCP { + get { + return ResourceManager.GetString("TCP", resourceCulture); + } + } + /// /// Looks up a localized string similar to TCP/IP stack is available. "{0}" is reachable via ICMP!. /// @@ -13780,6 +14257,15 @@ public static string TheSettingsLocationIsNotAffected { } } + /// + /// Looks up a localized string similar to This computer. + /// + public static string ThisComputer { + get { + return ResourceManager.GetString("ThisComputer", resourceCulture); + } + } + /// /// Looks up a localized string similar to This will reset all settings!. /// @@ -13969,6 +14455,15 @@ public static string Timezone { } } + /// + /// Looks up a localized string similar to Title. + /// + public static string Title { + get { + return ResourceManager.GetString("Title", resourceCulture); + } + } + /// /// Looks up a localized string similar to Total bytes received. /// @@ -14176,6 +14671,15 @@ public static string UpdateNow { } } + /// + /// Looks up a localized string similar to Up (detected by {0}). + /// + public static string UpDetectedByX { + get { + return ResourceManager.GetString("UpDetectedByX", resourceCulture); + } + } + /// /// Looks up a localized string similar to Upgraded to {0}. /// @@ -14428,6 +14932,15 @@ public static string ValidationFailed { } } + /// + /// Looks up a localized string similar to Valid until. + /// + public static string ValidUntil { + get { + return ResourceManager.GetString("ValidUntil", resourceCulture); + } + } + /// /// Looks up a localized string similar to Value. /// @@ -14583,6 +15096,24 @@ public static string WarnMessage_MultipleInstances { } } + /// + /// Looks up a localized string similar to The community is saved as plain text in the settings file (not encrypted). Use a read-only community (e.g. public), not one with write access.. + /// + public static string WarnMessage_SNMPCommunity { + get { + return ResourceManager.GetString("WarnMessage_SNMPCommunity", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Web. + /// + public static string Web { + get { + return ResourceManager.GetString("Web", resourceCulture); + } + } + /// /// Looks up a localized string similar to Web Console. /// @@ -14794,6 +15325,15 @@ public static string Window { } } + /// + /// Looks up a localized string similar to Windows. + /// + public static string Windows { + get { + return ResourceManager.GetString("Windows", resourceCulture); + } + } + /// /// Looks up a localized string similar to Windows DNS settings. /// @@ -14875,6 +15415,24 @@ public static string XDown { } } + /// + /// Looks up a localized string similar to {0} (expired). + /// + public static string XExpired { + get { + return ResourceManager.GetString("XExpired", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0} Gone. + /// + public static string XGone { + get { + return ResourceManager.GetString("XGone", resourceCulture); + } + } + /// /// Looks up a localized string similar to {0} host(s) did not answer the ping and were not scanned.. /// @@ -14884,6 +15442,42 @@ public static string XHostsDidNotAnswerThePingAndWereNotScanned { } } + /// + /// Looks up a localized string similar to {0} MAC changed. + /// + public static string XMACChanged { + get { + return ResourceManager.GetString("XMACChanged", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0} New. + /// + public static string XNew { + get { + return ResourceManager.GetString("XNew", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0} No longer open. + /// + public static string XNoLongerOpen { + get { + return ResourceManager.GetString("XNoLongerOpen", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0} Now open. + /// + public static string XNowOpen { + get { + return ResourceManager.GetString("XNowOpen", resourceCulture); + } + } + /// /// Looks up a localized string similar to {0} Open. /// @@ -14902,6 +15496,15 @@ public static string XPaused { } } + /// + /// Looks up a localized string similar to X-Powered-By. + /// + public static string XPoweredBy { + get { + return ResourceManager.GetString("XPoweredBy", resourceCulture); + } + } + /// /// Looks up a localized string similar to {0} Timed out. /// diff --git a/Source/NETworkManager.Localization/Resources/Strings.resx b/Source/NETworkManager.Localization/Resources/Strings.resx index 4bee6b1c4b..bcfccfd993 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.resx +++ b/Source/NETworkManager.Localization/Resources/Strings.resx @@ -1321,6 +1321,43 @@ Profile files are not affected! Change + + Since last scan + Column header (IP Scanner, Port Scanner): what changed since the last scan, e.g. "New", "Gone". + + + New + + + Gone + + + MAC changed + + + {0} New + + + {0} Gone + + + {0} MAC changed + + + Previous MAC address: {0} + + + Now open + + + No longer open + + + {0} Now open + + + {0} No longer open + Change Master Password... @@ -2490,12 +2527,6 @@ is disabled! Display duration (seconds) - - Host is up - - - Host is down - The application will be restarted... @@ -4000,6 +4031,24 @@ Right-click for more options. Reloaded at {0} + + Next refresh in {0} + + + Next scan in {0} + + + Automatically scan every + + + Show notification popup on changes + + + Play sound on changes + + + Only when the scan is repeated automatically ("Automatically scan every"): one notification per scan with the changes since the previous scan. + Failed to load hosts file: {0} @@ -5152,4 +5201,160 @@ Do you want to start the scan? PTR records can only be looked up for IP addresses. + + Up (detected by {0}) + + + TCP + + + Ping hosts + + + Send a ping (ICMP echo request) to every host. Disable it if ICMP is blocked or rate limited in the network, the scan is faster then. Hosts that only answer the ping are not found without it. + + + Ping is disabled! + + + Enable at least one of ping, port scan, NetBIOS or SNMP in the settings. A host is only found if it answers one of them. + + + ARP / NDP + + + Find hosts in the local subnet + + + Find hosts in a local subnet that don't answer the ping, a port or NetBIOS (e.g. a firewall drops everything) by their entry in the neighbor table (ARP / NDP). To reach a host in a local subnet, Windows has to resolve its MAC address first, and a host answers this request even if its firewall drops everything else. No additional packets are sent, except to check an entry from earlier traffic again. Hosts behind a router can't be found this way. Only decides if a host is up - the MAC address and vendor are shown with "Resolve MAC address and vendor". + + + Detect local subnet + + + Subnet of network interface + + + All local subnets + + + Guess the operating system from the TTL + + + Guess the operating system from the TTL of the ping reply: every operating system starts with a typical TTL, 64 (Linux, Unix, macOS, Android, iOS), 128 (Windows) or 255 (network devices like routers, switches and printers), and every router on the way reduces it by one. No additional packets are sent. It's a hint, not a detection: the TTL can be changed on the host, NAT and VPN can change it. Not available for IPv6 (the ping reply has no TTL). + + + OS (guess) + + + Linux / Unix + + + Network device + + + Windows + + + Based on the TTL of the ping reply ({0} hops) + + + This computer + + + mDNS + + + Resolve mDNS hostname + + + Resolve the mDNS hostname (e.g. macbook.local) of every reachable host with a reverse lookup sent to UDP port 5353 of the host. Answered by Apple devices, Linux (Avahi), Android, printers and many IoT devices, usually only in the local subnet. Used as hostname if there is no DNS hostname. + + + SNMP name + + + Read the SNMP system information + + + Read the name, description and location (sysName, sysDescr, sysLocation) of every IP address with an SNMP v1 / v2c request to UDP port 161, sent with the other probes. A host that answers is shown as up (detected by SNMP), also if it doesn't answer the ping, a port or NetBIOS. A host with another community usually doesn't answer (timeout). Many intrusion detection systems report requests with common communities. + + + The community is saved as plain text in the settings file (not encrypted). Use a read-only community (e.g. public), not one with write access. + + + Show the MAC address and vendor of every found host: read from the neighbor table (ARP / NDP) for hosts in a local subnet, otherwise from the NetBIOS answer (if NetBIOS is enabled). Doesn't find hosts - a host that doesn't answer the ping, a port or NetBIOS is only found with "Find hosts in the local subnet". + + + Status code + + + Title + + + Web + + + Hostnames + + + X-Powered-By + + + Location + Physical location of a device (SNMP sysLocation, e.g. "Server room"), not the location of a file or folder. + + + Hostname resolution is disabled! + + + Reading web pages is disabled! + + + SNMP is disabled! + + + Answered + + + No answer + + + Read web pages + + + Read the web page (GET /) of every reachable host with an open port of the list below, in this order (the port scan must be enabled and the ports must also be in its list): status code, title and the Server and X-Powered-By headers. The first port that answers is used (http on 80 and 8080, https and then http on other ports), redirects on the same host are followed. No cookies or credentials are sent, certificate errors are accepted. The request appears in the log of the web server. + + + Realm + Realm of an HTTP login prompt (WWW-Authenticate header), e.g. the name of a router. + + + Redirect to + + + Certificate + + + Issuer + + + Valid until + + + Self-signed + + + {0} (expired) + + + All IP addresses are excluded (inputs starting with !), there is nothing to scan. + + + mDNS hostname + + + SNMP version and credentials of "{0}" + IP Scanner: the tab uses the SNMP version and credentials of a profile or group, {0} is its name. + \ No newline at end of file diff --git a/Source/NETworkManager.Models/Export/ExportManager.IPScannerHostInfo.cs b/Source/NETworkManager.Models/Export/ExportManager.IPScannerHostInfo.cs index 509d6fdc08..6eebe414cb 100644 --- a/Source/NETworkManager.Models/Export/ExportManager.IPScannerHostInfo.cs +++ b/Source/NETworkManager.Models/Export/ExportManager.IPScannerHostInfo.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Net; using System.Text; using System.Xml.Linq; using NETworkManager.Models.Network; @@ -18,19 +19,23 @@ public static partial class ExportManager /// Path to the export file. /// Allowed are CSV, XML or JSON. /// Objects as to export. + /// + /// Options of the scan the hosts are from. Values of disabled probes are exported as empty values instead of + /// their placeholders (e.g. port status "Closed" if the port scan was disabled). + /// public static void Export(string filePath, ExportFileType fileType, - IReadOnlyList collection) + IReadOnlyList collection, IPScannerResultOptions options) { switch (fileType) { case ExportFileType.Csv: - CreateCsv(collection, filePath); + CreateCsv(collection, options, filePath); break; case ExportFileType.Xml: - CreateXml(collection, filePath); + CreateXml(collection, options, filePath); break; case ExportFileType.Json: - CreateJson(collection, filePath); + CreateJson(collection, options, filePath); break; case ExportFileType.Txt: default: @@ -38,38 +43,98 @@ public static void Export(string filePath, ExportFileType fileType, } } + /// + /// Value of an exported column (CSV) / element (XML, JSON key), see . + /// + /// Name of the column / element. + /// Value (e.g. a bool, an enum or a string), null for an empty value. + /// The value is quoted in the CSV file (text that may contain a comma). + private sealed record ExportField(string Name, object Value, bool IsQuoted = false); + + /// + /// Gets the exported values of a host - one list for CSV, XML and JSON. The released columns come first, new + /// ones are appended at the end, so the position of a column never changes. Values of a probe that is still + /// running (export during a scan, or a scan canceled before the probe finished) or disabled are placeholders - + /// exported as empty values instead of a result. The ports are a (joined in the CSV + /// file, one element per port in XML / JSON). + /// + /// Host to export. + /// Options of the scan the host is from. + /// Values in the order of the columns. + private static List GetFields(IPScannerHostInfo info, IPScannerResultOptions options) + { + var ping = GetPingInfo(info, options); + var netBIOSInfo = GetNetBIOSInfo(info, options); + var certificate = GetWebCertificate(info, options); + + string IfEnabled(bool isEnabled, string value) => isEnabled ? value : null; + + return + [ + new(nameof(IPScannerHostInfo.IsReachable), info.IsReachable), + new(nameof(PingInfo.IPAddress), info.PingInfo.IPAddress), + new(nameof(IPScannerHostInfo.Hostname), info.Hostname, true), + new("PingStatus", ping?.Status), + new(nameof(PingInfo.Timestamp), + ping == null ? null : DateTimeHelper.DateTimeToFullDateTimeString(ping.Timestamp)), + new(nameof(PingInfo.Time), ping == null ? null : Ping.TimeToString(ping.Status, ping.Time, true)), + new(nameof(PingInfo.TTL), ping?.TTL), + new(nameof(PingInfo.Bytes), ping?.Bytes), + new("PortStatus", GetPortStatus(info, options)), + new(nameof(IPScannerHostInfo.Ports), info.Ports != null && options.PortScanEnabled ? info.Ports : [], true), + new("NetBIOSIsReachable", netBIOSInfo?.IsReachable), + new("NetBIOSIPAddress", netBIOSInfo?.IPAddress), + new("NetBIOSComputerName", netBIOSInfo?.ComputerName, true), + new("NetBIOSUserName", netBIOSInfo?.UserName, true), + new("NetBIOSGroupName", netBIOSInfo?.GroupName, true), + new("NetBIOSMACAddress", netBIOSInfo?.MACAddress), + new("NetBIOSVendor", netBIOSInfo?.Vendor, true), + new(nameof(IPScannerHostInfo.MACAddress), info.MACAddress), + new(nameof(IPScannerHostInfo.Vendor), info.Vendor, true), + // Added later - after the released columns, so their position doesn't change + new(nameof(IPScannerHostInfo.DNSHostname), info.DNSHostname, true), + new(nameof(IPScannerHostInfo.DetectedBy), GetDetectedBy(info), true), + new(nameof(IPScannerHostInfo.OSGuess), GetOSGuess(info, options)), + new(nameof(IPScannerHostInfo.Role), GetRole(info), true), + new(nameof(IPScannerHostInfo.MDNSHostname), IfEnabled(options.MDNSEnabled, info.MDNSHostname), true), + new(nameof(IPScannerHostInfo.WebTitle), IfEnabled(IsWebPageRead(options), info.WebTitle), true), + new(nameof(IPScannerHostInfo.WebURL), IfEnabled(IsWebPageRead(options), info.WebURL), true), + new(nameof(IPScannerHostInfo.SNMPName), IfEnabled(options.SNMPEnabled, info.SNMPName), true), + new(nameof(IPScannerHostInfo.SNMPDescription), IfEnabled(options.SNMPEnabled, info.SNMPDescription), true), + new(nameof(IPScannerHostInfo.SNMPLocation), IfEnabled(options.SNMPEnabled, info.SNMPLocation), true), + new(nameof(IPScannerHostInfo.WebStatusCode), GetWebStatusCode(info, options)), + new(nameof(IPScannerHostInfo.WebServer), IfEnabled(IsWebPageRead(options), info.WebServer), true), + new(nameof(IPScannerHostInfo.WebPoweredBy), IfEnabled(IsWebPageRead(options), info.WebPoweredBy), true), + new(nameof(IPScannerHostInfo.WebRealm), IfEnabled(IsWebPageRead(options), info.WebRealm), true), + new(nameof(IPScannerHostInfo.WebRedirectURL), IfEnabled(IsWebPageRead(options), info.WebRedirectURL), true), + new("WebCertificateSubject", certificate?.Subject, true), + new("WebCertificateIssuer", certificate?.Issuer, true), + new("WebCertificateNotAfter", certificate == null + ? null + : DateTimeHelper.DateTimeToFullDateTimeString(certificate.NotAfter.ToLocalTime())), + new("WebCertificateSelfSigned", certificate?.IsSelfSigned), + // Change since the previous scan of the same input (empty without a previous scan / change) + new(nameof(IPScannerHostInfo.Change), + info.Change == IPScannerHostChange.None ? null : info.Change.ToString()), + new(nameof(IPScannerHostInfo.PreviousMACAddress), info.PreviousMACAddress), + // SNMPv3 error of the host (e.g. "AuthenticationFailed"), empty without + new(nameof(IPScannerHostInfo.SNMPError), + IfEnabled(options.SNMPEnabled, info.HasSNMPError ? info.SNMPError.ToString() : string.Empty), true) + ]; + } + /// /// Creates a CSV file from the given collection. /// /// Objects as to export. + /// Options of the scan the hosts are from. /// Path to the export file. - private static void CreateCsv(IEnumerable collection, string filePath) + private static void CreateCsv(IEnumerable collection, IPScannerResultOptions options, + string filePath) { var stringBuilder = new StringBuilder(); - stringBuilder.AppendLine( - $"{nameof(IPScannerHostInfo.IsReachable)}," + - $"{nameof(PingInfo.IPAddress)}," + - $"{nameof(IPScannerHostInfo.Hostname)}," + - $"PingStatus," + - $"{nameof(PingInfo.Timestamp)}," + - $"{nameof(PingInfo.Time)}," + - $"{nameof(PingInfo.TTL)}," + - $"{nameof(PingInfo.Bytes)}," + - $"PortStatus," + - $"{nameof(IPScannerHostInfo.Ports)}," + - $"NetBIOSIsReachable," + - $"NetBIOSIPAddress," + - $"NetBIOSComputerName," + - $"NetBIOSUserName," + - $"NetBIOSGroupName," + - $"NetBIOSMACAddress," + - $"NetBIOSVendor," + - $"{nameof(IPScannerHostInfo.MACAddress)}," + - $"{nameof(IPScannerHostInfo.Vendor)}," + - // Added later - last column, so the position of the other columns doesn't change - $"{nameof(IPScannerHostInfo.DNSHostname)}" - ); + stringBuilder.AppendLine(string.Join(",", GetFieldNames())); foreach (var info in collection) { @@ -77,99 +142,59 @@ 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) + stringBuilder.AppendLine(string.Join(",", GetFields(info, options).Select(field => { - foreach (var port in info.Ports) - stringBuilderPorts.Append( - $"{port.Port}/{port.LookupInfo.Protocol}/{port.LookupInfo.Service}/{port.LookupInfo.Description}/{port.State};"); - } + var value = field.Value is List ports + ? string.Join(";", ports.Select(port => + $"{port.Port}/{port.LookupInfo.Protocol}/{port.LookupInfo.Service}/{port.LookupInfo.Description}/{port.State}")) + : $"{field.Value}"; - stringBuilder.AppendLine( - $"{info.IsReachable}," + - $"{info.PingInfo.IPAddress}," + - $"{CsvHelper.QuoteString(info.Hostname)}," + - $"{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(';'))}," + - $"{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)}" - ); + return field.IsQuoted ? CsvHelper.QuoteString(value) : value; + }))); } File.WriteAllText(filePath, stringBuilder.ToString()); } + /// + /// Gets the names of the exported columns (header of the CSV file, also without hosts) - they don't depend on + /// the values. + /// + private static IEnumerable GetFieldNames() + { + var placeholder = new IPScannerHostInfo(IPAddress.None, false, new PingInfo(), false, [], + new NetBIOSInfo(IPAddress.None)); + + return GetFields(placeholder, new IPScannerResultOptions(false, false, false, false, false, false, false, false, + false)).Select(x => x.Name); + } + /// /// Creates a XML file from the given collection. /// /// Objects as to export. + /// Options of the scan the hosts are from. /// Path to the export file. - private static void CreateXml(IEnumerable collection, string filePath) + private static void CreateXml(IEnumerable collection, IPScannerResultOptions options, + string filePath) { var document = new XDocument(DefaultXDeclaration, new XElement(ApplicationName.IPScanner.ToString(), new XElement(nameof(IPScannerHostInfo) + "s", from info in collection where info?.PingInfo != null - // Placeholders of running probes are exported as empty values (see CreateCsv) - 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", ping?.Status), - new XElement(nameof(PingInfo.Timestamp), - ping == null ? null : DateTimeHelper.DateTimeToFullDateTimeString(ping.Timestamp)), - new XElement(nameof(PingInfo.Time), - 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), - new XElement(nameof(PortInfo.Port), port.Port), - new XElement(nameof(PortInfo.LookupInfo.Protocol), port.LookupInfo.Protocol), - new XElement(nameof(PortInfo.LookupInfo.Service), port.LookupInfo.Service), - new XElement(nameof(PortInfo.LookupInfo.Description), port.LookupInfo.Description), - new XElement(nameof(PortInfo.State), port.State)) - : Enumerable.Empty(), - 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 - new XElement(nameof(IPScannerHostInfo.DNSHostname), info.DNSHostname) - ) - ) - ) - ); + select new XElement(nameof(IPScannerHostInfo), + from field in GetFields(info, options) + // One element per port, no element for the list + from element in field.Value is List ports + ? ports.Select(port => new XElement(nameof(PortInfo), + new XElement(nameof(PortInfo.Port), port.Port), + new XElement(nameof(PortInfo.LookupInfo.Protocol), port.LookupInfo.Protocol), + new XElement(nameof(PortInfo.LookupInfo.Service), port.LookupInfo.Service), + new XElement(nameof(PortInfo.LookupInfo.Description), port.LookupInfo.Description), + new XElement(nameof(PortInfo.State), port.State))) + : [new XElement(field.Name, field.Value)] + select element)))); document.Save(filePath); } @@ -178,77 +203,132 @@ from info in collection /// Creates a JSON file from the given collection. /// /// Objects as to export. + /// Options of the scan the hosts are from. /// Path to the export file. - private static void CreateJson(IReadOnlyList collection, string filePath) + private static void CreateJson(IReadOnlyList collection, IPScannerResultOptions options, + string filePath) { - var validCollection = collection.Where(info => info?.PingInfo != null).ToList(); - var jsonData = new object[validCollection.Count]; + var jsonData = collection + .Where(info => info?.PingInfo != null) + .Select(info => GetFields(info, options).ToDictionary(x => x.Name, x => x.Value switch + { + List ports => ports.Select(port => new + { + port.Port, + Protocol = port.LookupInfo.Protocol.ToString(), + port.LookupInfo.Service, + port.LookupInfo.Description, + State = port.State.ToString() + }).ToArray(), + // As text, like in the CSV / XML file (not the number of the enum) + Enum or IPAddress => x.Value.ToString(), + _ => x.Value + })) + .ToArray(); - for (var i = 0; i < validCollection.Count; i++) - { - var info = validCollection[i]; + File.WriteAllText(filePath, JsonConvert.SerializeObject(jsonData, Formatting.Indented)); + } - // 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; + /// + /// Gets the probes a host answered (e.g. "ICMP, TCP"), null if it is unreachable. + /// + /// Host to get the probes for. + /// Probes or null. + private static string GetDetectedBy(IPScannerHostInfo info) + { + return info.DetectedBy == IPScannerDetectionMethod.None ? null : info.DetectedBy.ToString(); + } - object[] jsonDataPorts; + /// + /// Checks if the web pages were read in the scan: only with the port scan (only open ports are requested). + /// + /// Options of the scan the hosts are from. + /// True if the web pages were read. + private static bool IsWebPageRead(IPScannerResultOptions options) + { + return options.PortScanEnabled && options.WebPageEnabled; + } - if (info.Ports != null && info.Ports.Count > 0) - { - jsonDataPorts = new object[info.Ports.Count]; - for (var j = 0; j < info.Ports.Count; j++) - jsonDataPorts[j] = new - { - info.Ports[j].Port, - Protocol = info.Ports[j].LookupInfo.Protocol.ToString(), - info.Ports[j].LookupInfo.Service, - info.Ports[j].LookupInfo.Description, - State = info.Ports[j].State.ToString() - }; - } - else - { - jsonDataPorts = Array.Empty(); - } + /// + /// Gets the HTTP status code of the web page of a host, null if none answered or the web page was not read. + /// + /// Host to get the status code for. + /// Options of the scan the host is from. + /// Status code or null. + private static int? GetWebStatusCode(IPScannerHostInfo info, IPScannerResultOptions options) + { + return IsWebPageRead(options) && info.WebStatusCode > 0 ? info.WebStatusCode : null; + } - jsonData[i] = new - { - info.IsReachable, - IPAddress = info.PingInfo.IPAddress.ToString(), - info.Hostname, - 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 = GetPortStatus(info)?.ToString(), - Ports = jsonDataPorts, - 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 - }; - } + /// + /// Gets the TLS certificate of the web page of a host, null for http, if none answered or the web page was not + /// read. + /// + /// Host to get the certificate for. + /// Options of the scan the host is from. + /// Certificate or null. + private static WebCertificateInfo GetWebCertificate(IPScannerHostInfo info, IPScannerResultOptions options) + { + return IsWebPageRead(options) ? info.WebCertificate : null; + } - File.WriteAllText(filePath, JsonConvert.SerializeObject(jsonData, Formatting.Indented)); + /// + /// Gets the roles of a host for the local computer (e.g. "DefaultGateway, DNSServer"), null if it has none. + /// + /// Host to get the roles for. + /// Roles or null. + private static string GetRole(IPScannerHostInfo info) + { + return info.Role == IPScannerHostRole.None ? null : info.Role.ToString(); + } + + /// + /// Gets the operating system guessed from the TTL (e.g. "Windows"), null if there is no guess or it was disabled. + /// + /// Host to get the guess for. + /// Options of the scan the host is from. + /// Guessed operating system or null. + private static string GetOSGuess(IPScannerHostInfo info, IPScannerResultOptions options) + { + return options.ICMPEnabled && options.OSGuessEnabled && info.OSGuess != IPScannerOSGuess.None + ? info.OSGuess.ToString() + : null; + } + + /// + /// Gets the result of the ping to a host - null while the ping is still running or if it was disabled + /// (placeholder). + /// + /// Host to get the ping result for. + /// Options of the scan the host is from. + /// Result of the ping or null. + private static PingInfo GetPingInfo(IPScannerHostInfo info, IPScannerResultOptions options) + { + return info.IsPingPending || !options.ICMPEnabled ? null : info.PingInfo; + } + + /// + /// Gets the NetBIOS information of a host - null while the NetBIOS request is still running or if it was + /// disabled (placeholder). + /// + /// Host to get the NetBIOS information for. + /// Options of the scan the host is from. + /// NetBIOS information or null. + private static NetBIOSInfo GetNetBIOSInfo(IPScannerHostInfo info, IPScannerResultOptions options) + { + return info.IsNetBIOSPending || !options.NetBIOSEnabled ? null : info.NetBIOSInfo; } /// /// Gets the port status of a host: open if any port is open, otherwise closed - null while the port scan is - /// still running (no result yet). + /// still running (no result yet) or if it was disabled. /// /// Host to get the port status for. + /// Options of the scan the host is from. /// Port status or null. - private static PortState? GetPortStatus(IPScannerHostInfo info) + private static PortState? GetPortStatus(IPScannerHostInfo info, IPScannerResultOptions options) { - if (info.IsPortScanPending) + if (info.IsPortScanPending || !options.PortScanEnabled) return null; return info.IsAnyPortOpen ? PortState.Open : PortState.Closed; diff --git a/Source/NETworkManager.Models/Export/ExportManager.PortScannerInfo.cs b/Source/NETworkManager.Models/Export/ExportManager.PortScannerInfo.cs index 3306d06d56..2f34044de8 100644 --- a/Source/NETworkManager.Models/Export/ExportManager.PortScannerInfo.cs +++ b/Source/NETworkManager.Models/Export/ExportManager.PortScannerInfo.cs @@ -49,11 +49,11 @@ private static void CreateCsv(IEnumerable collection, strin var stringBuilder = new StringBuilder(); stringBuilder.AppendLine( - $"{nameof(PortScannerPortInfo.IPAddress)},{nameof(PortScannerPortInfo.Hostname)},{nameof(PortScannerPortInfo.Port)},{nameof(PortLookupInfo.Protocol)},{nameof(PortLookupInfo.Service)},{nameof(PortLookupInfo.Description)},{nameof(PortScannerPortInfo.State)}"); + $"{nameof(PortScannerPortInfo.IPAddress)},{nameof(PortScannerPortInfo.Hostname)},{nameof(PortScannerPortInfo.Port)},{nameof(PortLookupInfo.Protocol)},{nameof(PortLookupInfo.Service)},{nameof(PortLookupInfo.Description)},{nameof(PortScannerPortInfo.State)},{nameof(PortScannerPortInfo.Change)}"); foreach (var info in collection) stringBuilder.AppendLine( - $"{info.IPAddress},{CsvHelper.QuoteString(info.Hostname)},{info.Port},{info.LookupInfo.Protocol},{CsvHelper.QuoteString(info.LookupInfo.Service)},{CsvHelper.QuoteString(info.LookupInfo.Description)},{info.State}"); + $"{info.IPAddress},{CsvHelper.QuoteString(info.Hostname)},{info.Port},{info.LookupInfo.Protocol},{CsvHelper.QuoteString(info.LookupInfo.Service)},{CsvHelper.QuoteString(info.LookupInfo.Description)},{info.State},{GetChange(info)}"); File.WriteAllText(filePath, stringBuilder.ToString()); } @@ -77,7 +77,8 @@ from info in collection new XElement(nameof(PortLookupInfo.Protocol), info.LookupInfo.Protocol), new XElement(nameof(PortLookupInfo.Service), info.LookupInfo.Service), new XElement(nameof(PortLookupInfo.Description), info.LookupInfo.Description), - new XElement(nameof(PortScannerPortInfo.State), info.State))))); + new XElement(nameof(PortScannerPortInfo.State), info.State), + new XElement(nameof(PortScannerPortInfo.Change), GetChange(info)))))); document.Save(filePath); } @@ -100,9 +101,18 @@ private static void CreateJson(IReadOnlyList collection, st Protocol = collection[i].LookupInfo.Protocol.ToString(), collection[i].LookupInfo.Service, collection[i].LookupInfo.Description, - State = collection[i].State.ToString() + State = collection[i].State.ToString(), + Change = GetChange(collection[i]) }; File.WriteAllText(filePath, JsonConvert.SerializeObject(jsonData, Formatting.Indented)); } + + /// + /// Gets the change of a port since the previous scan, empty without a previous scan / change. + /// + private static string GetChange(PortScannerPortInfo info) + { + return info.Change == PortScannerPortChange.None ? string.Empty : info.Change.ToString(); + } } \ No newline at end of file diff --git a/Source/NETworkManager.Models/Network/HostRangeHelper.cs b/Source/NETworkManager.Models/Network/HostRangeHelper.cs index d32413b5bc..f937b30693 100644 --- a/Source/NETworkManager.Models/Network/HostRangeHelper.cs +++ b/Source/NETworkManager.Models/Network/HostRangeHelper.cs @@ -21,6 +21,21 @@ public static class HostRangeHelper /// private const int CancellationCheckInterval = 65536; + /// + /// Prefix of an input that excludes IP addresses from the other inputs (e.g. "10.0.0.0/24; !10.0.0.1"). + /// + public const char ExclusionPrefix = '!'; + + /// + /// Checks if an input excludes IP addresses (e.g. "!10.0.0.1"), see . + /// + /// Input like "10.0.0.1" or "!10.0.0.1" (see ). + /// True if the input is an exclusion. + public static bool IsExclusion(string host) + { + return host.StartsWith(ExclusionPrefix); + } + /// /// Create a list of hosts from a string input like "10.0.0.1; example.com; 10.0.0.0/24". /// Inputs can also be separated by newlines (e.g. pasted from Excel, one host/range per line). @@ -35,6 +50,81 @@ public static IEnumerable CreateListFromInput(string hosts) .ToArray(); } + /// + /// Parses a host input (without ) into its format and values - the only place that + /// knows the formats, used for the hosts to scan, the exclusions, the host count and the validation. + /// + /// Input like "10.0.0.1", "10.0.0.0/24", "10.0.0.1-100", "10.0.[0-10].1" or "example.com". + /// Format and values of the input, if it is not valid (also a + /// range whose start is after its end). + public static HostRangeInput Parse(string host) + { + switch (host) + { + // 192.168.0.1 or 2001:db8:85a3::8a2e:370:7334 + case var _ when RegexHelper.IPv4AddressRegex().IsMatch(host): + case var _ when Regex.IsMatch(host, RegexHelper.IPv6AddressRegex): + // The regex also matches addresses that can't be parsed (e.g. a zone id that is not an interface). + // No exception: the validation of the input calls this on every key press. + return IPAddress.TryParse(host, out var ipAddress) + ? new HostRangeInput(HostRangeInputKind.IPAddress, IPAddress: ipAddress) + : new HostRangeInput(HostRangeInputKind.Invalid); + + // 192.168.0.0/24 or 192.168.0.0/255.255.255.0 + case var _ when RegexHelper.IPv4AddressCidrRegex().IsMatch(host): + case var _ when RegexHelper.IPv4AddressSubnetmaskRegex().IsMatch(host): + var network = IPNetwork2.Parse(host); + + return CreateIPv4Range(network.Network, network.Broadcast); + + // 192.168.0.1-100 + case var _ when RegexHelper.IPv4AddressShortRangeRegex().IsMatch(host): + var shortRange = host.Split('-'); + var shortBase = shortRange[0][..shortRange[0].LastIndexOf('.')]; + + return CreateIPv4Range(IPAddress.Parse(shortRange[0]), IPAddress.Parse($"{shortBase}.{shortRange[1]}")); + + // 192.168.0.0 - 192.168.0.100 + case var _ when RegexHelper.IPv4AddressRangeRegex().IsMatch(host): + var range = host.Split('-'); + + return CreateIPv4Range(IPAddress.Parse(range[0]), IPAddress.Parse(range[1])); + + // 192.168.[50-100].1 + case var _ when RegexHelper.IPv4AddressSpecialRangeRegex().IsMatch(host): + var octets = GetIPv4SpecialRangeOctets(host); + + return octets == null + ? new HostRangeInput(HostRangeInputKind.Invalid) + : new HostRangeInput(HostRangeInputKind.IPv4SpecialRange, Octets: octets); + + // example.com + case var _ when RegexHelper.HostnameOrDomainRegex().IsMatch(host): + return new HostRangeInput(HostRangeInputKind.Hostname, Hostname: host); + + // example.com/24 or example.com/255.255.255.128 + case var _ when Regex.IsMatch(host, RegexHelper.HostnameOrDomainWithCidrRegex): + case var _ when Regex.IsMatch(host, RegexHelper.HostnameOrDomainWithSubnetmaskRegex): + var hostAndSubnet = host.Split('/'); + + return new HostRangeInput(HostRangeInputKind.HostnameWithSubnet, Hostname: hostAndSubnet[0], + Subnet: hostAndSubnet[1]); + + default: + return new HostRangeInput(HostRangeInputKind.Invalid); + } + } + + /// + /// Creates the parsed input of an IPv4 range, invalid if the start is after the end. + /// + private static HostRangeInput CreateIPv4Range(IPAddress start, IPAddress end) + { + return IPv4Address.ToUInt32(start) > IPv4Address.ToUInt32(end) + ? new HostRangeInput(HostRangeInputKind.Invalid) + : new HostRangeInput(HostRangeInputKind.IPv4Range, Start: start, End: end); + } + /// /// Adds every IPv4 address in the inclusive range [, ] to /// (in ascending order). Iterates using the unsigned IPv4 value widened to @@ -63,11 +153,9 @@ private static void AddIPv4Range(IPAddress start, IPAddress end, /// /// Adds every IPv4 address of a special range like "192.168.[50-100,200].1" to . /// - private static void AddIPv4SpecialRange(string host, List<(IPAddress ipAddress, string hostname)> hosts, + private static void AddIPv4SpecialRange(List> octets, List<(IPAddress ipAddress, string hostname)> hosts, CancellationToken ct) { - var octets = GetIPv4SpecialRangeOctets(host); - // Build the new ipv4 var count = 0; @@ -84,7 +172,8 @@ private static void AddIPv4SpecialRange(string host, List<(IPAddress ipAddress, } /// - /// Gets the numbers of each octet of a special range like "192.168.[50-100,200].1". + /// Gets the numbers of each octet of a special range like "192.168.[50-100,200].1", null if a range of an octet + /// starts after its end (e.g. "[100-50]"). /// private static List> GetIPv4SpecialRangeOctets(string host) { @@ -102,6 +191,9 @@ private static List> GetIPv4SpecialRangeOctets(string host) { var rangeNumbers = numberOrRange.Split('-'); + if (int.Parse(rangeNumbers[0]) > int.Parse(rangeNumbers[1])) + return null; + for (var i = int.Parse(rangeNumbers[0]); i <= int.Parse(rangeNumbers[1]); i++) numbers.Add((byte)i); } // 200 @@ -120,8 +212,8 @@ private static List> GetIPv4SpecialRangeOctets(string host) /// /// Gets the number of IP addresses of the inputs without expanding the ranges or resolving the hostnames - /// (a hostname counts as 1, overlapping inputs are counted multiple times). Used to warn about (or refuse) - /// very large scans before any memory is allocated. + /// (a hostname counts as 1, overlapping inputs are counted multiple times, exclusions are ignored). Used to warn + /// about (or refuse) very large scans before any memory is allocated. /// /// Inputs like "10.0.0.1", "example.com", "10.0.0.0/24" (see ). /// Number of IP addresses. @@ -131,48 +223,37 @@ public static long GetHostCount(IEnumerable hosts) foreach (var host in hosts) { - switch (host) + // Every address of the other inputs is expanded before the exclusions are applied + if (IsExclusion(host)) + continue; + + var input = Parse(host); + + switch (input.Kind) { - // 192.168.0.1 - case var _ when RegexHelper.IPv4AddressRegex().IsMatch(host): + // 192.168.0.1, 2001:db8::1 or example.com + case HostRangeInputKind.IPAddress: + case HostRangeInputKind.Hostname: count++; break; - // 192.168.0.0/24 or 192.168.0.0/255.255.255.0 - case var _ when RegexHelper.IPv4AddressCidrRegex().IsMatch(host): - case var _ when RegexHelper.IPv4AddressSubnetmaskRegex().IsMatch(host): - count += (long)IPNetwork2.Parse(host).Total; - break; - - // 192.168.0.1-100 - case var _ when RegexHelper.IPv4AddressShortRangeRegex().IsMatch(host): - var shortRange = host.Split('-'); - var shortBase = shortRange[0][..shortRange[0].LastIndexOf('.')]; - - count += GetIPv4RangeCount(IPAddress.Parse(shortRange[0]), - IPAddress.Parse($"{shortBase}.{shortRange[1]}")); - break; - - // 192.168.0.0 - 192.168.0.100 - case var _ when RegexHelper.IPv4AddressRangeRegex().IsMatch(host): - var range = host.Split('-'); - - count += GetIPv4RangeCount(IPAddress.Parse(range[0]), IPAddress.Parse(range[1])); + // 192.168.0.0/24, 192.168.0.1-100, 192.168.0.0 - 192.168.0.100 + case HostRangeInputKind.IPv4Range: + count += GetIPv4RangeCount(input.Start, input.End); break; // 192.168.[50-100].1 - case var _ when RegexHelper.IPv4AddressSpecialRangeRegex().IsMatch(host): - count += GetIPv4SpecialRangeOctets(host).Aggregate(1L, (product, octet) => product * octet.Count); + case HostRangeInputKind.IPv4SpecialRange: + count += input.Octets.Aggregate(1L, (product, octet) => product * octet.Count); break; - // example.com/24 or example.com/255.255.255.128 (IPv6 address and example.com count as 1) - case var _ when Regex.IsMatch(host, RegexHelper.HostnameOrDomainWithCidrRegex): - case var _ when Regex.IsMatch(host, RegexHelper.HostnameOrDomainWithSubnetmaskRegex): - count += (long)IPNetwork2.Parse($"0.0.0.0/{host.Split('/')[1]}").Total; + // example.com/24 or example.com/255.255.255.128 + case HostRangeInputKind.HostnameWithSubnet: + count += (long)IPNetwork2.Parse($"0.0.0.0/{input.Subnet}").Total; break; - default: - count++; + // Not a valid input (rejected by the validation) - nothing is scanned + case HostRangeInputKind.Invalid: break; } } @@ -194,22 +275,34 @@ private static long GetIPv4RangeCount(IPAddress start, IPAddress end) /// /// Resolves the inputs (IP addresses, ranges, subnets, hostnames) into a list of hosts. The list is sorted /// by IP address and contains every IP address only once (e.g. overlapping ranges), see - /// . + /// . IP addresses of an exclusion (e.g. "!10.0.0.1", "!10.0.0.0/28", + /// "!server.example.com", see ) are removed - exclusions are not expanded, so a + /// large excluded range doesn't need any memory. /// /// Inputs like "10.0.0.1", "example.com", "10.0.0.0/24" (see ). - /// Prefer the A record over the AAAA record for hostnames. + /// + /// Prefer the A record over the AAAA record for hostnames (an excluded hostname excludes all its addresses). + /// /// /// 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)> + /// + /// Sorted, distinct list of hosts, the hostnames that could not be resolved and the number of hosts removed by + /// the exclusions (e.g. to show that every IP address is excluded, also if a hostname could not be resolved). + /// No hosts if the hostname of an exclusion could not be resolved (its IP addresses would be scanned). + /// + public static async Task<(List<(IPAddress ipAddress, string hostname)> hosts, List hostnamesNotResolved, + int excludedCount)> ResolveAsync(IEnumerable hosts, bool dnsResolveHostnamePreferIPv4, bool keepAllHostnames, CancellationToken cancellationToken) { - var inputs = hosts.ToList(); + var allInputs = hosts.ToList(); + + var inputs = allInputs.Where(x => !IsExclusion(x)).ToList(); + var exclusionInputs = allInputs.Where(IsExclusion).Select(x => x[1..]).ToList(); // Results per input (index = position in the input) to keep the input order var hostsPerInput = new List<(IPAddress ipAddress, string hostname)>[inputs.Count]; @@ -219,95 +312,205 @@ await Parallel.ForEachAsync(Enumerable.Range(0, inputs.Count), new ParallelOptions { CancellationToken = cancellationToken }, async (index, ct) => { - var host = inputs[index]; + var input = Parse(inputs[index]); var list = new List<(IPAddress ipAddress, string hostname)>(); hostsPerInput[index] = list; - switch (host) + switch (input.Kind) { - // 192.168.0.1 - case var _ when RegexHelper.IPv4AddressRegex().IsMatch(host): - // 2001:db8:85a3::8a2e:370:7334 - case var _ when Regex.IsMatch(host, RegexHelper.IPv6AddressRegex): - list.Add((IPAddress.Parse(host), string.Empty)); - - break; - - // 192.168.0.0/24 - case var _ when RegexHelper.IPv4AddressCidrRegex().IsMatch(host): - // 192.168.0.0/255.255.255.0 - case var _ when RegexHelper.IPv4AddressSubnetmaskRegex().IsMatch(host): - var network = IPNetwork2.Parse(host); - - AddIPv4Range(network.Network, network.Broadcast, list, ct); - - break; - - // 192.168.0.1-100 - case var _ when RegexHelper.IPv4AddressShortRangeRegex().IsMatch(host): - var shortRange = host.Split('-'); - var shortBase = shortRange[0][..shortRange[0].LastIndexOf('.')]; - - AddIPv4Range(IPAddress.Parse(shortRange[0]), - IPAddress.Parse($"{shortBase}.{shortRange[1]}"), list, ct); + // 192.168.0.1 or 2001:db8:85a3::8a2e:370:7334 + case HostRangeInputKind.IPAddress: + list.Add((input.IPAddress, string.Empty)); break; - // 192.168.0.0 - 192.168.0.100 - case var _ when RegexHelper.IPv4AddressRangeRegex().IsMatch(host): - var range = host.Split('-'); - - AddIPv4Range(IPAddress.Parse(range[0]), IPAddress.Parse(range[1]), list, ct); + // 192.168.0.0/24, 192.168.0.1-100, 192.168.0.0 - 192.168.0.100 + case HostRangeInputKind.IPv4Range: + AddIPv4Range(input.Start, input.End, list, ct); break; // 192.168.[50-100].1 - case var _ when RegexHelper.IPv4AddressSpecialRangeRegex().IsMatch(host): - AddIPv4SpecialRange(host, list, ct); + case HostRangeInputKind.IPv4SpecialRange: + AddIPv4SpecialRange(input.Octets, list, ct); break; // example.com - case var _ when RegexHelper.HostnameOrDomainRegex().IsMatch(host): - var dnsResult = await DNSClientHelper.ResolveAorAaaaAsync(host, dnsResolveHostnamePreferIPv4) + case HostRangeInputKind.Hostname: + var dnsResult = await DNSClientHelper.ResolveAorAaaaAsync(input.Hostname, dnsResolveHostnamePreferIPv4) .WaitAsync(ct).ConfigureAwait(false); if (!dnsResult.HasError) - list.Add((IPAddress.Parse($"{dnsResult.Value}"), host)); + list.Add((IPAddress.Parse($"{dnsResult.Value}"), input.Hostname)); else - hostnameNotResolvedPerInput[index] = host; + hostnameNotResolvedPerInput[index] = input.Hostname; break; // example.com/24 or example.com/255.255.255.128 - case var _ when Regex.IsMatch(host, RegexHelper.HostnameOrDomainWithCidrRegex): - case var _ when Regex.IsMatch(host, RegexHelper.HostnameOrDomainWithSubnetmaskRegex): - var hostAndSubnet = host.Split('/'); - + case HostRangeInputKind.HostnameWithSubnet: // Only support IPv4 - var dnsResultWithSubnet = await DNSClientHelper.ResolveAorAaaaAsync(hostAndSubnet[0], true) + var dnsResultWithSubnet = await DNSClientHelper.ResolveAorAaaaAsync(input.Hostname, true) .WaitAsync(ct).ConfigureAwait(false); // Only support IPv4 for ranges for now if (!dnsResultWithSubnet.HasError && dnsResultWithSubnet.Value.AddressFamily == AddressFamily.InterNetwork) { - network = IPNetwork2.Parse($"{dnsResultWithSubnet.Value}/{hostAndSubnet[1]}"); + var network = IPNetwork2.Parse($"{dnsResultWithSubnet.Value}/{input.Subnet}"); AddIPv4Range(network.Network, network.Broadcast, list, ct); } else { - hostnameNotResolvedPerInput[index] = hostAndSubnet[0]; + hostnameNotResolvedPerInput[index] = input.Hostname; } break; } }).ConfigureAwait(false); - return (SortAndRemoveDuplicates(hostsPerInput, keepAllHostnames), - [.. hostnameNotResolvedPerInput.Where(x => x != null)]); + // Exclusions as checks (index = position in the input), null if the hostname could not be resolved + var exclusions = new Func[exclusionInputs.Count]; + var exclusionHostnameNotResolved = new string[exclusionInputs.Count]; + + await Parallel.ForEachAsync(Enumerable.Range(0, exclusionInputs.Count), + new ParallelOptions { CancellationToken = cancellationToken }, + async (index, ct) => + { + exclusions[index] = await CreateExclusionAsync(exclusionInputs[index], ct).ConfigureAwait(false); + + if (exclusions[index] == null) + exclusionHostnameNotResolved[index] = exclusionInputs[index].Split('/')[0]; + }).ConfigureAwait(false); + + var hostnamesNotResolved = hostnameNotResolvedPerInput.Concat(exclusionHostnameNotResolved) + .Where(x => x != null).ToList(); + + // The IP addresses of an exclusion that could not be resolved are not known - nothing is scanned instead of + // scanning the hosts that should be excluded (e.g. a printer that must not get any packets) + if (exclusions.Any(x => x == null)) + return ([], hostnamesNotResolved, 0); + + var result = SortAndRemoveDuplicates(hostsPerInput, keepAllHostnames); + + var excludedCount = 0; + + if (exclusions.Length > 0) + { + var count = result.Count; + + result = [.. result.Where(host => !exclusions.Any(isExcluded => isExcluded(host.ipAddress)))]; + + excludedCount = count - result.Count; + } + + return (result, hostnamesNotResolved, excludedCount); + } + + /// + /// Creates the check of an exclusion (input without ): IP address, subnet, range or + /// hostname. A hostname excludes all its IPv4 and IPv6 addresses (A and AAAA records) - an inclusion resolves + /// only one of them (depending on the IPv4 / IPv6 preference), the exclusion must match it in any case. An IPv6 + /// address without scope id also excludes the address with any scope id (e.g. "fe80::1" excludes "fe80::1%3"). + /// + /// Input like "10.0.0.1", "10.0.0.0/28", "10.0.0.200-254" or "server.example.com". + /// Token to monitor for cancellation requests. + /// Check if an IP address is excluded, null if the hostname could not be resolved. + private static async Task> CreateExclusionAsync(string host, + CancellationToken cancellationToken) + { + var input = Parse(host); + + switch (input.Kind) + { + // 192.168.0.1 or 2001:db8:85a3::8a2e:370:7334 + case HostRangeInputKind.IPAddress: + return CreateIPAddressExclusion(input.IPAddress); + + // 192.168.0.0/24, 192.168.0.1-100, 192.168.0.0 - 192.168.0.100 + case HostRangeInputKind.IPv4Range: + return CreateIPv4RangeExclusion(input.Start, input.End); + + // 192.168.[50-100].1 + case HostRangeInputKind.IPv4SpecialRange: + var octets = input.Octets.Select(x => x.ToHashSet()).ToArray(); + + return ipAddress => + { + if (ipAddress.AddressFamily != AddressFamily.InterNetwork) + return false; + + var bytes = ipAddress.GetAddressBytes(); + + return octets[0].Contains(bytes[0]) && octets[1].Contains(bytes[1]) && + octets[2].Contains(bytes[2]) && octets[3].Contains(bytes[3]); + }; + + // example.com (all IPv4 and IPv6 addresses) + case HostRangeInputKind.Hostname: + var ipAddresses = await DNSClient.GetInstance().ResolveAllAAndAaaaAsync(input.Hostname) + .WaitAsync(cancellationToken).ConfigureAwait(false); + + // A failed query (e.g. the A query timed out, the AAAA query returned records) - not all addresses + // of the host are known, an address that should be excluded could be scanned + if (ipAddresses == null || ipAddresses.Count == 0) + return null; + + var ipAddressExclusions = ipAddresses.Select(CreateIPAddressExclusion).ToList(); + + return ipAddress => ipAddressExclusions.Any(isExcluded => isExcluded(ipAddress)); + + // example.com/24 or example.com/255.255.255.128 (only IPv4, like the inputs) + case HostRangeInputKind.HostnameWithSubnet: + var dnsResultWithSubnet = await DNSClientHelper.ResolveAorAaaaAsync(input.Hostname, true) + .WaitAsync(cancellationToken).ConfigureAwait(false); + + if (dnsResultWithSubnet.HasError || + dnsResultWithSubnet.Value.AddressFamily != AddressFamily.InterNetwork) + return null; + + var network = IPNetwork2.Parse($"{dnsResultWithSubnet.Value}/{input.Subnet}"); + + return CreateIPv4RangeExclusion(network.Network, network.Broadcast); + + // Not a valid input (rejected by the validation of the input) - excludes nothing + default: + return _ => false; + } + } + + /// + /// Creates the check of an excluded IP address. An IPv6 address without scope id also excludes the address + /// with any scope id. + /// + private static Func CreateIPAddressExclusion(IPAddress excludedIPAddress) + { + if (excludedIPAddress.AddressFamily == AddressFamily.InterNetworkV6 && excludedIPAddress.ScopeId == 0) + return ipAddress => IPAddressHelper.WithoutScopeId(ipAddress).Equals(excludedIPAddress); + + return ipAddress => ipAddress.Equals(excludedIPAddress); + } + + /// + /// Creates the check of an excluded IPv4 range [, ]. + /// + private static Func CreateIPv4RangeExclusion(IPAddress start, IPAddress end) + { + var from = IPv4Address.ToUInt32(start); + var to = IPv4Address.ToUInt32(end); + + return ipAddress => + { + if (ipAddress.AddressFamily != AddressFamily.InterNetwork) + return false; + + var value = IPv4Address.ToUInt32(ipAddress); + + return value >= from && value <= to; + }; } /// diff --git a/Source/NETworkManager.Models/Network/HostRangeInput.cs b/Source/NETworkManager.Models/Network/HostRangeInput.cs new file mode 100644 index 0000000000..9a0ca3cdb4 --- /dev/null +++ b/Source/NETworkManager.Models/Network/HostRangeInput.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; +using System.Net; + +namespace NETworkManager.Models.Network; + +/// +/// Host input in its parts, see . Only the values of the +/// are set. +/// +/// Format of the input. +/// IP address (). +/// First IPv4 address (). +/// Last IPv4 address (). +/// Values of the four octets (). +/// +/// Hostname (, ). +/// +/// +/// CIDR or subnet mask after the hostname, e.g. "24" (). +/// +public sealed record HostRangeInput( + HostRangeInputKind Kind, + IPAddress IPAddress = null, + IPAddress Start = null, + IPAddress End = null, + List> Octets = null, + string Hostname = null, + string Subnet = null); diff --git a/Source/NETworkManager.Models/Network/HostRangeInputKind.cs b/Source/NETworkManager.Models/Network/HostRangeInputKind.cs new file mode 100644 index 0000000000..3474be6cd5 --- /dev/null +++ b/Source/NETworkManager.Models/Network/HostRangeInputKind.cs @@ -0,0 +1,37 @@ +namespace NETworkManager.Models.Network; + +/// +/// Format of a host input, see . +/// +public enum HostRangeInputKind +{ + /// + /// Not a valid input (rejected by the validation of the input). + /// + Invalid, + + /// + /// IPv4 or IPv6 address, e.g. "10.0.0.1" or "2001:db8::1". + /// + IPAddress, + + /// + /// IPv4 range, e.g. "10.0.0.0/24", "10.0.0.0/255.255.255.0", "10.0.0.1-100" or "10.0.0.1-10.0.0.100". + /// + IPv4Range, + + /// + /// IPv4 range per octet, e.g. "10.0.[0-10,20].1". + /// + IPv4SpecialRange, + + /// + /// Hostname, e.g. "server.example.com". + /// + Hostname, + + /// + /// Subnet of a hostname (IPv4), e.g. "server.example.com/24" or "server.example.com/255.255.255.0". + /// + HostnameWithSubnet +} diff --git a/Source/NETworkManager.Models/Network/IPScanner.cs b/Source/NETworkManager.Models/Network/IPScanner.cs index 88053c2601..0c0b589a30 100644 --- a/Source/NETworkManager.Models/Network/IPScanner.cs +++ b/Source/NETworkManager.Models/Network/IPScanner.cs @@ -35,10 +35,12 @@ public sealed class IPScanner(IPScannerOptions options) private readonly InterlockedCounter _hostsUp = new(); private readonly InterlockedCounter _hostsDown = new(); - // Snapshot of the neighbor table (IP address -> MAC address), see GetMACAddressFromNeighborTableAsync - private readonly SemaphoreSlim _neighborTableSemaphore = new(1, 1); - private Dictionary _neighborTable = []; - private DateTime _neighborTableTimestamp = DateTime.MinValue; + // Roles of the hosts for the local computer (own IP addresses, gateways, DNS / DHCP servers), set before the + // hosts are scanned and only read afterwards + private Dictionary _hostRoles = []; + + // Neighbor table (ARP / NDP) of the scan, created with the roles of the hosts + private IPScannerNeighborCache _neighborCache; /// /// Gets the number of hosts finished so far (all probes and lookups). Thread-safe; may be read from @@ -129,8 +131,12 @@ public void ScanAsync(IEnumerable<(IPAddress ipAddress, string hostname)> hosts, try { - // Get all network interfaces (for local mac address lookup) - var networkInterfaces = options.ResolveMACAddress ? NetworkInterface.GetNetworkInterfaces() : []; + // Get all network interfaces (for the roles of the hosts, the MAC address lookup and the neighbor + // check of hosts in a local subnet) + var networkInterfaces = NetworkInterface.GetNetworkInterfaces(); + + _hostRoles = IPScannerHostRoleHelper.GetRoles(networkInterfaces); + _neighborCache = new IPScannerNeighborCache(_hostRoles); var hostParallelOptions = new ParallelOptions { @@ -147,13 +153,19 @@ public void ScanAsync(IEnumerable<(IPAddress ipAddress, string hostname)> hosts, // Spread the start of the hosts over time instead of starting all at once - based on the time // an unreachable host takes (the slowest of ping attempts, port scan and NetBIOS). In long, the port // scan can exceed int (e.g. 15000 ms x 10 attempts x 65535 ports with one port thread) - var hostDuration = Math.Max((long)options.ICMPAttempts * options.ICMPTimeout, + var hostDuration = Math.Max( + options.ICMPEnabled ? (long)options.ICMPAttempts * options.ICMPTimeout : 0, Math.Max( options.PortScanEnabled ? (long)options.PortScanTimeout * options.PortScanAttempts * (long)Math.Ceiling(options.PortScanPorts.Length / (double)options.MaxPortThreads) : 0, - options.NetBIOSEnabled ? options.NetBIOSTimeout : 0)); + Math.Max(options.NetBIOSEnabled ? options.NetBIOSTimeout : 0, + // v3: discovery and request, each with the timeout + options.SNMPEnabled + ? (long)options.SNMPTimeout * + (options.SNMPCredentials.Version == SNMPVersion.V3 ? 2 : 1) + : 0))); var hostConcurrency = hosts.TryGetNonEnumeratedCount(out var hostCount) ? Math.Min(hostCount, options.MaxHostThreads) @@ -209,12 +221,23 @@ await ScanHostAsync(host.ipAddress, portScanParallelOptions, networkInterfaces, private async Task ScanHostAsync(IPAddress ipAddress, ParallelOptions portScanParallelOptions, List networkInterfaces, CancellationToken cancellationToken) { + // Neighbor entry before the probes are sent: a Reachable entry is not resolved again by the probes, so after + // them it doesn't prove an answer (see IPScannerNeighborCheck.Check) + var isNeighborCheckEnabled = options.ARPEnabled && IsInLocalSubnet(ipAddress, networkInterfaces); + + var wasNeighborReachableBefore = isNeighborCheckEnabled && + await _neighborCache.WasReachableBeforeAsync(ipAddress, cancellationToken) + .ConfigureAwait(false); + // Completed by the port scan when the first port answers (open or refused), so the host is found before // all ports are finished var portAnswered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var pingTask = PingProbe.PingAsync(ipAddress, options.ICMPTimeout, options.ICMPAttempts, - _icmpBuffer, cancellationToken); + // Without ping, the host gets the placeholder of the ping (see CreateHostInfo) + var pingTask = options.ICMPEnabled + ? PingProbe.PingAsync(ipAddress, options.ICMPTimeout, options.ICMPAttempts, _icmpBuffer, + cancellationToken) + : null; var portScanTask = options.PortScanEnabled ? PortScanAsync(ipAddress, portScanParallelOptions, () => portAnswered.TrySetResult(), cancellationToken) @@ -225,6 +248,12 @@ private async Task ScanHostAsync(IPAddress ipAddress, ParallelOptions portScanPa ? NetBIOSResolver.ResolveAsync(ipAddress, options.NetBIOSTimeout, cancellationToken) : Task.FromResult(new NetBIOSInfo(ipAddress)); + // A host that answers the SNMP request is up as well (null if it didn't answer) + var snmpTask = options.SNMPEnabled + ? SNMPSystemInfoResolver.ResolveAsync(ipAddress, options.SNMPCredentials, options.SNMPTimeout, + cancellationToken) + : null; + var enrichTask = Task.CompletedTask; // Results of the finished probes, null while a probe is running @@ -232,11 +261,42 @@ private async Task ScanHostAsync(IPAddress ipAddress, ParallelOptions portScanPa List ports = null; NetBIOSInfo netBIOSInfo = null; + // Result of the SNMP request (null if the host didn't answer), see isSNMPFinished + SNMPSystemInfo snmpInfo = null; + var isSNMPFinished = snmpTask == null; + + // Probes that answered so far + var detectedBy = IPScannerDetectionMethod.None; + + // The title of the web page is read for the found host (when the port scan is finished) + var isWebPagePending = false; + IPScannerHostInfo hostInfo = null; + // The host answered (the first probe, or its neighbor entry): it is up - raise it as found with the results so + // far and start the lookups (DNS, MAC address, mDNS) + void RaiseHostFound() + { + hostInfo = CreateHostInfo(ipAddress, detectedBy, pingInfo, ports, netBIOSInfo, snmpInfo, isSNMPFinished); + isWebPagePending = hostInfo.IsWebPagePending; + + _hostsUp.Increment(); + + OnHostFound(new IPScannerHostFoundArgs(hostInfo)); + OnProgressChanged(); + + enrichTask = EnrichAsync(ipAddress, hostInfo, DateTime.UtcNow, networkInterfaces, cancellationToken); + } + try { - List pending = [pingTask, portScanTask, netBIOSTask, portAnswered.Task]; + List pending = [portScanTask, netBIOSTask, portAnswered.Task]; + + if (pingTask != null) + pending.Add(pingTask); + + if (snmpTask != null) + pending.Add(snmpTask); while (pending.Count > 0) { @@ -249,6 +309,7 @@ private async Task ScanHostAsync(IPAddress ipAddress, ParallelOptions portScanPa cancellationToken.ThrowIfCancellationRequested(); bool isAnswer; + IPScannerDetectionMethod method; Action update = null; if (completed == pingTask) @@ -256,6 +317,7 @@ private async Task ScanHostAsync(IPAddress ipAddress, ParallelOptions portScanPa var value = pingInfo = pingTask.Result; isAnswer = value.Status == IPStatus.Success; // ICMP response + method = IPScannerDetectionMethod.ICMP; update = x => { x.PingInfo = value; @@ -271,6 +333,7 @@ private async Task ScanHostAsync(IPAddress ipAddress, ParallelOptions portScanPa // Any port is open or the connection was refused (TCP reset) isAnswer = ports.Any(x => x.State is PortState.Open or PortState.Closed); + method = IPScannerDetectionMethod.TCP; var shownPorts = GetShownPorts(ports); var isAnyPortOpen = ports.Any(x => x.State == PortState.Open); @@ -287,20 +350,49 @@ private async Task ScanHostAsync(IPAddress ipAddress, ParallelOptions portScanPa var value = netBIOSInfo = netBIOSTask.Result; isAnswer = value.IsReachable; // NetBIOS response + method = IPScannerDetectionMethod.NetBIOS; update = x => { x.NetBIOSInfo = value; x.IsNetBIOSPending = false; }; } + else if (completed == snmpTask) + { + var value = snmpInfo = snmpTask.Result; + isSNMPFinished = true; + + isAnswer = value != null; // SNMP response + method = IPScannerDetectionMethod.SNMP; + update = x => + { + x.SNMPName = value?.Name ?? string.Empty; + x.SNMPDescription = value?.Description ?? string.Empty; + x.SNMPLocation = value?.Location ?? string.Empty; + x.SNMPError = value?.Error ?? SNMPV3ErrorCode.None; + x.IsSNMPPending = false; + }; + } else { // A port answered - its result follows with the port scan isAnswer = true; + method = IPScannerDetectionMethod.TCP; } + if (isAnswer) + detectedBy |= method; + if (hostInfo != null) { + // Another probe answered after the host was found + if (isAnswer) + { + var detectedByValue = detectedBy; + + update += x => x.DetectedBy = detectedByValue; + } + if (update != null) OnHostUpdated(new IPScannerHostUpdatedArgs(hostInfo, update)); @@ -312,14 +404,30 @@ private async Task ScanHostAsync(IPAddress ipAddress, ParallelOptions portScanPa // First answer: the host is up. Before it could answer, the local system had to resolve its MAC // address, so it is in the neighbor table (ARP/NDP) from now on. - hostInfo = CreateHostInfo(ipAddress, true, pingInfo, ports, netBIOSInfo); + RaiseHostFound(); + } - _hostsUp.Increment(); + // No probe answered: a host in a local subnet may still have answered the ARP request / neighbor + // solicitation of the probes (e.g. a firewall that drops everything else) + if (hostInfo == null && isNeighborCheckEnabled && + await _neighborCache.IsNeighborAsync(ipAddress, wasNeighborReachableBefore, cancellationToken) + .ConfigureAwait(false)) + { + detectedBy = IPScannerDetectionMethod.ARP; + RaiseHostFound(); + } - OnHostFound(new IPScannerHostFoundArgs(hostInfo)); - OnProgressChanged(); + // Title of the web page - needs the open ports, so after the port scan. Without an open web port, the + // placeholder of a host found while the port scan was running is replaced. + if (isWebPagePending) + { + var webPorts = GetWebPorts(ports); - enrichTask = EnrichAsync(ipAddress, hostInfo, DateTime.UtcNow, networkInterfaces, cancellationToken); + if (webPorts.Count > 0) + enrichTask = Task.WhenAll(enrichTask, + UpdateWebPageAsync(ipAddress, hostInfo, webPorts, cancellationToken)); + else + OnHostUpdated(new IPScannerHostUpdatedArgs(hostInfo, x => x.IsWebPagePending = false)); } if (hostInfo == null) @@ -328,9 +436,11 @@ private async Task ScanHostAsync(IPAddress ipAddress, ParallelOptions portScanPa // reaching HostFound _hostsDown.Increment(); - if (options.ShowAllResults || options.ReportAllHosts) + if (options.ShowAllResults || options.ReportAllHosts || + options.ReportHosts?.Contains(ipAddress) == true) { - hostInfo = CreateHostInfo(ipAddress, false, pingInfo, ports, netBIOSInfo); + hostInfo = CreateHostInfo(ipAddress, IPScannerDetectionMethod.None, pingInfo, ports, + netBIOSInfo, snmpInfo, isSNMPFinished); OnHostFound(new IPScannerHostFoundArgs(hostInfo)); @@ -346,7 +456,8 @@ private async Task ScanHostAsync(IPAddress ipAddress, ParallelOptions portScanPa finally { // Never return while a probe or lookup is still running (e.g. another probe was canceled or failed) - await WaitIgnoringErrorsAsync(pingTask, portScanTask, netBIOSTask, enrichTask).ConfigureAwait(false); + await WaitIgnoringErrorsAsync(pingTask ?? Task.CompletedTask, portScanTask, netBIOSTask, + snmpTask ?? Task.CompletedTask, enrichTask).ConfigureAwait(false); } } @@ -381,17 +492,23 @@ private List GetShownPorts(List ports) /// /// Creates the result of a host with the results of the finished probes and placeholders for the running ones - /// (marked as pending). + /// (marked as pending) and the disabled ones. /// /// IP address of the host. - /// Indicates whether the host answered a probe. - /// Result of the ping, null if it is still running. + /// Probes the host answered so far, if it is + /// unreachable. + /// Result of the ping, null if it is still running or disabled. /// All probed ports, null if the port scan is still running. /// Result of the NetBIOS request, null if it is still running. + /// Result of the SNMP request, null if it is still running or the host didn't answer. + /// The SNMP request is finished (or disabled). /// Result of the host. - private IPScannerHostInfo CreateHostInfo(IPAddress ipAddress, bool isReachable, PingInfo pingInfo, - List ports, NetBIOSInfo netBIOSInfo) + private IPScannerHostInfo CreateHostInfo(IPAddress ipAddress, IPScannerDetectionMethod detectedBy, + PingInfo pingInfo, List ports, NetBIOSInfo netBIOSInfo, SNMPSystemInfo snmpInfo, + bool isSNMPFinished) { + var isReachable = detectedBy != IPScannerDetectionMethod.None; + return new IPScannerHostInfo( ipAddress, isReachable, @@ -400,16 +517,31 @@ private IPScannerHostInfo CreateHostInfo(IPAddress ipAddress, bool isReachable, ports != null ? GetShownPorts(ports) : [], netBIOSInfo ?? new NetBIOSInfo(ipAddress)) { - IsPingPending = pingInfo == null, + DetectedBy = detectedBy, + Role = IPScannerHostRoleHelper.GetRole(_hostRoles, ipAddress), + IsPingPending = pingInfo == null && options.ICMPEnabled, + // Lookups of every shown host and requests to reachable hosts, started after the host was found (see + // EnrichAsync, ScanHostAsync) + IsDNSPending = options.ResolveHostname, + IsNeighborMACAddressPending = options.ResolveMACAddress, + IsMDNSPending = isReachable && options.MDNSEnabled, + IsSNMPPending = !isSNMPFinished, + SNMPName = snmpInfo?.Name ?? string.Empty, + SNMPDescription = snmpInfo?.Description ?? string.Empty, + SNMPLocation = snmpInfo?.Location ?? string.Empty, + SNMPError = snmpInfo?.Error ?? SNMPV3ErrorCode.None, + IsWebPagePending = isReachable && options.WebPageEnabled && + (ports == null ? options.PortScanEnabled : GetWebPorts(ports).Count > 0), IsPortScanPending = ports == null, IsNetBIOSPending = netBIOSInfo == null }; } /// - /// Enriches a found host with the DNS hostname (PTR), MAC address and vendor. Each result is raised as - /// as soon as it arrives; the task completes only when all lookups are finished, so - /// the host (and the scan) is never finished while a lookup is still running. + /// Enriches a found host with the DNS hostname (PTR), MAC address and vendor and the mDNS hostname (only + /// reachable hosts). Each result is raised as as soon as it arrives; the task + /// completes only when all lookups are finished, so the host (and the scan) is never finished while a lookup is + /// still running. /// /// IP address of the host. /// Result of the host, already raised in . @@ -424,8 +556,25 @@ async Task UpdateHostnameAsync() var dnsHostname = await DNSClient.GetInstance().ResolvePtrOrEmptyAsync(ipAddress, cancellationToken) .ConfigureAwait(false); - if (!string.IsNullOrEmpty(dnsHostname)) - OnHostUpdated(new IPScannerHostUpdatedArgs(host, x => x.DNSHostname = dnsHostname)); + // Also without a hostname, so the placeholder is replaced + OnHostUpdated(new IPScannerHostUpdatedArgs(host, x => + { + x.DNSHostname = dnsHostname ?? string.Empty; + x.IsDNSPending = false; + })); + } + + async Task UpdateMDNSHostnameAsync() + { + var mdnsHostname = await MDNSResolver.ResolveAsync(ipAddress, options.MDNSTimeout, cancellationToken) + .ConfigureAwait(false); + + // Also without an answer, so the placeholder is replaced + OnHostUpdated(new IPScannerHostUpdatedArgs(host, x => + { + x.MDNSHostname = mdnsHostname; + x.IsMDNSPending = false; + })); } async Task UpdateMACAddressAsync() @@ -433,19 +582,68 @@ async Task UpdateMACAddressAsync() var (macAddress, vendor) = await ResolveMACAddressAsync(ipAddress, discoveredAt, networkInterfaces, cancellationToken).ConfigureAwait(false); - if (!string.IsNullOrEmpty(macAddress)) - OnHostUpdated(new IPScannerHostUpdatedArgs(host, x => - { - x.NeighborMACAddress = macAddress; - x.NeighborVendor = vendor; - })); + // Also without a MAC address, so the placeholder is replaced + OnHostUpdated(new IPScannerHostUpdatedArgs(host, x => + { + x.NeighborMACAddress = macAddress; + x.NeighborVendor = vendor; + x.IsNeighborMACAddressPending = false; + })); } - // DNS and MAC address lookup don't depend on each other - run them concurrently, the host is finished - // after the slower one instead of after both + // Requests sent to the host (e.g. mDNS) only for reachable hosts + var isReachable = discoveredAt != DateTime.MinValue; + + // The lookups don't depend on each other - run them concurrently, the host is finished after the slowest one + // instead of after all of them await Task.WhenAll( options.ResolveHostname ? UpdateHostnameAsync() : Task.CompletedTask, - options.ResolveMACAddress ? UpdateMACAddressAsync() : Task.CompletedTask).ConfigureAwait(false); + options.ResolveMACAddress ? UpdateMACAddressAsync() : Task.CompletedTask, + options.MDNSEnabled && isReachable ? UpdateMDNSHostnameAsync() : Task.CompletedTask).ConfigureAwait(false); + } + + /// + /// Gets the ports whose web page title is read (see ) that are + /// open, in the configured order. + /// + /// All probed ports, null if the port scan is disabled. + /// Open web ports. + private List GetWebPorts(List ports) + { + if (ports == null) + return []; + + var openPorts = ports.Where(x => x.State == PortState.Open).Select(x => x.Port).ToHashSet(); + + return [.. options.WebPagePorts.Where(openPorts.Contains)]; + } + + /// + /// Reads the title of the web page of a host and raises it as . + /// + /// IP address of the host. + /// Result of the host, already raised in . + /// Open ports to request, in the order to try them. + /// Token to monitor for cancellation requests. + private async Task UpdateWebPageAsync(IPAddress ipAddress, IPScannerHostInfo host, List webPorts, + CancellationToken cancellationToken) + { + var webPageInfo = await WebPageResolver.ResolveAsync(ipAddress, webPorts, options.WebPageTimeout, + cancellationToken).ConfigureAwait(false); + + // Also without an answer, so the placeholder is replaced + OnHostUpdated(new IPScannerHostUpdatedArgs(host, x => + { + x.WebTitle = webPageInfo?.Title ?? string.Empty; + x.WebURL = webPageInfo?.URL ?? string.Empty; + x.WebStatusCode = webPageInfo?.StatusCode ?? 0; + x.WebServer = webPageInfo?.Server ?? string.Empty; + x.WebPoweredBy = webPageInfo?.PoweredBy ?? string.Empty; + x.WebRealm = webPageInfo?.Realm ?? string.Empty; + x.WebRedirectURL = webPageInfo?.RedirectURL ?? string.Empty; + x.WebCertificate = webPageInfo?.Certificate; + x.IsWebPagePending = false; + })); } /// @@ -465,8 +663,8 @@ await Task.WhenAll( // Get info from neighbor table. Only hosts in a local subnet are in it (for any other // host it contains the router), an unreachable host only reads it if it was not read yet. if (IsInLocalSubnet(ipAddress, networkInterfaces)) - macAddress = await GetMACAddressFromNeighborTableAsync(ipAddress, discoveredAt, cancellationToken) - .ConfigureAwait(false); + macAddress = (await _neighborCache.GetEntryAsync(ipAddress, discoveredAt, cancellationToken) + .ConfigureAwait(false))?.MACAddress.ToString() ?? string.Empty; // Check if it is the local mac if (string.IsNullOrEmpty(macAddress)) @@ -492,66 +690,26 @@ await Task.WhenAll( 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 - /// if the cached snapshot is older than (the time the host answered, when - /// its entry was already created) - a host is always looked up in a table read after its answer, and hosts - /// that finish at the same time share one read. - /// - /// IP address of the host (in a local subnet, see ). - /// - /// Time the host answered, to use the cache - or to read the table if it was not - /// read yet. - /// - /// Token to monitor for cancellation requests. - /// MAC address or an empty string. - private async Task GetMACAddressFromNeighborTableAsync(IPAddress ipAddress, DateTime discoveredAt, - CancellationToken cancellationToken) - { - // Hosts wait without blocking a thread while another host reads the table - await _neighborTableSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); - - try - { - // With the scope id if it was entered (e.g. "fe80::1%3" and "fe80::1%7" are two hosts on different - // interfaces), see NeighborTable.GetMACAddressMap - if (_neighborTable.TryGetValue(ipAddress, out var macAddress)) - return macAddress; - - // 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 - _neighborTableTimestamp = DateTime.UtcNow; - _neighborTable = NeighborTable.GetMACAddressMap(); - - return _neighborTable.GetValueOrDefault(ipAddress, string.Empty); - } - finally - { - _neighborTableSemaphore.Release(); - } - } - /// /// Checks if is in a subnet of a local network interface (on-link). Only these /// hosts are in the neighbor table - for a host behind a router, its MAC address can't be resolved via ARP/NDP /// (the table contains the router), so there is no need to look it up (and to read the table again). /// IPv6: link-local addresses, other addresses if they are in the same /64 as a local address (the prefix - /// length is not known, local IPv6 subnets are /64). + /// length is not known, local IPv6 subnets are /64). Only operational network interfaces (an adapter that is + /// down may still have its static IPv4 address, e.g. of a subnet that is reached via VPN). /// /// IP address of the host. /// Local network interfaces. /// True if the IP address is in a local subnet. private static bool IsInLocalSubnet(IPAddress ipAddress, IEnumerable networkInterfaces) { + var operationalInterfaces = networkInterfaces.Where(x => x.IsOperational); + if (ipAddress.AddressFamily == AddressFamily.InterNetwork) { var address = IPv4Address.ToUInt32(ipAddress); - return networkInterfaces.Any(networkInterface => networkInterface.IPv4Address.Any(x => + return operationalInterfaces.Any(networkInterface => networkInterface.IPv4Address.Any(x => { if (x.Item2 == null) return false; @@ -567,7 +725,7 @@ private static bool IsInLocalSubnet(IPAddress ipAddress, IEnumerable networkInterface.IPv6Address.Any(x => + return operationalInterfaces.Any(networkInterface => networkInterface.IPv6Address.Any(x => x.GetAddressBytes().AsSpan(0, 8).SequenceEqual(prefix))); } diff --git a/Source/NETworkManager.Models/Network/IPScannerDetectionMethod.cs b/Source/NETworkManager.Models/Network/IPScannerDetectionMethod.cs new file mode 100644 index 0000000000..49587a3178 --- /dev/null +++ b/Source/NETworkManager.Models/Network/IPScannerDetectionMethod.cs @@ -0,0 +1,41 @@ +using System; + +namespace NETworkManager.Models.Network; + +/// +/// Probes of the a host answered, i.e. how it was detected as reachable. +/// +[Flags] +public enum IPScannerDetectionMethod +{ + /// + /// The host didn't answer any probe. + /// + None = 0, + + /// + /// The host answered the ping (ICMP echo reply). + /// + ICMP = 1, + + /// + /// A TCP port of the host answered (open, or the connection was refused). + /// + TCP = 2, + + /// + /// The host answered the NetBIOS request. + /// + NetBIOS = 4, + + /// + /// The host answered the ARP request (IPv4) / neighbor solicitation (IPv6) of the probes, see + /// . + /// + ARP = 8, + + /// + /// The host answered the SNMP request (UDP 161). + /// + SNMP = 16 +} diff --git a/Source/NETworkManager.Models/Network/IPScannerHostChange.cs b/Source/NETworkManager.Models/Network/IPScannerHostChange.cs new file mode 100644 index 0000000000..b33bf8012c --- /dev/null +++ b/Source/NETworkManager.Models/Network/IPScannerHostChange.cs @@ -0,0 +1,29 @@ +namespace NETworkManager.Models.Network; + +/// +/// Change of a host since the previous scan of the same input in the same tab (see +/// ). +/// +public enum IPScannerHostChange +{ + /// + /// No change, or there is no previous scan to compare with. + /// + None, + + /// + /// The host is up now, it was not up in the previous scan. + /// + New, + + /// + /// The host was up in the previous scan, it is not up now. + /// + Gone, + + /// + /// The host is up with another MAC address than in the previous scan (both known), e.g. a replaced device, an + /// IP address conflict or ARP spoofing. + /// + MACChanged +} diff --git a/Source/NETworkManager.Models/Network/IPScannerHostInfo.cs b/Source/NETworkManager.Models/Network/IPScannerHostInfo.cs index a2b929ee5e..e3ebaa5b0b 100644 --- a/Source/NETworkManager.Models/Network/IPScannerHostInfo.cs +++ b/Source/NETworkManager.Models/Network/IPScannerHostInfo.cs @@ -1,5 +1,7 @@ using System.Collections.Generic; +using System.Linq; using System.Net; +using System.Net.NetworkInformation; using NETworkManager.Utilities; namespace NETworkManager.Models.Network; @@ -55,6 +57,46 @@ internal set } } + /// + /// Probes the host answered so far, i.e. how it was detected as reachable. + /// if it is unreachable. + /// + public IPScannerDetectionMethod DetectedBy + { + get; + internal set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } + + /// + /// Roles of the host for the local computer (e.g. its default gateway), see + /// . + /// + public IPScannerHostRole Role + { + get; + internal set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(PrimaryRole)); + } + } + + /// + /// Most important role of the host (for its icon), see . + /// + public IPScannerHostRole PrimaryRole => IPScannerHostRoleHelper.GetPrimaryRole(Role); + /// /// Information about the ping to the host. /// @@ -68,15 +110,56 @@ internal set field = value; OnPropertyChanged(); + OnPropertyChanged(nameof(OSGuess)); + OnPropertyChanged(nameof(OSGuessHops)); } } /// - /// Hostname of the host: DNS hostname, fallback to the NetBIOS computer name. + /// Operating system guessed from the TTL of the ping reply (see ), + /// without a ping reply (or TTL, e.g. IPv6). + /// + public IPScannerOSGuess OSGuess => GetOSGuess().osGuess; + + /// + /// Number of hops to the host based on the TTL of the ping reply (see ). + /// + public int OSGuessHops => GetOSGuess().hops; + + private (IPScannerOSGuess osGuess, int initialTTL, int hops) GetOSGuess() + { + return !IsPingPending && PingInfo?.Status == IPStatus.Success + ? IPScannerOSGuessHelper.GetFromTTL(PingInfo.TTL) + : (IPScannerOSGuess.None, 0, 0); + } + + /// + /// Hostname of the host: DNS hostname, fallback to the mDNS hostname and the NetBIOS computer name. /// public string Hostname => !string.IsNullOrEmpty(DNSHostname) ? DNSHostname - : NetBIOSInfo?.ComputerName ?? string.Empty; + : !string.IsNullOrEmpty(MDNSHostname) + ? MDNSHostname + : NetBIOSInfo?.ComputerName ?? string.Empty; + + /// + /// mDNS hostname of the host (e.g. "macbook.local", see ), empty if not resolved + /// (yet). + /// + public string MDNSHostname + { + get; + internal set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(IsHostnamePending)); + OnPropertyChanged(nameof(Hostname)); + } + } = string.Empty; /// /// DNS hostname of the host (PTR record), empty if not resolved (yet). @@ -91,6 +174,7 @@ internal set field = value; OnPropertyChanged(); + OnPropertyChanged(nameof(IsHostnamePending)); OnPropertyChanged(nameof(Hostname)); } } = string.Empty; @@ -159,6 +243,8 @@ internal set field = value; OnPropertyChanged(); + OnPropertyChanged(nameof(OSGuess)); + OnPropertyChanged(nameof(OSGuessHops)); } } @@ -192,6 +278,8 @@ internal set field = value; OnPropertyChanged(); + OnPropertyChanged(nameof(IsHostnamePending)); + OnPropertyChanged(nameof(IsMACAddressPending)); } } @@ -209,6 +297,7 @@ internal set field = value; OnPropertyChanged(); + OnPropertyChanged(nameof(IsMACAddressPending)); OnPropertyChanged(nameof(MACAddress)); OnPropertyChanged(nameof(Vendor)); } @@ -245,6 +334,422 @@ internal set ? NeighborVendor : NetBIOSInfo?.Vendor ?? string.Empty; + /// + /// Indicates whether may still change: the DNS hostname is resolved, or there is none + /// (yet) and the mDNS hostname or - without both - the NetBIOS name is still requested. + /// + public bool IsHostnamePending => !_areLookupsEnded && + (IsDNSPending || + (string.IsNullOrEmpty(DNSHostname) && + (IsMDNSPending || (string.IsNullOrEmpty(MDNSHostname) && IsNetBIOSPending)))); + + /// + /// Indicates whether and may still change: the MAC address is + /// resolved from the neighbor table, or there is none and the NetBIOS answer (fallback) is still requested. + /// + public bool IsMACAddressPending => !_areLookupsEnded && + (IsNeighborMACAddressPending || + (string.IsNullOrEmpty(NeighborMACAddress) && IsNetBIOSPending)); + + // Set by EndPendingLookups: the scan was canceled / failed, nothing arrives any more + private bool _areLookupsEnded; + + /// + /// Ends the lookups that are still running when the scan was canceled or failed (their results never arrive): + /// DNS hostname, MAC address, mDNS hostname and web page show no value instead of "...", and + /// / no longer wait for NetBIOS. The probes that decide if + /// the host is up (ping, ports, NetBIOS, SNMP) stay pending ("..." = not finished). Call it on the thread that + /// owns the host (UI thread). + /// + public void EndPendingLookups() + { + IsDNSPending = false; + IsNeighborMACAddressPending = false; + IsMDNSPending = false; + IsWebPagePending = false; + + _areLookupsEnded = true; + + OnPropertyChanged(nameof(IsHostnamePending)); + OnPropertyChanged(nameof(IsMACAddressPending)); + } + + /// + /// Title of the web page of the host, see . Empty if it has none or it was not + /// read (yet). + /// + public string WebTitle + { + get; + internal set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(WebSummary)); + } + } = string.Empty; + + /// + /// Short summary of the web page for the result: the title, otherwise the realm of the login prompt, the + /// Server header or the target of a redirect to another host. + /// + public string WebSummary => new[] { WebTitle, WebRealm, WebServer, WebRedirectURL } + .FirstOrDefault(x => !string.IsNullOrEmpty(x)) ?? string.Empty; + + /// + /// TLS certificate of the web page, null for http or if none answered (yet). + /// + public WebCertificateInfo WebCertificate + { + get; + internal set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } + + /// + /// Realm of the login prompt of the web page (WWW-Authenticate header), empty if there is none. + /// + public string WebRealm + { + get; + internal set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(WebSummary)); + } + } = string.Empty; + + /// + /// Target of a redirect of the web page to another host, empty if there is none. + /// + public string WebRedirectURL + { + get; + internal set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(WebSummary)); + } + } = string.Empty; + + /// + /// HTTP status code of the web page (e.g. 200), 0 if none answered (yet). + /// + public int WebStatusCode + { + get; + internal set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } + + /// + /// Value of the Server header of the web page, empty if there is none. + /// + public string WebServer + { + get; + internal set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(WebSummary)); + } + } = string.Empty; + + /// + /// Value of the X-Powered-By header of the web page, empty if there is none. + /// + public string WebPoweredBy + { + get; + internal set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } = string.Empty; + + /// + /// URL of the web page that answered (e.g. "https://192.168.1.1/"), empty if none answered (yet). + /// + public string WebURL + { + get; + internal set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } = string.Empty; + + /// + /// Name of the host read via SNMP (sysName), empty if not read (yet). + /// + public string SNMPName + { + get; + internal set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } = string.Empty; + + /// + /// Description of the host read via SNMP (sysDescr), empty if not read (yet). + /// + public string SNMPDescription + { + get; + internal set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } = string.Empty; + + /// + /// Location of the host read via SNMP (sysLocation), empty if not read (yet). + /// + public string SNMPLocation + { + get; + internal set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } = string.Empty; + + /// + /// SNMPv3 error of the host (e.g. unknown user, wrong password: it answered, the values are empty), + /// otherwise. + /// + public SNMPV3ErrorCode SNMPError + { + get; + internal set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(HasSNMPError)); + } + } + + /// + /// The host answered the SNMPv3 request with an error (see ). + /// + public bool HasSNMPError => SNMPError != SNMPV3ErrorCode.None; + + /// + /// Indicates whether the DNS hostname (PTR) is still resolved ( is a placeholder). + /// + public bool IsDNSPending + { + get; + internal set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(IsHostnamePending)); + } + } + + /// + /// Indicates whether the MAC address is still resolved from the neighbor table ( + /// and are placeholders). + /// + public bool IsNeighborMACAddressPending + { + get; + internal set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(IsMACAddressPending)); + } + } + + /// + /// Indicates whether the mDNS request is still running ( is a placeholder). + /// + public bool IsMDNSPending + { + get; + internal set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(IsHostnamePending)); + } + } + + /// + /// Indicates whether the title of the web page is still read, or the port scan it needs is still running + /// ( is a placeholder). + /// + public bool IsWebPagePending + { + get; + internal set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } + + /// + /// Indicates whether the SNMP request is still running (, + /// , are placeholders). + /// + public bool IsSNMPPending + { + get; + internal set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } + + /// + /// Change since the previous scan of the same input (set by the view model after the scan, see + /// ). Not taken over by : a rescan of the host keeps + /// the change of the scan. + /// + public IPScannerHostChange Change + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } + + /// + /// MAC address of the previous scan if it changed (), otherwise + /// empty. + /// + public string PreviousMACAddress + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } = string.Empty; + + /// + /// Creates the row of a host that was up in the previous scan and is not up now + /// (), with the hostname, MAC address and role of the previous scan. + /// + /// IP address of the host. + /// The host in the previous scan. + /// Unreachable host, without pending values. + public static IPScannerHostInfo CreateGone(IPAddress ipAddress, IPScannerBaselineHost previous) + { + var host = new IPScannerHostInfo(ipAddress, false, + new PingInfo(System.DateTime.Now, ipAddress, IPStatus.Unknown), false, [], new NetBIOSInfo(ipAddress)) + { + // The shown hostname of the previous scan (DNS, mDNS or NetBIOS) - the host is not asked again + DNSHostname = previous.Hostname, + NeighborMACAddress = previous.MACAddress, + NeighborVendor = previous.Vendor, + Role = previous.Role, + Change = IPScannerHostChange.Gone + }; + + host.EndPendingLookups(); + + return host; + } + + /// + /// Marks a shown row of a host that is down now as (e.g. a row of a + /// repeated scan): the hostname and MAC address of the previous scan are shown if the host has none now. + /// + /// The host in the previous scan. + public void MarkGone(IPScannerBaselineHost previous) + { + if (string.IsNullOrEmpty(Hostname)) + DNSHostname = previous.Hostname; + + if (string.IsNullOrEmpty(MACAddress)) + { + NeighborMACAddress = previous.MACAddress; + NeighborVendor = previous.Vendor; + } + + Change = IPScannerHostChange.Gone; + } + /// /// Takes over all values of another result of the same host (e.g. a rescan), so the shown row keeps its /// identity (selection, expanded details). Call it on the thread that owns the host (UI thread). @@ -253,6 +758,8 @@ internal set public void CopyFrom(IPScannerHostInfo source) { IsReachable = source.IsReachable; + DetectedBy = source.DetectedBy; + Role = source.Role; PingInfo = source.PingInfo; IsAnyPortOpen = source.IsAnyPortOpen; Ports = source.Ports; @@ -261,6 +768,29 @@ public void CopyFrom(IPScannerHostInfo source) IsPortScanPending = source.IsPortScanPending; IsNetBIOSPending = source.IsNetBIOSPending; DNSHostname = source.DNSHostname; + MDNSHostname = source.MDNSHostname; + WebTitle = source.WebTitle; + WebURL = source.WebURL; + WebStatusCode = source.WebStatusCode; + WebServer = source.WebServer; + WebPoweredBy = source.WebPoweredBy; + WebRealm = source.WebRealm; + WebCertificate = source.WebCertificate; + WebRedirectURL = source.WebRedirectURL; + SNMPName = source.SNMPName; + SNMPDescription = source.SNMPDescription; + SNMPLocation = source.SNMPLocation; + SNMPError = source.SNMPError; + IsDNSPending = source.IsDNSPending; + IsNeighborMACAddressPending = source.IsNeighborMACAddressPending; + IsMDNSPending = source.IsMDNSPending; + IsWebPagePending = source.IsWebPagePending; + + // A rescan restarts the lookups (or a canceled one restores the ended state) + _areLookupsEnded = source._areLookupsEnded; + OnPropertyChanged(nameof(IsHostnamePending)); + OnPropertyChanged(nameof(IsMACAddressPending)); + IsSNMPPending = source.IsSNMPPending; NeighborMACAddress = source.NeighborMACAddress; NeighborVendor = source.NeighborVendor; } @@ -272,14 +802,13 @@ public void CopyFrom(IPScannerHostInfo source) /// Copy of the host information (without event subscribers). public IPScannerHostInfo Clone() { - return new IPScannerHostInfo(IPAddress, IsReachable, PingInfo, IsAnyPortOpen, Ports, NetBIOSInfo) - { - IsPingPending = IsPingPending, - IsPortScanPending = IsPortScanPending, - IsNetBIOSPending = IsNetBIOSPending, - DNSHostname = DNSHostname, - NeighborMACAddress = NeighborMACAddress, - NeighborVendor = NeighborVendor - }; + var clone = new IPScannerHostInfo(IPAddress, IsReachable, PingInfo, IsAnyPortOpen, Ports, NetBIOSInfo); + + // One list of the values (see CopyFrom), the new instance has no event subscribers yet + clone.CopyFrom(this); + clone.Change = Change; + clone.PreviousMACAddress = PreviousMACAddress; + + return clone; } } diff --git a/Source/NETworkManager.Models/Network/IPScannerHostRole.cs b/Source/NETworkManager.Models/Network/IPScannerHostRole.cs new file mode 100644 index 0000000000..c030d177fc --- /dev/null +++ b/Source/NETworkManager.Models/Network/IPScannerHostRole.cs @@ -0,0 +1,35 @@ +using System; + +namespace NETworkManager.Models.Network; + +/// +/// Roles of a host for the local computer (e.g. its default gateway), see . +/// +[Flags] +public enum IPScannerHostRole +{ + /// + /// No role. + /// + None = 0, + + /// + /// IP address of the local computer. + /// + LocalComputer = 1, + + /// + /// Default gateway of a local network interface. + /// + DefaultGateway = 2, + + /// + /// DNS server of a local network interface. + /// + DNSServer = 4, + + /// + /// DHCP server of a local network interface. + /// + DHCPServer = 8 +} diff --git a/Source/NETworkManager.Models/Network/IPScannerHostRoleHelper.cs b/Source/NETworkManager.Models/Network/IPScannerHostRoleHelper.cs new file mode 100644 index 0000000000..254a1e4685 --- /dev/null +++ b/Source/NETworkManager.Models/Network/IPScannerHostRoleHelper.cs @@ -0,0 +1,103 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using NETworkManager.Utilities; + +namespace NETworkManager.Models.Network; + +/// +/// Gets the roles of hosts for the local computer (own IP addresses, default gateways, DNS and DHCP servers) from +/// the configuration of the local network interfaces. +/// +public static class IPScannerHostRoleHelper +{ + /// + /// Gets the roles of the IP addresses known from the operational local network interfaces. + /// + /// + /// Local network interfaces, see . + /// + /// + /// Roles per IP address, IPv6 with its scope id (a link-local address is only unique on its interface, see + /// ). + /// + public static Dictionary GetRoles( + IEnumerable networkInterfaces) + { + var roles = new Dictionary(); + + void Add(IEnumerable ipAddresses, IPScannerHostRole role) + { + foreach (var ipAddress in ipAddresses ?? []) + roles[ipAddress] = roles.GetValueOrDefault(ipAddress) | role; + } + + foreach (var networkInterface in networkInterfaces.Where(x => x.IsOperational)) + { + Add(networkInterface.IPv4Address?.Select(x => x.Item1), IPScannerHostRole.LocalComputer); + Add(networkInterface.IPv6Address, IPScannerHostRole.LocalComputer); + Add(networkInterface.IPv6AddressLinkLocal, IPScannerHostRole.LocalComputer); + Add(networkInterface.IPv4Gateway, IPScannerHostRole.DefaultGateway); + Add(networkInterface.IPv6Gateway, IPScannerHostRole.DefaultGateway); + Add(networkInterface.DNSServer, IPScannerHostRole.DNSServer); + Add(networkInterface.DhcpServer, IPScannerHostRole.DHCPServer); + } + + return roles; + } + + /// + /// Gets the roles of a host. An IPv6 address with a scope id only matches the address on the same interface + /// (e.g. the gateway "fe80::1%12" is not the host "fe80::1%7" on another interface) or a known address without + /// scope id; an address without scope id (e.g. entered as "fe80::1") matches it on every interface - the roles + /// of all of them. + /// + /// Roles per IP address, see . + /// IP address of the host (an IPv6 address may have a scope id). + /// Roles of the host, if it has none. + public static IPScannerHostRole GetRole(IReadOnlyDictionary roles, + IPAddress ipAddress) + { + var role = roles.GetValueOrDefault(ipAddress); + + // IPv4, or no scope id known for the address + if (ipAddress.AddressFamily != AddressFamily.InterNetworkV6) + return role; + + var withoutScopeId = IPAddressHelper.WithoutScopeId(ipAddress); + + // With scope id: also a known address without scope id (e.g. a DNS server configured without one) + if (ipAddress.ScopeId != 0) + return role | roles.GetValueOrDefault(withoutScopeId); + + // Without scope id: the interface is not known - the address on every interface + foreach (var (key, keyRole) in roles) + { + if (key.AddressFamily == AddressFamily.InterNetworkV6 && key.ScopeId != 0 && + IPAddressHelper.WithoutScopeId(key).Equals(withoutScopeId)) + role |= keyRole; + } + + return role; + } + + /// + /// Gets the most important role of a host (for its icon): local computer, default gateway, DNS server, DHCP + /// server. + /// + /// Roles of the host. + /// Most important role, if it has none. + public static IPScannerHostRole GetPrimaryRole(IPScannerHostRole role) + { + foreach (var primaryRole in (IPScannerHostRole[]) + [IPScannerHostRole.LocalComputer, IPScannerHostRole.DefaultGateway, IPScannerHostRole.DNSServer, + IPScannerHostRole.DHCPServer]) + { + if (role.HasFlag(primaryRole)) + return primaryRole; + } + + return IPScannerHostRole.None; + } +} diff --git a/Source/NETworkManager.Models/Network/IPScannerNeighborCache.cs b/Source/NETworkManager.Models/Network/IPScannerNeighborCache.cs new file mode 100644 index 0000000000..3d5dc5d58e --- /dev/null +++ b/Source/NETworkManager.Models/Network/IPScannerNeighborCache.cs @@ -0,0 +1,262 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using log4net; + +namespace NETworkManager.Models.Network; + +/// +/// Neighbor table (ARP / NDP) of a scan of the (one instance per scan): a snapshot of the +/// table shared by all hosts and read again only when needed, the state of an entry before the probes of a host, +/// and the check of a host that didn't answer any probe (incl. the verification of an entry from earlier traffic +/// and the proxy ARP check against the default gateways). +/// +/// Roles of the hosts for the local computer (default gateways), see +/// . +internal sealed class IPScannerNeighborCache(IReadOnlyDictionary hostRoles) +{ + private static readonly ILog Log = LogManager.GetLogger(typeof(IPScannerNeighborCache)); + + /// + /// Minimum time between two reads of the neighbor table, see . Hosts that + /// finish while waiting share the next read - otherwise the table would be read for almost every host when + /// unreachable hosts are checked for their neighbor entry (ARP / NDP). + /// + private static readonly TimeSpan MinNeighborTableReadInterval = TimeSpan.FromMilliseconds(100); + + /// + /// Maximum number of neighbor entries that are verified at the same time, see - + /// each verification blocks a thread for up to about 3 seconds. + /// + private const int MaxConcurrentNeighborVerifications = 4; + + /// + /// Maximum age of the neighbor table snapshot that tells if the entry of a host was already + /// before its probes (see + /// ): the minimum time Windows keeps an entry reachable (reachable + /// time 30 s, randomized to 15 to 45 s). + /// + private static readonly TimeSpan MaxNeighborSnapshotAgeBeforeProbes = TimeSpan.FromSeconds(15); + + // Snapshot of the neighbor table (IP address -> entry), see GetEntryAsync. Replaced, never changed. + // Snapshot and the time it was read (before reading) in one object, so they are always read together without a + // lock. Only a read of the table takes the semaphore. + private sealed record NeighborTableSnapshot(Dictionary Entries, DateTime Timestamp); + + private readonly SemaphoreSlim _neighborTableSemaphore = new(1, 1); + private volatile NeighborTableSnapshot _neighborTable = new([], DateTime.MinValue); + + // The neighbor table could not be read (logged once per scan), only changed while holding the semaphore + private bool _neighborTableReadFailed; + + private readonly SemaphoreSlim _neighborVerificationSemaphore = + new(MaxConcurrentNeighborVerifications, MaxConcurrentNeighborVerifications); + + // Default gateways of the operational network interfaces (IPv6 with scope id, the neighbor table has both), for the + // proxy ARP check + private readonly List _gateways = + [.. hostRoles.Where(x => x.Value.HasFlag(IPScannerHostRole.DefaultGateway)).Select(x => x.Key)]; + + /// + /// Gets the entry of from the neighbor table, e.g. for its MAC address, see + /// . + /// + /// IP address of the host (in a local subnet of the scanner). + /// Time the host answered, if it is unreachable. + /// Token to monitor for cancellation requests. + /// Neighbor entry or null. + public async Task GetEntryAsync(IPAddress ipAddress, DateTime discoveredAt, + CancellationToken cancellationToken) + { + return (await GetEntryAsync(ipAddress, discoveredAt, false, cancellationToken).ConfigureAwait(false)).entry; + } + + /// + /// Gets the entry of from the neighbor table (ARP / NDP). The table is + /// read once and cached for the scan instead of reading it for every host. It is only read again on a miss + /// (or, with , if the cached entry is not + /// ) if the cached snapshot is older than + /// (the time the host answered, when its entry was already created) - a host is always looked up in a table + /// read after its answer, and hosts that finish at the same time share one read (at most one read per + /// ). + /// + /// IP address of the host (in a local subnet of the scanner). + /// + /// Time the host answered (or its probes were finished), to use the cache - or + /// to read the table if it was not read yet. + /// + /// + /// Read the table again if the cached entry is not (e.g. still + /// / in an older snapshot). + /// + /// Token to monitor for cancellation requests. + /// Neighbor entry or null, and the time the snapshot it is from was read. + private async Task<(NeighborInfo entry, DateTime timestamp)> GetEntryAsync(IPAddress ipAddress, + DateTime discoveredAt, bool requireReachable, CancellationToken cancellationToken) + { + // Cached snapshot, no lock needed: an entry, or no newer entry since the snapshot was read after the host + // answered. 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.GetNeighborMap. + var snapshot = _neighborTable; + + if (TryGetCachedEntry(snapshot, ipAddress, discoveredAt, requireReachable, out var entry)) + return (entry, snapshot.Timestamp); + + // Hosts that finish in the meantime share the next read - wait without holding the lock, so lookups that are + // answered from the cache never wait for it + var wait = snapshot.Timestamp + MinNeighborTableReadInterval - DateTime.UtcNow; + + if (wait > TimeSpan.Zero) + await Task.Delay(wait, cancellationToken).ConfigureAwait(false); + + // Hosts wait without blocking a thread while another host reads the table + await _neighborTableSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + + try + { + // Read by another host in the meantime + snapshot = _neighborTable; + + if (TryGetCachedEntry(snapshot, ipAddress, discoveredAt, requireReachable, out entry)) + return (entry, snapshot.Timestamp); + + // Timestamp before reading the table, so entries created while reading are covered by the next read + var timestamp = DateTime.UtcNow; + + Dictionary entries; + + try + { + entries = NeighborTable.GetNeighborMap(); + } + catch (Exception ex) + { + // E.g. an error of the IP helper API: no entry instead of ending the scan (hosts are only found by their + // probes and get no MAC address from the table), read again on the next miss + if (!_neighborTableReadFailed) + { + _neighborTableReadFailed = true; + + Log.Error("GetEntryAsync - Could not read the neighbor table (ARP / NDP).", ex); + } + + entries = []; + } + + _neighborTable = new NeighborTableSnapshot(entries, timestamp); + + return (entries.GetValueOrDefault(ipAddress), timestamp); + } + finally + { + _neighborTableSemaphore.Release(); + } + } + + /// + /// Checks if the neighbor entry of a host was already before its probes + /// are sent (see ), from the cached snapshot. A snapshot older than + /// doesn't tell: the entry (e.g. + /// in the snapshot) may have been confirmed by other traffic since and still + /// be Reachable from it, also if the host is gone - it is treated as reachable, so a Reachable entry after the + /// probes is verified. + /// + /// IP address of the host (in a local subnet of the scanner). + /// Token to monitor for cancellation requests. + /// True if the entry was reachable before the probes, or if this is not known. + public async Task WasReachableBeforeAsync(IPAddress ipAddress, CancellationToken cancellationToken) + { + var (entry, timestamp) = await GetEntryAsync(ipAddress, DateTime.MinValue, false, cancellationToken) + .ConfigureAwait(false); + + return entry?.State == NeighborState.Reachable || + timestamp < DateTime.UtcNow - MaxNeighborSnapshotAgeBeforeProbes; + } + + /// + /// Looks up an entry in a snapshot of the neighbor table, see . + /// + /// Snapshot of the neighbor table. + /// IP address of the host. + /// Time the host answered (or its probes were finished). + /// Only a entry is a hit. + /// Entry of the host (also a not reachable one), null if there is none. + /// True if the snapshot answers the lookup (no need to read the table again). + private static bool TryGetCachedEntry(NeighborTableSnapshot snapshot, IPAddress ipAddress, + DateTime discoveredAt, bool requireReachable, out NeighborInfo entry) + { + if (snapshot.Entries.TryGetValue(ipAddress, out entry) && + (!requireReachable || entry.State == NeighborState.Reachable)) + return true; + + // Already read after the host answered -> no (newer) entry + return snapshot.Timestamp > discoveredAt; + } + + /// + /// Checks if a host in a local subnet that didn't answer any probe answered the ARP request / neighbor + /// solicitation of the probes (see ): its neighbor entry is read after the + /// probes were finished; an entry from earlier traffic that was not verified again yet (e.g. + /// ) is resolved again (at most + /// at the same time). + /// + /// IP address of the host (in a local subnet of the scanner). + /// + /// The entry was already reachable before the probes were sent, or this is not known (see + /// ). + /// + /// Token to monitor for cancellation requests. + /// True if the host is reachable. + public async Task IsNeighborAsync(IPAddress ipAddress, bool wasReachableBefore, + CancellationToken cancellationToken) + { + var (entry, _) = await GetEntryAsync(ipAddress, DateTime.UtcNow, true, cancellationToken) + .ConfigureAwait(false); + + // Gateways (from the roles of the hosts, computed once per scan) + var isGateway = IPScannerHostRoleHelper.GetRole(hostRoles, ipAddress) + .HasFlag(IPScannerHostRole.DefaultGateway); + + // MAC addresses of the gateways from the current snapshot (replaced, never changed - no lock needed) + var neighborTable = _neighborTable.Entries; + + var gatewayMACAddresses = _gateways + .Select(x => neighborTable.GetValueOrDefault(x)?.MACAddress.ToString()) + .Where(x => !string.IsNullOrEmpty(x)) + .ToList(); + + switch (IPScannerNeighborCheck.Check(entry, wasReachableBefore, isGateway, gatewayMACAddresses)) + { + case IPScannerNeighborCheckResult.Reachable: + return true; + case IPScannerNeighborCheckResult.Unreachable: + return false; + } + + // Entry from earlier traffic: resolve it again - blocks a thread until the neighbor answered or the resolution + // failed, so only a few at the same time + await _neighborVerificationSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + + try + { + // Address of the entry (IPv6 link-local: with the scope id of its interface). Also a canceled scan waits + // for the resolution (max. ~3 s) - nothing may still run (and change the neighbor table) when the scan + // is complete. + var macAddress = await Task.Run(() => NeighborTable.ResolveEntry(entry.IPAddress, entry.InterfaceIndex), + CancellationToken.None).ConfigureAwait(false); + + cancellationToken.ThrowIfCancellationRequested(); + + return !string.IsNullOrEmpty(macAddress) && + (isGateway || !IPScannerNeighborCheck.IsProxyARPAnswer(macAddress, gatewayMACAddresses)); + } + finally + { + _neighborVerificationSemaphore.Release(); + } + } + +} diff --git a/Source/NETworkManager.Models/Network/IPScannerNeighborCheck.cs b/Source/NETworkManager.Models/Network/IPScannerNeighborCheck.cs new file mode 100644 index 0000000000..1d9a829099 --- /dev/null +++ b/Source/NETworkManager.Models/Network/IPScannerNeighborCheck.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; + +namespace NETworkManager.Models.Network; + +/// +/// Decides if a host that didn't answer any probe of the is reachable based on its +/// neighbor entry (ARP / NDP). Every probe (ping, TCP, NetBIOS) makes Windows resolve the MAC address of a host in +/// a local subnet first, and a host answers ARP requests / neighbor solicitations even if its firewall drops +/// everything else. +/// +public static class IPScannerNeighborCheck +{ + /// + /// Checks the neighbor entry of a host that didn't answer any probe. + /// + /// + /// Neighbor entry of the host (read after its probes were finished), null if there is none. + /// + /// + /// The entry was already before the probes were sent - Windows doesn't + /// resolve it again for the probes while it is (about 15 to 45 seconds), so it may be from earlier traffic of a + /// host that is gone now. + /// + /// The host is a default gateway of a local network interface. + /// MAC addresses of the default gateways (e.g. "001122AABBCC"). + /// What the entry says about the host. + public static IPScannerNeighborCheckResult Check(NeighborInfo entry, bool wasReachableBefore, bool isGateway, + IReadOnlyCollection gatewayMACAddresses) + { + // No entry (the neighbor table only contains entries with a MAC address): the host didn't answer the ARP + // request / neighbor solicitation + if (entry == null) + return IPScannerNeighborCheckResult.Unreachable; + + // A router with proxy ARP answers for addresses of other hosts (e.g. every address of the subnet) with its own + // MAC address - not an answer of the host + if (!isGateway && IsProxyARPAnswer(entry.MACAddress.ToString(), gatewayMACAddresses)) + return IPScannerNeighborCheckResult.Unreachable; + + return entry.State switch + { + // Confirmed when the probes resolved it - or still from before the probes (verify it) + NeighborState.Reachable => wasReachableBefore + ? IPScannerNeighborCheckResult.Verify + : IPScannerNeighborCheckResult.Reachable, + // Entry from earlier traffic, not verified again yet (Delay / Probe: the verification is still running) + NeighborState.Stale or NeighborState.Delay or NeighborState.Probe => IPScannerNeighborCheckResult.Verify, + // Static entry (says nothing about the host), or the neighbor didn't answer + _ => IPScannerNeighborCheckResult.Unreachable + }; + } + + /// + /// Checks if a MAC address resolved for a host is the MAC address of a default gateway (proxy ARP). + /// + /// Resolved MAC address (e.g. "001122AABBCC"). + /// MAC addresses of the default gateways (e.g. "001122AABBCC"). + /// True if the MAC address is the MAC address of a default gateway. + public static bool IsProxyARPAnswer(string macAddress, IReadOnlyCollection gatewayMACAddresses) + { + foreach (var gatewayMACAddress in gatewayMACAddresses) + { + if (string.Equals(macAddress, gatewayMACAddress, StringComparison.OrdinalIgnoreCase)) + return true; + } + + return false; + } +} diff --git a/Source/NETworkManager.Models/Network/IPScannerNeighborCheckResult.cs b/Source/NETworkManager.Models/Network/IPScannerNeighborCheckResult.cs new file mode 100644 index 0000000000..d60a7f0d52 --- /dev/null +++ b/Source/NETworkManager.Models/Network/IPScannerNeighborCheckResult.cs @@ -0,0 +1,25 @@ +namespace NETworkManager.Models.Network; + +/// +/// Result of : what the neighbor entry (ARP / NDP) of a host that +/// didn't answer any probe says about the host. +/// +public enum IPScannerNeighborCheckResult +{ + /// + /// The host is not reachable (no entry, the neighbor didn't answer, a static entry or a proxy ARP answer). + /// + Unreachable, + + /// + /// The host answered the ARP request / neighbor solicitation of the probes - it is reachable. + /// + Reachable, + + /// + /// The entry is older than the probes and was not verified again yet (e.g. + /// ) - resolve it again to find out (see + /// ). + /// + Verify +} diff --git a/Source/NETworkManager.Models/Network/IPScannerOSGuess.cs b/Source/NETworkManager.Models/Network/IPScannerOSGuess.cs new file mode 100644 index 0000000000..ce217d26c4 --- /dev/null +++ b/Source/NETworkManager.Models/Network/IPScannerOSGuess.cs @@ -0,0 +1,27 @@ +namespace NETworkManager.Models.Network; + +/// +/// Operating system of a host guessed from the TTL of its ping reply, see . +/// +public enum IPScannerOSGuess +{ + /// + /// No guess (no ping reply, or no TTL like for IPv6). + /// + None, + + /// + /// Initial TTL 64: Linux, Unix, macOS, Android, iOS. + /// + LinuxUnix, + + /// + /// Initial TTL 128: Windows. + /// + Windows, + + /// + /// Initial TTL 255: network device (e.g. router, switch, printer), Solaris. + /// + NetworkDevice +} diff --git a/Source/NETworkManager.Models/Network/IPScannerOSGuessHelper.cs b/Source/NETworkManager.Models/Network/IPScannerOSGuessHelper.cs new file mode 100644 index 0000000000..e767dd55c6 --- /dev/null +++ b/Source/NETworkManager.Models/Network/IPScannerOSGuessHelper.cs @@ -0,0 +1,28 @@ +namespace NETworkManager.Models.Network; + +/// +/// Guesses the operating system of a host from the TTL of its ping reply. Every operating system starts with a +/// typical TTL (64, 128 or 255) and every router on the way reduces it by one - the initial TTL is the smallest +/// typical TTL that is not lower than the received TTL. It's a hint, not a detection: the TTL can be changed on +/// the host, NAT and VPN can change it. +/// +public static class IPScannerOSGuessHelper +{ + /// + /// Gets the guessed operating system and the number of hops from the TTL of a ping reply. + /// + /// TTL of the ping reply, 0 if there is none (e.g. IPv6). + /// Guessed operating system, initial TTL and hops (0 if there is no guess). + public static (IPScannerOSGuess osGuess, int initialTTL, int hops) GetFromTTL(int ttl) + { + var (osGuess, initialTTL) = ttl switch + { + <= 0 or > 255 => (IPScannerOSGuess.None, 0), + <= 64 => (IPScannerOSGuess.LinuxUnix, 64), + <= 128 => (IPScannerOSGuess.Windows, 128), + _ => (IPScannerOSGuess.NetworkDevice, 255) + }; + + return osGuess == IPScannerOSGuess.None ? (osGuess, 0, 0) : (osGuess, initialTTL, initialTTL - ttl); + } +} diff --git a/Source/NETworkManager.Models/Network/IPScannerOptions.cs b/Source/NETworkManager.Models/Network/IPScannerOptions.cs index 39047590e9..26dc4a68f8 100644 --- a/Source/NETworkManager.Models/Network/IPScannerOptions.cs +++ b/Source/NETworkManager.Models/Network/IPScannerOptions.cs @@ -16,6 +16,11 @@ public record IPScannerOptions /// public required int MaxPortThreads { get; init; } + /// + /// Ping the host. + /// + public required bool ICMPEnabled { get; init; } + /// /// Number of attempts to ping a host. /// @@ -32,6 +37,11 @@ public record IPScannerOptions /// public required int ICMPBufferSize { get; init; } + /// + /// Guess the operating system from the TTL of the ping reply (only shown in the result, no extra traffic). + /// + public required bool OSGuessEnabled { get; init; } + /// /// Resolve the hostname for an IP address. /// @@ -67,11 +77,59 @@ public record IPScannerOptions /// public required int NetBIOSTimeout { get; init; } + /// + /// Find hosts in a local subnet that didn't answer any probe by their neighbor entry (ARP / NDP), see + /// . + /// + public required bool ARPEnabled { get; init; } + /// /// Resolve the MAC address for the host from ARP. /// public required bool ResolveMACAddress { get; init; } + /// + /// Resolve the mDNS hostname of reachable hosts, see . + /// + public required bool MDNSEnabled { get; init; } + + /// + /// Timeout in milliseconds after which an mDNS request is considered lost. + /// + public required int MDNSTimeout { get; init; } + + /// + /// Read the title of the web page of reachable hosts with an open web port, see + /// . + /// + public required bool WebPageEnabled { get; init; } + + /// + /// Ports whose web page title is read, in this order (only open ports, the first that answers is used). + /// + public required int[] WebPagePorts { get; init; } + + /// + /// Timeout in milliseconds of a web request. + /// + public required int WebPageTimeout { get; init; } + + /// + /// Read the SNMP system information of reachable hosts, see . + /// + public required bool SNMPEnabled { get; init; } + + /// + /// SNMP version and credentials (community for v1 / v2c, user for v3): of the settings, or the override of the + /// profile / group the tab was opened with. + /// + public required SNMPCredentials SNMPCredentials { get; init; } + + /// + /// Timeout in milliseconds after which an SNMP request is considered lost. + /// + public required int SNMPTimeout { get; init; } + /// /// Include unreachable IP addresses in the result. /// @@ -82,4 +140,11 @@ public record IPScannerOptions /// hosts), without showing all ports like . /// public required bool ReportAllHosts { get; init; } + + /// + /// Hosts that are raised in also if they are unreachable (e.g. the shown rows + /// of a repeated scan, so a host that is down now updates its row) - unlike , the + /// other unreachable hosts are not created and looked up. Null for none. + /// + public System.Collections.Generic.IReadOnlySet ReportHosts { get; init; } } diff --git a/Source/NETworkManager.Models/Network/IPScannerResultOptions.cs b/Source/NETworkManager.Models/Network/IPScannerResultOptions.cs index 385c71fce0..d838269e62 100644 --- a/Source/NETworkManager.Models/Network/IPScannerResultOptions.cs +++ b/Source/NETworkManager.Models/Network/IPScannerResultOptions.cs @@ -4,22 +4,33 @@ /// Options of an IP scan that decide which columns and details of its result are shown. Only these flags of the /// , without the ports and the ICMP buffer of the scan. /// +/// The hosts were pinged. +/// The operating system was guessed from the TTL of the ping reply. /// The hostname was resolved via DNS. /// The ports of the hosts were scanned. /// The NetBIOS information was requested. /// The MAC address and vendor were resolved. +/// The mDNS hostname was resolved. +/// The title of the web pages was read. +/// The SNMP system information was read. public sealed record IPScannerResultOptions( + bool ICMPEnabled, + bool OSGuessEnabled, bool ResolveHostname, bool PortScanEnabled, bool NetBIOSEnabled, - bool ResolveMACAddress) + bool ResolveMACAddress, + bool MDNSEnabled, + bool WebPageEnabled, + bool SNMPEnabled) { /// /// Creates the result options from the options of a scan. /// /// Options of the scan. public IPScannerResultOptions(IPScannerOptions options) - : this(options.ResolveHostname, options.PortScanEnabled, options.NetBIOSEnabled, options.ResolveMACAddress) + : this(options.ICMPEnabled, options.OSGuessEnabled, options.ResolveHostname, options.PortScanEnabled, options.NetBIOSEnabled, options.ResolveMACAddress, + options.MDNSEnabled, options.WebPageEnabled, options.SNMPEnabled) { } } diff --git a/Source/NETworkManager.Models/Network/IPScannerScanComparer.cs b/Source/NETworkManager.Models/Network/IPScannerScanComparer.cs new file mode 100644 index 0000000000..49ca6466b4 --- /dev/null +++ b/Source/NETworkManager.Models/Network/IPScannerScanComparer.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; +using System.Net; + +namespace NETworkManager.Models.Network; + +/// +/// A host of a completed scan, the baseline of the next scan of the same input (see +/// ). +/// +/// The host was up (reachable). +/// Shown hostname (DNS, mDNS or NetBIOS), empty if unknown. +/// MAC address, empty if unknown. +/// Vendor of the MAC address, empty if unknown. +/// Roles of the host (e.g. the default gateway), kept by the row of a gone host. +public sealed record IPScannerBaselineHost(bool IsUp, string Hostname, string MACAddress, string Vendor, + IPScannerHostRole Role = IPScannerHostRole.None); + +/// +/// Result of . +/// +/// Change of each host of the current scan (only hosts with a change). +/// Hosts that were up in the previous scan and are not up (or not in the result) now. +public sealed record IPScannerComparison( + IReadOnlyDictionary Changes, + IReadOnlyList<(IPAddress IPAddress, IPScannerBaselineHost Previous)> Gone); + +/// +/// Compares a scan with the previous scan of the same input: which hosts are new, gone or have another MAC address. +/// +public static class IPScannerScanComparer +{ + /// + /// Creates the baseline of a completed scan, to compare the next scan with. + /// + /// Hosts of the result (up, and down ones if they are shown). + /// Baseline by IP address. + public static Dictionary CreateBaseline(IEnumerable hosts) + { + var baseline = new Dictionary(); + + foreach (var host in hosts) + { + baseline[host.IPAddress] = new IPScannerBaselineHost(host.IsReachable, host.Hostname ?? string.Empty, + host.MACAddress ?? string.Empty, host.Vendor ?? string.Empty, host.Role); + } + + return baseline; + } + + /// + /// Compares the current scan with the baseline of the previous one. + /// + /// Baseline of the previous scan (see ). + /// Current scan as baseline (see ). + /// Changes of the current hosts and the gone hosts (in the order of the previous scan). + public static IPScannerComparison Compare(IReadOnlyDictionary baseline, + IReadOnlyDictionary current) + { + var changes = new Dictionary(); + var gone = new List<(IPAddress, IPScannerBaselineHost)>(); + + foreach (var (ipAddress, host) in current) + { + if (!host.IsUp) + continue; + + if (!baseline.TryGetValue(ipAddress, out var previous) || !previous.IsUp) + { + changes[ipAddress] = (IPScannerHostChange.New, string.Empty); + continue; + } + + if (!string.IsNullOrEmpty(host.MACAddress) && !string.IsNullOrEmpty(previous.MACAddress) && + !string.Equals(host.MACAddress, previous.MACAddress, StringComparison.OrdinalIgnoreCase)) + changes[ipAddress] = (IPScannerHostChange.MACChanged, previous.MACAddress); + } + + foreach (var (ipAddress, previous) in baseline) + { + if (previous.IsUp && (!current.TryGetValue(ipAddress, out var host) || !host.IsUp)) + gone.Add((ipAddress, previous)); + } + + return new IPScannerComparison(changes, gone); + } +} diff --git a/Source/NETworkManager.Models/Network/LocalSubnetInfo.cs b/Source/NETworkManager.Models/Network/LocalSubnetInfo.cs new file mode 100644 index 0000000000..31bee907d9 --- /dev/null +++ b/Source/NETworkManager.Models/Network/LocalSubnetInfo.cs @@ -0,0 +1,15 @@ +namespace NETworkManager.Models.Network; + +/// +/// IPv4 subnet of a local network interface, e.g. to scan the subnet of a specific network interface. +/// +/// Name of the network interface (e.g. "Ethernet"). +/// Subnet in CIDR notation (network address, e.g. "192.168.1.0/24"). +/// The subnet is the link-local subnet (169.254.0.0/16, no DHCP server answered). +public sealed record LocalSubnetInfo(string InterfaceName, string Subnet, bool IsLinkLocal) +{ + /// + /// Text to show the subnet in a list, e.g. "192.168.1.0/24 (Ethernet)". + /// + public string DisplayName => $"{Subnet} ({InterfaceName})"; +} diff --git a/Source/NETworkManager.Models/Network/MDNSResolver.cs b/Source/NETworkManager.Models/Network/MDNSResolver.cs new file mode 100644 index 0000000000..f5d8bb7103 --- /dev/null +++ b/Source/NETworkManager.Models/Network/MDNSResolver.cs @@ -0,0 +1,216 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace NETworkManager.Models.Network; + +/// +/// Resolves the mDNS hostname (e.g. "macbook.local") of a host: reverse lookup (PTR) sent as unicast query +/// to UDP port 5353 of the host (RFC 6762: a query from a port other than 5353 is answered with a unicast +/// response to the sender, which repeats the ID and the question). Answered by Apple devices, Linux (Avahi), +/// Android, printers and many IoT devices - usually only for requests from the local subnet. +/// +public static class MDNSResolver +{ + private const int MDNSPort = 5353; + + private const ushort TypePTR = 12; + private const ushort ClassIN = 1; + + // Maximum number of compression pointers followed in a name (protection against loops) + private const int MaxCompressionPointers = 32; + + /// + /// Resolves the mDNS hostname of a host. + /// + /// IP address of the host. + /// Timeout in milliseconds to wait for the answer. + /// Token to monitor for cancellation requests. + /// Hostname without trailing dot (e.g. "macbook.local"), empty if the host didn't answer. + public static async Task ResolveAsync(IPAddress ipAddress, int timeout, + CancellationToken cancellationToken) + { + try + { + var id = (ushort)Random.Shared.Next(1, ushort.MaxValue); + + var response = await UdpRequest.SendAsync(ipAddress, MDNSPort, CreateReverseQuery(ipAddress, id), + timeout, cancellationToken).ConfigureAwait(false); + + return response == null ? string.Empty : ParsePTRResponse(response, id); + } + catch (Exception) + { + return string.Empty; + } + } + + /// + /// Gets the reverse lookup name of an IP address (e.g. "4.3.2.1.in-addr.arpa", nibbles + "ip6.arpa" for IPv6). + /// + /// IP address. + /// Reverse lookup name. + public static string GetReverseName(IPAddress ipAddress) + { + var bytes = ipAddress.GetAddressBytes(); + + if (ipAddress.AddressFamily == AddressFamily.InterNetwork) + return $"{bytes[3]}.{bytes[2]}.{bytes[1]}.{bytes[0]}.in-addr.arpa"; + + var builder = new StringBuilder(); + + for (var i = bytes.Length - 1; i >= 0; i--) + builder.Append($"{bytes[i] & 0x0F:x}.{bytes[i] >> 4:x}."); + + return builder.Append("ip6.arpa").ToString(); + } + + /// + /// Creates a DNS query for the PTR record of the reverse lookup name of an IP address. + /// + /// IP address to look up. + /// ID of the query (repeated in the answer). + /// DNS message. + public static byte[] CreateReverseQuery(IPAddress ipAddress, ushort id) + { + var query = new List + { + // Header: ID, flags (standard query), 1 question, no answer / authority / additional records + (byte)(id >> 8), (byte)id, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 + }; + + foreach (var label in GetReverseName(ipAddress).Split('.')) + { + query.Add((byte)label.Length); + query.AddRange(Encoding.ASCII.GetBytes(label)); + } + + query.AddRange([0x00, TypePTR >> 8, TypePTR & 0xFF, ClassIN >> 8, ClassIN & 0xFF]); + + return [.. query]; + } + + /// + /// Gets the hostname from the answer to : the first PTR record of the + /// answer section. + /// + /// DNS message of the answer. + /// ID of the query. + /// + /// Hostname without trailing dot and control characters, empty if the answer is invalid or contains no + /// PTR record. + /// + public static string ParsePTRResponse(byte[] response, ushort id) + { + // Header (12 bytes), must be a response (QR bit) to the query (ID, some responders use 0) + if (response.Length < 12 || (response[2] & 0x80) == 0) + return string.Empty; + + var responseId = (ushort)((response[0] << 8) | response[1]); + + if (responseId != id && responseId != 0) + return string.Empty; + + var questions = (response[4] << 8) | response[5]; + var answers = (response[6] << 8) | response[7]; + + var offset = 12; + + // Skip the question(s): name, type, class + for (var i = 0; i < questions; i++) + { + if (ReadName(response, ref offset) == null || offset + 4 > response.Length) + return string.Empty; + + offset += 4; + } + + for (var i = 0; i < answers; i++) + { + // Name, type, class (cache flush bit in mDNS), TTL, data length, data + if (ReadName(response, ref offset) == null || offset + 10 > response.Length) + return string.Empty; + + var type = (response[offset] << 8) | response[offset + 1]; + var dataLength = (response[offset + 8] << 8) | response[offset + 9]; + + offset += 10; + + if (offset + dataLength > response.Length) + return string.Empty; + + if (type == TypePTR) + { + var dataOffset = offset; + + // Labels can contain any byte - control characters (e.g. 0x00) are not shown and would make the XML + // export fail + var name = ReadName(response, ref dataOffset) ?? string.Empty; + + return new string([.. name.Where(x => !char.IsControl(x))]); + } + + offset += dataLength; + } + + return string.Empty; + } + + /// + /// Reads a (compressed) domain name at and moves the offset behind it. + /// + /// DNS message. + /// Offset of the name, behind the name afterwards. + /// Name without trailing dot, null if the name is invalid. + private static string ReadName(byte[] message, ref int offset) + { + var labels = new List(); + var position = offset; + var endOffset = -1; + var pointers = 0; + + while (true) + { + if (position >= message.Length) + return null; + + var length = message[position]; + + // End of the name + if (length == 0) + { + position++; + break; + } + + // Compression pointer (2 bytes) to a name earlier in the message + if ((length & 0xC0) == 0xC0) + { + if (position + 1 >= message.Length || ++pointers > MaxCompressionPointers) + return null; + + // The name ends after the first pointer + if (endOffset < 0) + endOffset = position + 2; + + position = ((length & 0x3F) << 8) | message[position + 1]; + continue; + } + + if ((length & 0xC0) != 0 || position + 1 + length > message.Length) + return null; + + labels.Add(Encoding.UTF8.GetString(message, position + 1, length)); + position += 1 + length; + } + + offset = endOffset >= 0 ? endOffset : position; + + return string.Join(".", labels); + } +} diff --git a/Source/NETworkManager.Models/Network/NeighborState.cs b/Source/NETworkManager.Models/Network/NeighborState.cs index 761e5339ad..6dd72acc37 100644 --- a/Source/NETworkManager.Models/Network/NeighborState.cs +++ b/Source/NETworkManager.Models/Network/NeighborState.cs @@ -2,16 +2,16 @@ namespace NETworkManager.Models.Network; /// /// Reachability state of an IP neighbor entry. -/// Values match the State field of the MIB_IPNET_ROW2 structure -/// returned by GetIpNetTable2, so a direct cast is safe. +/// Values match the NL_NEIGHBOR_STATE enumeration (State field of the MIB_IPNET_ROW2 +/// structure returned by GetIpNetTable2, starts at 0), so a direct cast is safe. /// public enum NeighborState { - Unreachable = 1, - Incomplete = 2, - Probe = 3, - Delay = 4, - Stale = 5, - Reachable = 6, - Permanent = 7 + Unreachable = 0, + Incomplete = 1, + Probe = 2, + Delay = 3, + Stale = 4, + Reachable = 5, + Permanent = 6 } diff --git a/Source/NETworkManager.Models/Network/NeighborTable.cs b/Source/NETworkManager.Models/Network/NeighborTable.cs index 2de34aa297..ba24b3cafd 100644 --- a/Source/NETworkManager.Models/Network/NeighborTable.cs +++ b/Source/NETworkManager.Models/Network/NeighborTable.cs @@ -75,6 +75,16 @@ private struct MIB_IPNET_ROW2 [DllImport("Iphlpapi.dll")] private static extern void FreeMibTable(IntPtr memory); + /// + /// Flushes the neighbor entry of Row.Address on the interface of the row and resolves it again (ARP request / + /// neighbor solicitation). Blocks until the neighbor answered or the resolution failed. Returns 0 on success. + /// + [DllImport("Iphlpapi.dll")] + private static extern uint ResolveIpNetEntry2(ref MIB_IPNET_ROW2 row, IntPtr sourceAddress); + + // ResolveIpNetEntry2: the neighbor didn't answer (network name not found) + private const uint ERROR_BAD_NET_NAME = 67; + /// /// Ensures that only one PowerShell pipeline runs on at a time. /// @@ -312,31 +322,84 @@ private static Dictionary BuildInterfaceAliasMap() } /// - /// Returns a snapshot of the neighbor cache (IPv4 and IPv6) as map from IP address to MAC address - /// (e.g. "001122AABBCC"). IPv6 addresses are stored with their scope id (e.g. "fe80::1%3", for an address entered + /// Returns a snapshot of the neighbor cache (IPv4 and IPv6) as map from IP address to the entry (MAC address, state, + /// interface). IPv6 addresses are stored with their scope id (e.g. "fe80::1%3", for an address entered /// with one) and without it (see , for an address entered without one - /// if the address is on several interfaces, the last entry wins). Entries without a MAC address, or with a link-layer /// address that is not a 6 byte MAC address (e.g. the IPv4 address of a tunnel interface), are skipped. /// Reading the table once and looking up many addresses is much cheaper than reading it per address. /// - public static Dictionary GetMACAddressMap() + public static Dictionary GetNeighborMap() { - var map = new Dictionary(); + var map = new Dictionary(); foreach (var entry in GetTable()) { if (entry.MACAddress.GetAddressBytes().Length != 6) continue; - var macAddress = entry.MACAddress.ToString(); - - map[entry.IPAddress] = macAddress; - map[IPAddressHelper.WithoutScopeId(entry.IPAddress)] = macAddress; + map[entry.IPAddress] = entry; + map[IPAddressHelper.WithoutScopeId(entry.IPAddress)] = entry; } return map; } + /// + /// Verifies a neighbor entry: flushes the entry of on the interface and resolves it again + /// (ARP request for IPv4, neighbor solicitation for IPv6) via ResolveIpNetEntry2, e.g. to check if a neighbor + /// whose entry is is still there. Blocks until the neighbor answered or + /// the resolution failed (about 3 seconds) - run it on a thread pool thread and limit how many run at the same time. + /// + /// IP address of the neighbor (IPv4 or IPv6, with the scope id for a link-local address). + /// Index of the interface the neighbor is on (e.g. of its current entry). + /// MAC address (e.g. "001122AABBCC") if the neighbor answered, otherwise null. + public static string ResolveEntry(IPAddress ipAddress, int interfaceIndex) + { + // SOCKADDR_INET: family (2 bytes), IPv4: address at offset 4, IPv6: address at offset 8 and scope id at 24 + var address = new byte[SOCKADDR_INET_SIZE]; + + if (ipAddress.AddressFamily == AddressFamily.InterNetwork) + { + BitConverter.GetBytes(AF_INET).CopyTo(address, 0); + ipAddress.GetAddressBytes().CopyTo(address, 4); + } + else + { + BitConverter.GetBytes(AF_INET6).CopyTo(address, 0); + ipAddress.GetAddressBytes().CopyTo(address, 8); + BitConverter.GetBytes((uint)ipAddress.ScopeId).CopyTo(address, 24); + } + + var row = new MIB_IPNET_ROW2 + { + Address = address, + InterfaceIndex = (uint)interfaceIndex, + PhysicalAddress = new byte[IF_MAX_PHYS_ADDRESS_LENGTH] + }; + + var result = ResolveIpNetEntry2(ref row, IntPtr.Zero); + + if (result != 0) + { + // No answer is the expected result for a neighbor that is gone + if (result != ERROR_BAD_NET_NAME) + Log.Warn($"Could not resolve the neighbor entry of {ipAddress} (interface {interfaceIndex}): " + + new Win32Exception((int)result).Message); + + return null; + } + + if (row.PhysicalAddressLength != 6) + return null; + + var macBytes = new byte[6]; + Buffer.BlockCopy(row.PhysicalAddress, 0, macBytes, 0, 6); + + // An unanswered resolution can return a zero MAC address + return macBytes.All(x => x == 0) ? null : new PhysicalAddress(macBytes).ToString(); + } + /// /// Adds a permanent neighbor entry for with the given /// by running New-NetNeighbor through the shared diff --git a/Source/NETworkManager.Models/Network/NetBIOSResolver.cs b/Source/NETworkManager.Models/Network/NetBIOSResolver.cs index 8fdc11ba91..b776ecd094 100644 --- a/Source/NETworkManager.Models/Network/NetBIOSResolver.cs +++ b/Source/NETworkManager.Models/Network/NetBIOSResolver.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.Linq; using System.Net; -using System.Net.Sockets; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -44,58 +43,19 @@ public static class NetBIOSResolver 0x00, 0x01 }; - /// - /// Maximum size of a NetBIOS name status response (57 bytes header + 255 names x 18 bytes + statistics). - /// - private const int ResponseBufferSize = 8192; - public static async Task ResolveAsync(IPAddress ipAddress, int timeout, CancellationToken cancellationToken) { try { - using var socket = new Socket(ipAddress.AddressFamily, SocketType.Dgram, ProtocolType.Udp); - - // Closing the socket on timeout or cancellation aborts a pending send / receive, it completes with - // OperationAborted. The results are reported as SocketError, no exception if the host doesn't - // answer (see SocketOperation). - using var timeoutCts = new CancellationTokenSource(timeout); - using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); - await using var registration = linkedCts.Token.Register(socket.Dispose); + // Name status response: 57 bytes header + 255 names x 18 bytes + statistics (max. 8192 bytes) + var response = await UdpRequest.SendAsync(ipAddress, NetBIOSUdpPort, RequestData, timeout, + cancellationToken).ConfigureAwait(false); - using var sendArgs = new SocketAsyncEventArgs(); - sendArgs.RemoteEndPoint = new IPEndPoint(ipAddress, NetBIOSUdpPort); - sendArgs.SetBuffer(RequestData); - - if (await SocketOperation.RunAsync(sendArgs, socket.SendToAsync).ConfigureAwait(false) != - SocketError.Success) + // No answer (timeout), port unreachable, etc. + if (response == null) return new NetBIOSInfo(ipAddress); - var buffer = new byte[ResponseBufferSize]; - byte[] response; - - // Wait for the answer of the host - a datagram from another address (e.g. a stray or spoofed one) is - // ignored, until the timeout - while (true) - { - using var receiveArgs = new SocketAsyncEventArgs(); - receiveArgs.RemoteEndPoint = new IPEndPoint( - ipAddress.AddressFamily == AddressFamily.InterNetworkV6 ? IPAddress.IPv6Any : IPAddress.Any, 0); - receiveArgs.SetBuffer(buffer); - - // No answer (timeout), port unreachable, etc. - if (await SocketOperation.RunAsync(receiveArgs, socket.ReceiveFromAsync).ConfigureAwait(false) != - SocketError.Success) - return new NetBIOSInfo(ipAddress); - - if (receiveArgs.RemoteEndPoint is not IPEndPoint remoteEndPoint || - IPAddressHelper.CompareIPAddresses(remoteEndPoint.Address, ipAddress) != 0) - continue; - - response = buffer[..receiveArgs.BytesTransferred]; - break; - } - if (response.Length < ResponseBaseLen || response[ResponseTypePos] != ResponseTypeNbstat) return new NetBIOSInfo(ipAddress); // response was too short diff --git a/Source/NETworkManager.Models/Network/NetworkInterface.cs b/Source/NETworkManager.Models/Network/NetworkInterface.cs index 8481df1fa4..1c2072268c 100644 --- a/Source/NETworkManager.Models/Network/NetworkInterface.cs +++ b/Source/NETworkManager.Models/Network/NetworkInterface.cs @@ -232,6 +232,49 @@ public static List GetNetworkInterfaces() return listNetworkInterfaceInfo; } + /// + /// Gets the IPv4 subnets of the operational network interfaces (e.g. to scan the subnet of a specific network + /// interface), in the order of the network interfaces with the link-local subnet (169.254.0.0/16, no DHCP server + /// answered) at the end. Addresses without subnet mask and single addresses (/32, e.g. some VPN adapters) are + /// skipped, every subnet is listed once per network interface. + /// + /// Network interfaces, see . + /// Subnets with the network address in CIDR notation (e.g. "192.168.1.0/24"). + public static List GetLocalSubnets(IEnumerable networkInterfaces) + { + List subnets = []; + + foreach (var networkInterface in networkInterfaces.Where(x => x.IsOperational)) + { + foreach (var (ipAddress, subnetmask) in networkInterface.IPv4Address ?? []) + { + if (subnetmask == null) + continue; + + var cidr = Subnetmask.ConvertSubnetmaskToCidr(subnetmask); + + if (cidr is 0 or 32) + continue; + + // Network address = IP address AND subnet mask + var ipAddressBytes = ipAddress.GetAddressBytes(); + var subnetmaskBytes = subnetmask.GetAddressBytes(); + + for (var i = 0; i < ipAddressBytes.Length; i++) + ipAddressBytes[i] &= subnetmaskBytes[i]; + + var subnet = new LocalSubnetInfo(networkInterface.Name, $"{new IPAddress(ipAddressBytes)}/{cidr}", + ipAddressBytes[0] == 169 && ipAddressBytes[1] == 254); + + if (!subnets.Contains(subnet)) + subnets.Add(subnet); + } + } + + // Stable sort: the order of the network interfaces is kept + return [.. subnets.OrderBy(x => x.IsLinkLocal)]; + } + /// /// Asynchronously determines the local IP address that would be used to route traffic to the specified remote IP /// address. diff --git a/Source/NETworkManager.Models/Network/PortScanner.cs b/Source/NETworkManager.Models/Network/PortScanner.cs index 370ac31b5e..dc37b928ac 100644 --- a/Source/NETworkManager.Models/Network/PortScanner.cs +++ b/Source/NETworkManager.Models/Network/PortScanner.cs @@ -291,7 +291,8 @@ await Parallel.ForEachAsync(ports, portParallelOptions, async (port, portCt) => } } - if (_options.ShowAllResults || portState == PortState.Open) + if (_options.ShowAllResults || portState == PortState.Open || + _options.ReportPorts?.Contains((host.ipAddress, port)) == true) OnPortScanned(new PortScannerPortScannedArgs( new PortScannerPortInfo(host.ipAddress, hostname, port, PortLookup.LookupByPortAndProtocol(port), portState))); diff --git a/Source/NETworkManager.Models/Network/PortScannerOptions.cs b/Source/NETworkManager.Models/Network/PortScannerOptions.cs index ce447114d9..599c710263 100644 --- a/Source/NETworkManager.Models/Network/PortScannerOptions.cs +++ b/Source/NETworkManager.Models/Network/PortScannerOptions.cs @@ -63,4 +63,11 @@ public record PortScannerOptions /// Include closed ports in the result. /// public required bool ShowAllResults { get; init; } + + /// + /// Ports that are reported in any state, also if is disabled: the open ports of + /// the previous scan, so a port that is no longer open has a row with its state (see + /// ). Null for none. + /// + public System.Collections.Generic.IReadOnlySet<(System.Net.IPAddress IPAddress, int Port)> ReportPorts { get; init; } } diff --git a/Source/NETworkManager.Models/Network/PortScannerPortChange.cs b/Source/NETworkManager.Models/Network/PortScannerPortChange.cs new file mode 100644 index 0000000000..45758db1a9 --- /dev/null +++ b/Source/NETworkManager.Models/Network/PortScannerPortChange.cs @@ -0,0 +1,23 @@ +namespace NETworkManager.Models.Network; + +/// +/// Change of a port since the previous scan of the same hosts and ports in the same tab (see +/// ). +/// +public enum PortScannerPortChange +{ + /// + /// No change, or there is no previous scan to compare with. + /// + None, + + /// + /// The port is open now, it was not open in the previous scan. + /// + Opened, + + /// + /// The port was open in the previous scan, it is not open now (closed, timed out or the host was not scanned). + /// + Closed +} diff --git a/Source/NETworkManager.Models/Network/PortScannerPortInfo.cs b/Source/NETworkManager.Models/Network/PortScannerPortInfo.cs index c6eb3b7454..9ebf5fae44 100644 --- a/Source/NETworkManager.Models/Network/PortScannerPortInfo.cs +++ b/Source/NETworkManager.Models/Network/PortScannerPortInfo.cs @@ -39,4 +39,21 @@ public PortScannerPortInfo(IPAddress ipAddress, string hostname, int port, PortL /// hostname (e.g. a generic PTR record of a provider). /// public string HostAsString => string.IsNullOrEmpty(Hostname) ? IPAddress.ToString() : $"{Hostname} ({IPAddress})"; + + /// + /// Change since the previous scan of the same hosts and ports (set by the view model after the scan, see + /// ). + /// + public PortScannerPortChange Change + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } } \ No newline at end of file diff --git a/Source/NETworkManager.Models/Network/PortScannerScanComparer.cs b/Source/NETworkManager.Models/Network/PortScannerScanComparer.cs new file mode 100644 index 0000000000..0739677562 --- /dev/null +++ b/Source/NETworkManager.Models/Network/PortScannerScanComparer.cs @@ -0,0 +1,72 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net; + +namespace NETworkManager.Models.Network; + +/// +/// Result of . +/// +/// Ports that are open now and were not open in the previous scan. +/// Ports of the result that were open in the previous scan and are not open now. +/// +/// Ports that were open in the previous scan and are not in the result (e.g. the host didn't answer the ping +/// before the scan), with the hostname of the previous scan. +/// +public sealed record PortScannerComparison( + IReadOnlySet<(IPAddress IPAddress, int Port)> Opened, + IReadOnlySet<(IPAddress IPAddress, int Port)> Closed, + IReadOnlyList<(IPAddress IPAddress, int Port, string Hostname)> Missing); + +/// +/// Compares a port scan with the previous scan of the same hosts and ports: which ports are open now or no longer +/// open. Only the open ports of a scan are kept (closed ports may not be in the result). +/// +public static class PortScannerScanComparer +{ + /// + /// Creates the baseline of a completed scan: its open ports with the hostname. + /// + /// Ports of the result. + /// Open ports and their hostname. + public static Dictionary<(IPAddress IPAddress, int Port), string> CreateBaseline( + IEnumerable ports) + { + return ports.Where(x => x.State == PortState.Open) + .GroupBy(x => (x.IPAddress, x.Port)) + .ToDictionary(x => x.Key, x => x.First().Hostname ?? string.Empty); + } + + /// + /// Compares the result of a scan with the baseline of the previous one. + /// + /// Open ports of the previous scan (see ). + /// Ports of the current result. + /// Opened, closed and missing ports. + public static PortScannerComparison Compare(IReadOnlyDictionary<(IPAddress IPAddress, int Port), string> baseline, + IEnumerable ports) + { + var opened = new HashSet<(IPAddress, int)>(); + var closed = new HashSet<(IPAddress, int)>(); + var seen = new HashSet<(IPAddress, int)>(); + + foreach (var port in ports) + { + var key = (port.IPAddress, port.Port); + seen.Add(key); + + var wasOpen = baseline.ContainsKey(key); + + if (port.State == PortState.Open && !wasOpen) + opened.Add(key); + else if (port.State != PortState.Open && wasOpen) + closed.Add(key); + } + + var missing = baseline.Where(x => !seen.Contains(x.Key)) + .Select(x => (x.Key.IPAddress, x.Key.Port, x.Value)) + .ToList(); + + return new PortScannerComparison(opened, closed, missing); + } +} diff --git a/Source/NETworkManager.Models/Network/SNMPClient.cs b/Source/NETworkManager.Models/Network/SNMPClient.cs index cb42015148..66212b0d07 100644 --- a/Source/NETworkManager.Models/Network/SNMPClient.cs +++ b/Source/NETworkManager.Models/Network/SNMPClient.cs @@ -14,19 +14,6 @@ namespace NETworkManager.Models.Network; public sealed class SNMPClient { - #region Variables - - /// - /// List of known SNMPv3 error codes with their Object Identifier. - /// - private readonly Dictionary _snmpv3ErrorOIDs = new() - { - { new ObjectIdentifier("1.3.6.1.6.3.15.1.1.3.0"), SNMPV3ErrorCode.UnknownUserName }, - { new ObjectIdentifier("1.3.6.1.6.3.15.1.1.5.0"), SNMPV3ErrorCode.AuthenticationFailed } - }; - - #endregion - #region Events /// @@ -192,7 +179,7 @@ public void GetAsyncV3(IPAddress ipAddress, List oids, SNMPOptionsV3 opt if (response is ReportMessage) // Check for SNMPv3 error codes if (pdu.Variables.Count > 0 && - _snmpv3ErrorOIDs.TryGetValue(pdu.Variables[0].Id, out var errorCodeV3)) + SNMPV3Helper.TryGetErrorCode(pdu.Variables[0].Id, out var errorCodeV3)) { OnError(new SNMPErrorArgs(errorCodeV3)); @@ -359,7 +346,7 @@ public void WalkAsyncV3(IPAddress ipAddress, string oid, SNMPOptionsV3 options) { // Check for SNMPv3 error codes if (pdu.Variables.Count > 0 && - _snmpv3ErrorOIDs.TryGetValue(pdu.Variables[0].Id, out var errorCodeV3)) + SNMPV3Helper.TryGetErrorCode(pdu.Variables[0].Id, out var errorCodeV3)) { OnError(new SNMPErrorArgs(errorCodeV3)); @@ -393,7 +380,7 @@ public void WalkAsyncV3(IPAddress ipAddress, string oid, SNMPOptionsV3 options) if (response is ReportMessage) // Check for SNMPv3 error codes if (pdu.Variables.Count > 0 && - _snmpv3ErrorOIDs.TryGetValue(pdu.Variables[0].Id, out errorCodeV3)) + SNMPV3Helper.TryGetErrorCode(pdu.Variables[0].Id, out errorCodeV3)) { OnError(new SNMPErrorArgs(errorCodeV3)); @@ -537,7 +524,7 @@ public void SetAsyncV3(IPAddress ipAddress, string oid, string data, SNMPOptions if (response is ReportMessage) // Check for SNMPv3 error codes if (pdu.Variables.Count > 0 && - _snmpv3ErrorOIDs.TryGetValue(pdu.Variables[0].Id, out var errorCodeV3)) + SNMPV3Helper.TryGetErrorCode(pdu.Variables[0].Id, out var errorCodeV3)) { OnError(new SNMPErrorArgs(errorCodeV3)); @@ -568,91 +555,10 @@ public void SetAsyncV3(IPAddress ipAddress, string oid, string data, SNMPOptions /// Privacy provider. private static IPrivacyProvider GetPrivacyProvider(SNMPOptionsV3 options) { - return options.Security switch - { - // AuthPriv - SNMPV3Security.AuthPriv => GetPrivacyProvider(options.AuthProvider, options.Auth, options.PrivProvider, - options.Priv), - - // AuthNoPriv - SNMPV3Security.AuthNoPriv => GetPrivacyProvider(options.AuthProvider, options.Auth), - - // NoAuthNoPriv - _ => GetPrivacyProvider() - }; - } - - /// - /// Create the privacy provider with default values. - /// - /// Privacy provider. - private static IPrivacyProvider GetPrivacyProvider() - { - return new DefaultPrivacyProvider(DefaultAuthenticationProvider.Instance); - } - - /// - /// Create the privacy provider based on the given information's. - /// - /// Authentication provider to use. - /// Authentication password. - /// Privacy provider. - private static IPrivacyProvider GetPrivacyProvider(SNMPV3AuthenticationProvider authProvider, SecureString auth) - { - return new DefaultPrivacyProvider(GetAuthenticationProvider(authProvider, auth)); - } - - /// - /// Create the privacy provider based on the given information's. - /// - /// Authentication provider to use. - /// Authentication password. - /// Privacy provider to use. - /// Privacy password. - /// Privacy provider. - private static IPrivacyProvider GetPrivacyProvider(SNMPV3AuthenticationProvider authProvider, SecureString auth, - SNMPV3PrivacyProvider privProvider, SecureString priv) - { - var privPlain = SecureStringHelper.ConvertToString(priv); - - return privProvider switch - { -#pragma warning disable CS0618 // Allow outdated algorithms. We provide the function also for old devices. The user should use newer algorithms... - SNMPV3PrivacyProvider.DES => new DESPrivacyProvider(new OctetString(privPlain), - GetAuthenticationProvider(authProvider, auth)), -#pragma warning restore CS0618 // Allow outdated algorithms. We provide the function also for old devices. The user should use newer algorithms... - SNMPV3PrivacyProvider.AES => new AESPrivacyProvider(new OctetString(privPlain), - GetAuthenticationProvider(authProvider, auth)), - SNMPV3PrivacyProvider.AES192 => new AES192PrivacyProvider(new OctetString(privPlain), - GetAuthenticationProvider(authProvider, auth)), - SNMPV3PrivacyProvider.AES256 => new AES256PrivacyProvider(new OctetString(privPlain), - GetAuthenticationProvider(authProvider, auth)), - _ => null - }; - } - - /// - /// Create the authentication provider based on the given information's. - /// - /// Authentication provider to use. - /// Authentication password. - /// Authentication provider. - private static IAuthenticationProvider GetAuthenticationProvider(SNMPV3AuthenticationProvider authProvider, - SecureString auth) - { - var authPlain = SecureStringHelper.ConvertToString(auth); - - return authProvider switch - { -#pragma warning disable CS0618 // Allow outdated algorithms. We provide the function also for old devices. The user should use newer algorithms... - SNMPV3AuthenticationProvider.MD5 => new MD5AuthenticationProvider(new OctetString(authPlain)), - SNMPV3AuthenticationProvider.SHA1 => new SHA1AuthenticationProvider(new OctetString(authPlain)), -#pragma warning restore CS0618 // Allow outdated algorithms. We provide the function also for old devices. The user should use newer algorithms... - SNMPV3AuthenticationProvider.SHA256 => new SHA256AuthenticationProvider(new OctetString(authPlain)), - SNMPV3AuthenticationProvider.SHA384 => new SHA384AuthenticationProvider(new OctetString(authPlain)), - SNMPV3AuthenticationProvider.SHA512 => new SHA512AuthenticationProvider(new OctetString(authPlain)), - _ => null - }; + return SNMPV3Helper.CreatePrivacyProvider(options.Security, options.AuthProvider, + options.Auth != null ? SecureStringHelper.ConvertToString(options.Auth) : string.Empty, + options.PrivProvider, + options.Priv != null ? SecureStringHelper.ConvertToString(options.Priv) : string.Empty); } #endregion diff --git a/Source/NETworkManager.Models/Network/SNMPCredentials.cs b/Source/NETworkManager.Models/Network/SNMPCredentials.cs new file mode 100644 index 0000000000..1012f18db5 --- /dev/null +++ b/Source/NETworkManager.Models/Network/SNMPCredentials.cs @@ -0,0 +1,72 @@ +using System.Text; + +namespace NETworkManager.Models.Network; + +/// +/// Version and credentials of an SNMP request of the IP Scanner (see ): the +/// community for v1 / v2c, the user (security level, authentication and privacy) for v3. Plain text only for the +/// duration of a scan (a tab keeps the ones of a profile / group as ); +/// doesn't contain the secrets. +/// +/// SNMP version. +public sealed record SNMPCredentials(SNMPVersion Version) +{ + /// + /// Community (v1 / v2c), e.g. "public". + /// + public string Community { get; init; } = string.Empty; + + /// + /// Security level (v3). + /// + public SNMPV3Security Security { get; init; } = SNMPV3Security.AuthPriv; + + /// + /// Username (v3). + /// + public string Username { get; init; } = string.Empty; + + /// + /// Authentication provider (v3, AuthNoPriv and AuthPriv). + /// + public SNMPV3AuthenticationProvider AuthenticationProvider { get; init; } = SNMPV3AuthenticationProvider.SHA1; + + /// + /// Authentication password (v3, AuthNoPriv and AuthPriv). + /// + public string Auth { get; init; } = string.Empty; + + /// + /// Privacy provider (v3, AuthPriv). + /// + public SNMPV3PrivacyProvider PrivacyProvider { get; init; } = SNMPV3PrivacyProvider.AES; + + /// + /// Privacy password (v3, AuthPriv). + /// + public string Priv { get; init; } = string.Empty; + + /// + /// Credentials of v1 / v2c with a community. + /// + /// SNMP version ( or ). + /// Community, e.g. "public". + /// Credentials. + public static SNMPCredentials FromCommunity(SNMPVersion version, string community) + { + return new SNMPCredentials(version) { Community = community ?? string.Empty }; + } + + /// + /// Only the version and the user (e.g. in a log), never the community or the passwords. + /// + private bool PrintMembers(StringBuilder builder) + { + builder.Append($"Version = {Version}"); + + if (Version == SNMPVersion.V3) + builder.Append($", Security = {Security}, Username = {Username}"); + + return true; + } +} diff --git a/Source/NETworkManager.Models/Network/SNMPSecureCredentials.cs b/Source/NETworkManager.Models/Network/SNMPSecureCredentials.cs new file mode 100644 index 0000000000..e01731e2c3 --- /dev/null +++ b/Source/NETworkManager.Models/Network/SNMPSecureCredentials.cs @@ -0,0 +1,84 @@ +using System.Security; +using NETworkManager.Utilities; + +namespace NETworkManager.Models.Network; + +/// +/// SNMP version and credentials of a profile / group as kept by an IP Scanner tab: the community and the passwords +/// protected (), converted to plain text () only for the SNMP +/// request of a scan. +/// +/// SNMP version. +public sealed record SNMPSecureCredentials(SNMPVersion Version) +{ + /// + /// Community (v1 / v2c). + /// + public SecureString Community { get; init; } + + /// + /// Security level (v3). + /// + public SNMPV3Security Security { get; init; } = SNMPV3Security.AuthPriv; + + /// + /// Username (v3). + /// + public string Username { get; init; } = string.Empty; + + /// + /// Authentication provider (v3). + /// + public SNMPV3AuthenticationProvider AuthenticationProvider { get; init; } = SNMPV3AuthenticationProvider.SHA1; + + /// + /// Authentication password (v3). + /// + public SecureString Auth { get; init; } + + /// + /// Privacy provider (v3). + /// + public SNMPV3PrivacyProvider PrivacyProvider { get; init; } = SNMPV3PrivacyProvider.AES; + + /// + /// Privacy password (v3). + /// + public SecureString Priv { get; init; } + + /// + /// Converts the credentials to plain text for the SNMP request of a scan. + /// + /// Credentials of the scan. + public SNMPCredentials ToCredentials() + { + return new SNMPCredentials(Version) + { + Community = ToString(Community), + Security = Security, + Username = Username ?? string.Empty, + AuthenticationProvider = AuthenticationProvider, + Auth = ToString(Auth), + PrivacyProvider = PrivacyProvider, + Priv = ToString(Priv) + }; + } + + /// + /// Only the version and the user (e.g. in a log), never the community or the passwords. + /// + private bool PrintMembers(System.Text.StringBuilder builder) + { + builder.Append($"Version = {Version}"); + + if (Version == SNMPVersion.V3) + builder.Append($", Security = {Security}, Username = {Username}"); + + return true; + } + + private static string ToString(SecureString value) + { + return value == null ? string.Empty : SecureStringHelper.ConvertToString(value); + } +} diff --git a/Source/NETworkManager.Models/Network/SNMPSystemInfo.cs b/Source/NETworkManager.Models/Network/SNMPSystemInfo.cs new file mode 100644 index 0000000000..2535db64b3 --- /dev/null +++ b/Source/NETworkManager.Models/Network/SNMPSystemInfo.cs @@ -0,0 +1,17 @@ +namespace NETworkManager.Models.Network; + +/// +/// System information of a host read via SNMP, see . +/// +/// Name of the host (sysName). +/// Description of the host, e.g. model and firmware (sysDescr). +/// Location of the host (sysLocation). +/// +/// SNMPv3 error of the report of the host (e.g. unknown user, wrong password - the host answered, the values are +/// empty), otherwise. +/// +public sealed record SNMPSystemInfo( + string Name, + string Description, + string Location, + SNMPV3ErrorCode Error = SNMPV3ErrorCode.None); diff --git a/Source/NETworkManager.Models/Network/SNMPSystemInfoResolver.cs b/Source/NETworkManager.Models/Network/SNMPSystemInfoResolver.cs new file mode 100644 index 0000000000..3a1e2a3779 --- /dev/null +++ b/Source/NETworkManager.Models/Network/SNMPSystemInfoResolver.cs @@ -0,0 +1,282 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using Lextm.SharpSnmpLib; +using Lextm.SharpSnmpLib.Messaging; +using Lextm.SharpSnmpLib.Security; + +namespace NETworkManager.Models.Network; + +/// +/// Reads the system information (sysName, sysDescr, sysLocation of the MIB-II system group) of a host with an SNMP +/// GET request to UDP port 161. v1 / v2c: a host with another community usually doesn't answer at all (timeout). +/// v3: a discovery request first (any SNMPv3 agent answers it with a report, also without valid credentials), then +/// the GET request with the user - wrong credentials are answered with a report (see +/// ). +/// +public static class SNMPSystemInfoResolver +{ + private const int SNMPPort = 161; + + /// + /// OID of sysName. + /// + public const string SysNameOID = "1.3.6.1.2.1.1.5.0"; + + /// + /// OID of sysDescr. + /// + public const string SysDescrOID = "1.3.6.1.2.1.1.1.0"; + + /// + /// OID of sysLocation. + /// + public const string SysLocationOID = "1.3.6.1.2.1.1.6.0"; + + /// + /// Reads the system information of a host. + /// + /// IP address of the host. + /// Version and credentials. + /// Timeout in milliseconds to wait for an answer (v3: per request). + /// Token to monitor for cancellation requests. + /// System information, null if the host didn't answer or the answer is invalid. + public static async Task ResolveAsync(IPAddress ipAddress, SNMPCredentials credentials, + int timeout, CancellationToken cancellationToken) + { + if (credentials.Version == SNMPVersion.V3) + return await ResolveV3Async(ipAddress, credentials, timeout, cancellationToken).ConfigureAwait(false); + + try + { + var requestId = Messenger.NextRequestId; + + var request = CreateRequest(requestId, credentials.Version, credentials.Community); + + var response = await UdpRequest.SendAsync(ipAddress, SNMPPort, request.ToBytes(), timeout, + cancellationToken).ConfigureAwait(false); + + return response == null ? null : ParseResponse(response, requestId); + } + catch (Exception) + { + return null; + } + } + + /// + /// Reads the system information with SNMPv3: discovery (engine ID, boots and time of the agent), then the GET + /// request with the user. + /// + private static async Task ResolveV3Async(IPAddress ipAddress, SNMPCredentials credentials, + int timeout, CancellationToken cancellationToken) + { + try + { + var discoveryMessageId = Messenger.NextMessageId; + + var discovery = new Discovery(discoveryMessageId, Messenger.NextRequestId, Messenger.MaxMessageSize); + + var discoveryResponse = await UdpRequest.SendAsync(ipAddress, SNMPPort, discovery.ToBytes(), timeout, + cancellationToken).ConfigureAwait(false); + + if (discoveryResponse == null) + return null; + + var report = ParseDiscoveryResponse(discoveryResponse, discoveryMessageId); + + // Not an SNMPv3 agent (e.g. only v1 / v2c, or another service on the port) + if (report == null) + return null; + + var privacy = SNMPV3Helper.CreatePrivacyProvider(credentials.Security, + credentials.AuthenticationProvider, credentials.Auth, credentials.PrivacyProvider, credentials.Priv); + + var messageId = Messenger.NextMessageId; + var requestId = Messenger.NextRequestId; + + var request = CreateRequestV3(messageId, requestId, credentials.Username, privacy, report); + + var response = await UdpRequest.SendAsync(ipAddress, SNMPPort, request.ToBytes(), timeout, + cancellationToken).ConfigureAwait(false); + + // The agent answered the discovery (it is reachable), but not the request (e.g. a wrong privacy password + // can't be decrypted - some agents don't report it) + if (response == null) + return new SNMPSystemInfo(string.Empty, string.Empty, string.Empty); + + return ParseResponseV3(response, messageId, requestId, + new UserRegistry([new User(new OctetString(credentials.Username ?? string.Empty), privacy)])) ?? + new SNMPSystemInfo(string.Empty, string.Empty, string.Empty); + } + catch (Exception) + { + return null; + } + } + + /// + /// Gets the report of the answer to the discovery request (v3). + /// + /// SNMP message of the answer. + /// Message ID of the discovery request (repeated in the report). + /// Report (with the engine ID, boots and time of the agent), null if the answer is invalid. + public static ReportMessage ParseDiscoveryResponse(byte[] response, int messageId) + { + try + { + return MessageFactory.ParseMessages(response, new UserRegistry()).FirstOrDefault() is ReportMessage report && + report.MessageId() == messageId + ? report + : null; + } + catch (Exception) + { + return null; + } + } + + /// + /// Creates the SNMPv3 GET request for sysName, sysDescr and sysLocation. + /// + /// Message ID (repeated in the answer and in a report). + /// Request ID (repeated in the answer). + /// Username. + /// Privacy provider of the credentials, see . + /// Report of the discovery (engine ID, boots and time of the agent). + /// SNMP message. + public static GetRequestMessage CreateRequestV3(int messageId, int requestId, string username, + IPrivacyProvider privacy, ISnmpMessage report) + { + return new GetRequestMessage(VersionCode.V3, messageId, requestId, new OctetString(username ?? string.Empty), + OctetString.Empty, CreateVariables(), privacy, Messenger.MaxMessageSize, report); + } + + /// + /// Gets the system information from the answer to . + /// + /// SNMP message of the answer. + /// Message ID of the request. + /// Request ID of the request. + /// User of the request (authentication and decryption of the answer). + /// + /// System information; of a report (e.g. unknown user, wrong password) empty values with the error. Null if the + /// answer is invalid or for another request. + /// + public static SNMPSystemInfo ParseResponseV3(byte[] response, int messageId, int requestId, UserRegistry registry) + { + ISnmpMessage message; + + try + { + message = MessageFactory.ParseMessages(response, registry).FirstOrDefault(); + } + catch (Exception) + { + return null; + } + + if (message == null || message.MessageId() != messageId) + return null; + + // Wrong credentials: the agent answered with a report (usmStats), not with the values + if (message is ReportMessage) + { + var variables = message.Pdu().Variables; + + return new SNMPSystemInfo(string.Empty, string.Empty, string.Empty, + variables.Count > 0 && SNMPV3Helper.TryGetErrorCode(variables[0].Id, out var errorCode) + ? errorCode + : SNMPV3ErrorCode.None); + } + + return message is ResponseMessage && message.RequestId() == requestId ? GetSystemInfo(message) : null; + } + + /// + /// Creates the GET request for sysName, sysDescr and sysLocation. + /// + /// ID of the request (repeated in the answer). + /// SNMP version ( or ). + /// Community (e.g. "public"). + /// SNMP message. + public static GetRequestMessage CreateRequest(int requestId, SNMPVersion version, string community) + { + return new GetRequestMessage(requestId, version == SNMPVersion.V1 ? VersionCode.V1 : VersionCode.V2, + new OctetString(community ?? string.Empty), CreateVariables()); + } + + /// + /// Variables of the request: sysName, sysDescr and sysLocation. + /// + private static List CreateVariables() + { + return + [ + new(new ObjectIdentifier(SysNameOID)), + new(new ObjectIdentifier(SysDescrOID)), + new(new ObjectIdentifier(SysLocationOID)) + ]; + } + + /// + /// Gets the system information from the answer to . + /// + /// SNMP message of the answer. + /// ID of the request. + /// + /// System information (empty values if the host answered with an error, e.g. noSuchName of v1 - it is reachable), + /// null if the answer is invalid or for another request. + /// + public static SNMPSystemInfo ParseResponse(byte[] response, int requestId) + { + ISnmpMessage message; + + try + { + message = MessageFactory.ParseMessages(response, new UserRegistry()).FirstOrDefault(); + } + catch (Exception) + { + return null; + } + + if (message is not ResponseMessage || message.RequestId() != requestId) + return null; + + return GetSystemInfo(message); + } + + /// + /// Gets the system information of a response (empty values if the host answered with an error, e.g. noSuchName + /// of v1 - it is reachable). + /// + private static SNMPSystemInfo GetSystemInfo(ISnmpMessage message) + { + if (message.Pdu().ErrorStatus.ToInt32() != 0) + return new SNMPSystemInfo(string.Empty, string.Empty, string.Empty); + + var values = message.Variables().ToDictionary(x => x.Id.ToString(), x => GetValue(x.Data)); + + return new SNMPSystemInfo(values.GetValueOrDefault(SysNameOID, string.Empty), + values.GetValueOrDefault(SysDescrOID, string.Empty), + values.GetValueOrDefault(SysLocationOID, string.Empty)); + } + + /// + /// Gets the value of a variable as text, empty for "no such object / instance" (v2c) and non-text values. + /// + private static string GetValue(ISnmpData data) + { + if (data is not OctetString octetString) + return string.Empty; + + // Control characters (e.g. line breaks in sysDescr) are shown as spaces, several spaces as one + var value = new string([.. octetString.ToString().Select(x => char.IsControl(x) ? ' ' : x)]); + + return string.Join(' ', value.Split(' ', StringSplitOptions.RemoveEmptyEntries)); + } +} diff --git a/Source/NETworkManager.Models/Network/SNMPV3Helper.cs b/Source/NETworkManager.Models/Network/SNMPV3Helper.cs new file mode 100644 index 0000000000..90fb4f3d88 --- /dev/null +++ b/Source/NETworkManager.Models/Network/SNMPV3Helper.cs @@ -0,0 +1,99 @@ +using System.Collections.Generic; +using Lextm.SharpSnmpLib; +using Lextm.SharpSnmpLib.Security; + +namespace NETworkManager.Models.Network; + +/// +/// Methods for SNMPv3 (SNMP tool, SNMP of the IP Scanner): the privacy provider of the credentials and the error codes +/// of a report. +/// +public static class SNMPV3Helper +{ + /// + /// Known SNMPv3 error codes (usmStats of a report) with their Object Identifier. + /// + private static readonly Dictionary ErrorOIDs = new() + { + { new ObjectIdentifier("1.3.6.1.6.3.15.1.1.3.0"), SNMPV3ErrorCode.UnknownUserName }, + { new ObjectIdentifier("1.3.6.1.6.3.15.1.1.5.0"), SNMPV3ErrorCode.AuthenticationFailed } + }; + + /// + /// Gets the SNMPv3 error code of a variable of a report (e.g. usmStatsUnknownUserNames). + /// + /// Object Identifier of the variable. + /// Error code, if unknown. + /// True if the error code is known. + public static bool TryGetErrorCode(ObjectIdentifier oid, out SNMPV3ErrorCode errorCode) + { + if (ErrorOIDs.TryGetValue(oid, out errorCode)) + return true; + + errorCode = SNMPV3ErrorCode.None; + + return false; + } + + /// + /// Creates the privacy provider (incl. the authentication provider) of SNMPv3 credentials. + /// + /// Security level. + /// Authentication provider (AuthNoPriv, AuthPriv). + /// Authentication password (AuthNoPriv, AuthPriv). + /// Privacy provider (AuthPriv). + /// Privacy password (AuthPriv). + /// Privacy provider. + public static IPrivacyProvider CreatePrivacyProvider(SNMPV3Security security, + SNMPV3AuthenticationProvider authProvider, string auth, SNMPV3PrivacyProvider privProvider, string priv) + { + return security switch + { + SNMPV3Security.AuthPriv => CreatePrivacyProvider(privProvider, priv, + CreateAuthenticationProvider(authProvider, auth)), + SNMPV3Security.AuthNoPriv => new DefaultPrivacyProvider(CreateAuthenticationProvider(authProvider, auth)), + _ => new DefaultPrivacyProvider(DefaultAuthenticationProvider.Instance) + }; + } + + /// + /// Creates the privacy provider of a privacy password. + /// + private static IPrivacyProvider CreatePrivacyProvider(SNMPV3PrivacyProvider privProvider, string priv, + IAuthenticationProvider authenticationProvider) + { + var privacyPassword = new OctetString(priv ?? string.Empty); + + return privProvider switch + { +#pragma warning disable CS0618 // Allow outdated algorithms. We provide the function also for old devices. The user should use newer algorithms... + SNMPV3PrivacyProvider.DES => new DESPrivacyProvider(privacyPassword, authenticationProvider), +#pragma warning restore CS0618 // Allow outdated algorithms. We provide the function also for old devices. The user should use newer algorithms... + SNMPV3PrivacyProvider.AES => new AESPrivacyProvider(privacyPassword, authenticationProvider), + SNMPV3PrivacyProvider.AES192 => new AES192PrivacyProvider(privacyPassword, authenticationProvider), + SNMPV3PrivacyProvider.AES256 => new AES256PrivacyProvider(privacyPassword, authenticationProvider), + _ => null + }; + } + + /// + /// Creates the authentication provider of an authentication password. + /// + private static IAuthenticationProvider CreateAuthenticationProvider(SNMPV3AuthenticationProvider authProvider, + string auth) + { + var authenticationPassword = new OctetString(auth ?? string.Empty); + + return authProvider switch + { +#pragma warning disable CS0618 // Allow outdated algorithms. We provide the function also for old devices. The user should use newer algorithms... + SNMPV3AuthenticationProvider.MD5 => new MD5AuthenticationProvider(authenticationPassword), + SNMPV3AuthenticationProvider.SHA1 => new SHA1AuthenticationProvider(authenticationPassword), +#pragma warning restore CS0618 // Allow outdated algorithms. We provide the function also for old devices. The user should use newer algorithms... + SNMPV3AuthenticationProvider.SHA256 => new SHA256AuthenticationProvider(authenticationPassword), + SNMPV3AuthenticationProvider.SHA384 => new SHA384AuthenticationProvider(authenticationPassword), + SNMPV3AuthenticationProvider.SHA512 => new SHA512AuthenticationProvider(authenticationPassword), + _ => null + }; + } +} diff --git a/Source/NETworkManager.Models/Network/UdpRequest.cs b/Source/NETworkManager.Models/Network/UdpRequest.cs new file mode 100644 index 0000000000..d25a11d812 --- /dev/null +++ b/Source/NETworkManager.Models/Network/UdpRequest.cs @@ -0,0 +1,72 @@ +using System.Net; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +using NETworkManager.Utilities; + +namespace NETworkManager.Models.Network; + +/// +/// Sends a UDP request to a host and waits for its answer (e.g. NetBIOS, mDNS). No exception if the host doesn't +/// answer, see . +/// +public static class UdpRequest +{ + /// + /// Maximum size of an answer. + /// + private const int ResponseBufferSize = 8192; + + /// + /// Sends to of the host and returns the first datagram + /// the host answers with. Datagrams from other addresses (e.g. a stray or spoofed one) are ignored. + /// + /// IP address of the host. + /// UDP port of the host. + /// Data to send. + /// Timeout in milliseconds to wait for the answer. + /// Token to monitor for cancellation requests. + /// Answer of the host, null if it didn't answer within the timeout (or port unreachable, etc.). + public static async Task SendAsync(IPAddress ipAddress, int port, byte[] request, int timeout, + CancellationToken cancellationToken) + { + using var socket = new Socket(ipAddress.AddressFamily, SocketType.Dgram, ProtocolType.Udp); + + // Closing the socket on timeout or cancellation aborts a pending send / receive, it completes with + // OperationAborted. The results are reported as SocketError, no exception if the host doesn't + // answer (see SocketOperation). + using var timeoutCts = new CancellationTokenSource(timeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); + await using var registration = linkedCts.Token.Register(socket.Dispose); + + using var sendArgs = new SocketAsyncEventArgs(); + sendArgs.RemoteEndPoint = new IPEndPoint(ipAddress, port); + sendArgs.SetBuffer(request); + + if (await SocketOperation.RunAsync(sendArgs, socket.SendToAsync).ConfigureAwait(false) != SocketError.Success) + return null; + + var buffer = new byte[ResponseBufferSize]; + + // Wait for the answer of the host - a datagram from another address (e.g. a stray or spoofed one) is + // ignored, until the timeout + while (true) + { + using var receiveArgs = new SocketAsyncEventArgs(); + receiveArgs.RemoteEndPoint = new IPEndPoint( + ipAddress.AddressFamily == AddressFamily.InterNetworkV6 ? IPAddress.IPv6Any : IPAddress.Any, 0); + receiveArgs.SetBuffer(buffer); + + // No answer (timeout), port unreachable, etc. + if (await SocketOperation.RunAsync(receiveArgs, socket.ReceiveFromAsync).ConfigureAwait(false) != + SocketError.Success) + return null; + + if (receiveArgs.RemoteEndPoint is not IPEndPoint remoteEndPoint || + IPAddressHelper.CompareIPAddresses(remoteEndPoint.Address, ipAddress) != 0) + continue; + + return buffer[..receiveArgs.BytesTransferred]; + } + } +} diff --git a/Source/NETworkManager.Models/Network/WebCertificateInfo.cs b/Source/NETworkManager.Models/Network/WebCertificateInfo.cs new file mode 100644 index 0000000000..40ba3ab10e --- /dev/null +++ b/Source/NETworkManager.Models/Network/WebCertificateInfo.cs @@ -0,0 +1,34 @@ +using System; +using System.Linq; +using System.Security.Cryptography.X509Certificates; + +namespace NETworkManager.Models.Network; + +/// +/// TLS certificate of a web page, see . +/// +/// Name of the subject (common name, e.g. "FRITZ!Box"). +/// Name of the issuer (common name, e.g. "Let's Encrypt R11"). +/// End of the validity (UTC). +/// The certificate is self-signed (subject is the issuer). +public sealed record WebCertificateInfo(string Subject, string Issuer, DateTime NotAfter, bool IsSelfSigned) +{ + /// + /// Indicates whether the certificate is expired. + /// + public bool IsExpired => NotAfter < DateTime.UtcNow; + + /// + /// Creates the information of a certificate (copies the values, the certificate can be disposed afterwards). + /// + /// Certificate of the web server. + /// Information of the certificate. + public static WebCertificateInfo FromCertificate(X509Certificate2 certificate) + { + return new WebCertificateInfo( + WebPageResolver.CleanHeaderValue(certificate.GetNameInfo(X509NameType.SimpleName, false)), + WebPageResolver.CleanHeaderValue(certificate.GetNameInfo(X509NameType.SimpleName, true)), + certificate.NotAfter.ToUniversalTime(), + certificate.SubjectName.RawData.SequenceEqual(certificate.IssuerName.RawData)); + } +} diff --git a/Source/NETworkManager.Models/Network/WebConsoleUrlHelper.cs b/Source/NETworkManager.Models/Network/WebConsoleUrlHelper.cs index a82ddb1e71..fcf1aa3931 100644 --- a/Source/NETworkManager.Models/Network/WebConsoleUrlHelper.cs +++ b/Source/NETworkManager.Models/Network/WebConsoleUrlHelper.cs @@ -54,6 +54,19 @@ public static string CreateUrl(string host, IEnumerable openPorts) /// Port number. /// URL. public static string CreateUrl(string host, int port) + { + return CreateUrl(host, port, port is 443 or 8443 ? "https" : "http"); + } + + /// + /// Creates the URL for a host, a port and a scheme (e.g. of a web page that answered): the default port of the + /// scheme (80 / 443) is left out, IPv6 addresses in brackets, the trailing dot of a hostname is removed. + /// + /// Hostname or IP address. + /// Port number. + /// "http" or "https". + /// URL. + public static string CreateUrl(string host, int port, string scheme) { // The browser resolves a hostname via Windows (e.g. "gateway." -> "gateway") if (!IPAddress.TryParse(host, out var ipAddress)) @@ -62,12 +75,12 @@ public static string CreateUrl(string host, int port) // Also a hostname of only dots ArgumentException.ThrowIfNullOrWhiteSpace(host); - var scheme = port is 443 or 8443 ? "https" : "http"; - // The zone id of an IPv6 address has to be encoded ("%" -> "%25", RFC 6874) if (ipAddress?.AddressFamily == AddressFamily.InterNetworkV6) host = $"[{ipAddress.ToString().Replace("%", "%25")}]"; - return port is 80 or 443 ? $"{scheme}://{host}/" : $"{scheme}://{host}:{port}/"; + var isDefaultPort = (scheme == "http" && port == 80) || (scheme == "https" && port == 443); + + return isDefaultPort ? $"{scheme}://{host}/" : $"{scheme}://{host}:{port}/"; } } diff --git a/Source/NETworkManager.Models/Network/WebPageInfo.cs b/Source/NETworkManager.Models/Network/WebPageInfo.cs new file mode 100644 index 0000000000..a97786f0c0 --- /dev/null +++ b/Source/NETworkManager.Models/Network/WebPageInfo.cs @@ -0,0 +1,33 @@ +namespace NETworkManager.Models.Network; + +/// +/// Web page of a host, see . +/// +/// +/// URL that was requested on the port that answered (e.g. "https://192.168.1.1/") - also after a redirect on the +/// same host, so the Web Console opens the port (and scheme) that answered; the other values are of the last +/// response. +/// +/// HTTP status code of the (last) response, e.g. 200. +/// Title of the web page, empty if it has none (e.g. a redirect to another host). +/// Value of the Server header (e.g. "nginx"), empty if there is none. +/// Value of the X-Powered-By header (e.g. "PHP/8.2"), empty if there is none. +/// +/// Realm of the WWW-Authenticate header (login prompt, e.g. "RT-AC68U"), empty if there is none. +/// +/// +/// Target of a redirect to another host (e.g. "https://unifi.local:8443/"), empty if there is none. +/// +/// +/// TLS certificate of the web server (of the last response), null for http (also after a redirect from https to +/// http). +/// +public sealed record WebPageInfo( + string URL, + int StatusCode, + string Title, + string Server, + string PoweredBy, + string Realm, + string RedirectURL, + WebCertificateInfo Certificate); diff --git a/Source/NETworkManager.Models/Network/WebPageResolver.cs b/Source/NETworkManager.Models/Network/WebPageResolver.cs new file mode 100644 index 0000000000..b339a88a6e --- /dev/null +++ b/Source/NETworkManager.Models/Network/WebPageResolver.cs @@ -0,0 +1,341 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; + +namespace NETworkManager.Models.Network; + +/// +/// Reads the title of the web page of a host (GET / on an open web port): no cookies, no credentials, no +/// proxy; certificate errors are accepted (only the title is read, nothing is sent). The request appears in the +/// log of the web server. +/// +public static partial class WebPageResolver +{ + /// + /// Maximum number of bytes read from the web page. + /// + private const int MaxContentLength = 64 * 1024; + + /// + /// Maximum length of the title. + /// + private const int MaxTitleLength = 200; + + /// + /// Maximum number of redirects followed (only on the same host). + /// + private const int MaxRedirects = 3; + + /// + /// Maximum timeout in milliseconds of the https attempt on a port where https is only guessed (not 443 / + /// 8443) and http is tried next: a plain http server that doesn't close the connection on the TLS handshake + /// (e.g. some embedded devices) would otherwise cost the whole timeout before http is tried. + /// + private const int MaxGuessedHttpsTimeout = 1000; + + /// + /// Key of the TLS certificate of the web server in the options of a request, set by the certificate callback of + /// the handler (it gets the request, unlike the callback of the SslStream). + /// + private static readonly HttpRequestOptionsKey CertificateKey = + new("NETworkManager.Certificate"); + + private static readonly HttpClient Client = new(new HttpClientHandler + { + AllowAutoRedirect = false, + UseCookies = false, + UseProxy = false, + Credentials = null, + AutomaticDecompression = DecompressionMethods.All, + // Devices use self-signed certificates - accepted (nothing is sent), the certificate is read + ServerCertificateCustomValidationCallback = (request, certificate, _, _) => + { + if (certificate != null) + request.Options.Set(CertificateKey, WebCertificateInfo.FromCertificate(certificate)); + + return true; + } + }) + { + // The timeout of each request is set with its cancellation token + Timeout = Timeout.InfiniteTimeSpan, + DefaultRequestHeaders = { { "User-Agent", "NETworkManager" } } + }; + + /// + /// Registers the code page encodings (e.g. windows-1252, iso-8859-2, shift_jis) for + /// - .NET only knows UTF-8 / UTF-16 / UTF-32, ASCII and ISO-8859-1 without them. + /// + static WebPageResolver() + { + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + } + + [GeneratedRegex(@"]*>(.*?)", RegexOptions.IgnoreCase | RegexOptions.Singleline)] + private static partial Regex TitleRegex(); + + [GeneratedRegex(@"]+charset\s*=\s*[""']?\s*([a-z0-9_\-]+)", RegexOptions.IgnoreCase)] + private static partial Regex MetaCharsetRegex(); + + [GeneratedRegex(@"\s+")] + private static partial Regex WhitespaceRegex(); + + [GeneratedRegex(@"realm\s*=\s*(?:""(?[^""]*)""|(?[^\s,]+))", RegexOptions.IgnoreCase)] + private static partial Regex RealmRegex(); + + /// + /// Reads the title of the web page of a host from the first port that answers. + /// + /// IP address of the host. + /// Open ports of the host to request, in the order to try them. + /// Timeout in milliseconds per request. + /// Token to monitor for cancellation requests. + /// URL and title, null if no port answered. + public static async Task ResolveAsync(IPAddress ipAddress, IEnumerable ports, + int timeout, CancellationToken cancellationToken) + { + foreach (var port in ports) + { + foreach (var scheme in GetSchemes(port)) + { + var url = WebConsoleUrlHelper.CreateUrl(ipAddress.ToString(), port, scheme); + + var info = await GetWebPageAsync(url, GetTimeout(port, scheme, timeout), cancellationToken) + .ConfigureAwait(false); + + if (info != null) + return info; + } + } + + return null; + } + + /// + /// Gets the timeout of a request: the timeout of the settings, at most + /// for https on a port where https is only guessed (not 443 / 8443, http + /// is tried next). + /// + /// Port number. + /// "http" or "https". + /// Timeout in milliseconds of the settings. + /// Timeout in milliseconds. + public static int GetTimeout(int port, string scheme, int timeout) + { + return scheme == "https" && port is not (443 or 8443) ? Math.Min(timeout, MaxGuessedHttpsTimeout) : timeout; + } + + /// + /// Gets the schemes to try for a port: https for 443 / 8443 (if the TLS handshake fails, e.g. a device that + /// only supports TLS 1.0, http would only return the error page of the server for plain http on an https port), + /// http for 80 / 8080, https and then http for any other port (a TLS handshake with a plain http server fails + /// right away). + /// + /// Port number. + /// Schemes in the order to try them. + public static string[] GetSchemes(int port) + { + return port switch + { + 443 or 8443 => ["https"], + 80 or 8080 => ["http"], + _ => ["https", "http"] + }; + } + + /// + /// Requests the web page and reads its title and headers, follows redirects on the same host. + /// + /// URL of the web page. + /// Timeout in milliseconds per request. + /// Token to monitor for cancellation requests. + /// Web page (title, status code, headers), null if the host didn't answer. + private static async Task GetWebPageAsync(string url, int timeout, + CancellationToken cancellationToken) + { + try + { + // In the try: an URL that can't be parsed only means no web page for this host, not the end of the scan + var uri = new Uri(url); + + for (var redirects = 0; ; redirects++) + { + using var timeoutCts = new CancellationTokenSource(timeout); + using var linkedCts = + CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); + + // A new connection per request (no pooled one), so the certificate callback runs for every request + using var request = new HttpRequestMessage(HttpMethod.Get, uri); + request.Headers.ConnectionClose = true; + + using var response = await Client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, + linkedCts.Token).ConfigureAwait(false); + + // Certificate of this response (e.g. after a redirect from http to https), null for http (also after a + // redirect from https to http) + var certificate = request.Options.TryGetValue(CertificateKey, out var requestCertificate) + ? requestCertificate + : null; + + var statusCode = (int)response.StatusCode; + var server = CleanHeaderValue(response.Headers.Server.ToString()); + var poweredBy = CleanHeaderValue(response.Headers.TryGetValues("X-Powered-By", out var values) + ? string.Join(", ", values) + : string.Empty); + + // Login prompt (401) of routers, cameras, printers - the realm often names the model + var realm = ParseRealm(response.Headers.WwwAuthenticate.Select(x => x.ToString())); + + // Redirect on the same host (e.g. http -> https, / -> /login.html) + if ((int)response.StatusCode is >= 300 and < 400 && response.Headers.Location != null && + redirects < MaxRedirects) + { + var location = response.Headers.Location.IsAbsoluteUri + ? response.Headers.Location + : new Uri(uri, response.Headers.Location); + + if (location.Scheme is "http" or "https" && + string.Equals(location.Host, uri.Host, StringComparison.OrdinalIgnoreCase)) + { + uri = location; + continue; + } + + // Redirect to another host - only the headers and the target + return new WebPageInfo(url, statusCode, string.Empty, server, poweredBy, realm, + CleanHeaderValue(location.ToString()), certificate); + } + + var content = await ReadContentAsync(response, linkedCts.Token).ConfigureAwait(false); + + var title = ParseTitle(Decode(content, response.Content.Headers.ContentType?.CharSet)); + + return new WebPageInfo(url, statusCode, title, server, poweredBy, realm, string.Empty, certificate); + } + } + catch (Exception) when (!cancellationToken.IsCancellationRequested) + { + // Connection refused / reset, TLS error, timeout, invalid response, ... + return null; + } + } + + /// + /// Gets the realm of the WWW-Authenticate header(s) of a response (e.g. Basic realm="RT-AC68U", + /// Digest realm="IP Camera", nonce="..."). + /// + /// Values of the WWW-Authenticate header(s). + /// First realm (cleaned, see ), empty if there is none. + public static string ParseRealm(IEnumerable headerValues) + { + foreach (var headerValue in headerValues ?? []) + { + var match = RealmRegex().Match(headerValue ?? string.Empty); + + if (!match.Success) + continue; + + var realm = CleanHeaderValue(match.Groups["quoted"].Success + ? match.Groups["quoted"].Value + : match.Groups["token"].Value); + + if (!string.IsNullOrEmpty(realm)) + return realm; + } + + return string.Empty; + } + + /// + /// Cleans a header value received from the host for display: control characters removed, whitespace collapsed, + /// at most characters. + /// + /// Header value. + /// Cleaned value. + public static string CleanHeaderValue(string value) + { + var withoutControlCharacters = new string([.. (value ?? string.Empty).Where(x => !char.IsControl(x))]); + var cleaned = WhitespaceRegex().Replace(withoutControlCharacters, " ").Trim(); + + return cleaned.Length > MaxTitleLength ? cleaned[..MaxTitleLength] + "..." : cleaned; + } + + /// + /// Reads the content of the response, at most bytes. + /// + private static async Task ReadContentAsync(HttpResponseMessage response, + CancellationToken cancellationToken) + { + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + + var buffer = new byte[MaxContentLength]; + var length = 0; + + while (length < buffer.Length) + { + var read = await stream.ReadAsync(buffer.AsMemory(length), cancellationToken).ConfigureAwait(false); + + if (read == 0) + break; + + length += read; + } + + return buffer[..length]; + } + + /// + /// Decodes the content of a web page: charset of the Content-Type header, of a meta tag or + /// UTF-8. + /// + /// Content of the web page. + /// Charset of the Content-Type header, null if there is none. + /// Decoded content. + public static string Decode(byte[] content, string charset) + { + // The meta tag is ASCII in all common charsets + charset ??= MetaCharsetRegex().Match(Encoding.ASCII.GetString(content)) is { Success: true } match + ? match.Groups[1].Value + : null; + + Encoding encoding; + + try + { + encoding = string.IsNullOrEmpty(charset) ? Encoding.UTF8 : Encoding.GetEncoding(charset.Trim('"')); + } + catch (ArgumentException) + { + encoding = Encoding.UTF8; + } + + return encoding.GetString(content); + } + + /// + /// Gets the title of a web page: content of the first title element, HTML entities decoded, whitespace + /// collapsed, at most characters. + /// + /// HTML of the web page. + /// Title, empty if there is none. + public static string ParseTitle(string html) + { + var match = TitleRegex().Match(html ?? string.Empty); + + if (!match.Success) + return string.Empty; + + var title = WhitespaceRegex().Replace(WebUtility.HtmlDecode(match.Groups[1].Value), " ").Trim(); + + // Control characters (e.g. from a broken page) are not shown + title = new string([.. title.Where(x => !char.IsControl(x))]); + + return title.Length > MaxTitleLength ? title[..MaxTitleLength] + "..." : title; + } +} diff --git a/Source/NETworkManager.Models/NotificationStatus.cs b/Source/NETworkManager.Models/NotificationStatus.cs new file mode 100644 index 0000000000..7a46a1cbb8 --- /dev/null +++ b/Source/NETworkManager.Models/NotificationStatus.cs @@ -0,0 +1,56 @@ +namespace NETworkManager.Models; + +/// +/// Status of a notification (e.g. a host of the Ping Monitor, the changes of a repeated scan), shown by its icon, +/// color and sound. +/// +public enum NotificationStatus +{ + /// + /// Something is up / was added (host is up, new hosts, ports open now). + /// + Up, + + /// + /// Something is down / went away (host is down, hosts gone, ports no longer open). + /// + Down, + + /// + /// Both: something is up and something is down (e.g. a new and a gone host). + /// + UpAndDown, + + /// + /// Another change that is neither up nor down (e.g. a MAC address changed). + /// + Changed +} + +/// +/// Methods for . +/// +public static class NotificationStatusHelper +{ + /// + /// Gets the status of a notification about changes: up if there are only additions, down if there are only + /// removals, up and down if there are both, otherwise changed (e.g. only a changed MAC address). + /// + /// Number of additions (e.g. new hosts, ports open now). + /// Number of removals (e.g. gone hosts, ports no longer open). + /// Number of other changes (e.g. changed MAC addresses). + /// Status of the notification. + public static NotificationStatus FromChanges(int additions, int removals, int otherChanges) + { + if (additions > 0 && removals > 0) + return NotificationStatus.UpAndDown; + + if (otherChanges > 0) + return NotificationStatus.Changed; + + if (additions > 0) + return NotificationStatus.Up; + + return removals > 0 ? NotificationStatus.Down : NotificationStatus.Changed; + } +} diff --git a/Source/NETworkManager.Models/RunCommand/RunCommandTarget.cs b/Source/NETworkManager.Models/RunCommand/RunCommandTarget.cs index 4c32588196..aefdbfe945 100644 --- a/Source/NETworkManager.Models/RunCommand/RunCommandTarget.cs +++ b/Source/NETworkManager.Models/RunCommand/RunCommandTarget.cs @@ -2,6 +2,7 @@ using System.Linq; using System.Net; using System.Text.RegularExpressions; +using NETworkManager.Models.Network; using NETworkManager.Utilities; namespace NETworkManager.Models.RunCommand; @@ -53,13 +54,16 @@ public static RunCommandTargetKind GetKind(string target) if (RegexHelper.ASNumberWithPrefixRegex().IsMatch(target)) return RunCommandTargetKind.ASN; - // Several hosts, IP ranges or subnets + // Several hosts, IP ranges or subnets, optionally with exclusions (e.g. "10.0.0.0/24;!10.0.0.1") if (target.Contains(';')) { - var kinds = target.Split(';', StringSplitOptions.RemoveEmptyEntries).Select(GetKind).ToList(); + var entries = target.Split(';', StringSplitOptions.RemoveEmptyEntries); - if (kinds.Count == 0 || kinds.Any(x => x is not (RunCommandTargetKind.Host or RunCommandTargetKind.Hostname - or RunCommandTargetKind.IPRange or RunCommandTargetKind.Subnet))) + var kinds = entries.Where(x => !HostRangeHelper.IsExclusion(x)).Select(GetKind).ToList(); + var exclusionKinds = entries.Where(HostRangeHelper.IsExclusion).Select(x => GetKind(x[1..])).ToList(); + + if (kinds.Count == 0 || kinds.Concat(exclusionKinds).Any(x => x is not (RunCommandTargetKind.Host + or RunCommandTargetKind.Hostname or RunCommandTargetKind.IPRange or RunCommandTargetKind.Subnet))) return RunCommandTargetKind.None; // Only host names without a domain (e.g. "foo;" or "a;b" from copied text) can also be any words, like a @@ -67,8 +71,9 @@ public static RunCommandTargetKind GetKind(string target) if (kinds.All(x => x == RunCommandTargetKind.Hostname)) return RunCommandTargetKind.None; - // With an IP range or subnet, it is a range for the scanners (e.g. not for a DNS lookup). - return kinds.Any(x => x is RunCommandTargetKind.IPRange or RunCommandTargetKind.Subnet) + // With an IP range, subnet or exclusion, it is a range for the scanners (e.g. not for a DNS lookup). + return exclusionKinds.Count > 0 || + kinds.Any(x => x is RunCommandTargetKind.IPRange or RunCommandTargetKind.Subnet) ? RunCommandTargetKind.IPRange : RunCommandTargetKind.Hosts; } diff --git a/Source/NETworkManager.Profiles/Application/IPScanner.cs b/Source/NETworkManager.Profiles/Application/IPScanner.cs new file mode 100644 index 0000000000..d836a0f771 --- /dev/null +++ b/Source/NETworkManager.Profiles/Application/IPScanner.cs @@ -0,0 +1,63 @@ +using System.Security; +using NETworkManager.Models.Network; + +namespace NETworkManager.Profiles.Application; + +public static class IPScanner +{ + /// + /// Gets the SNMP version and credentials of a profile for the IP Scanner: its own (if overridden), otherwise the + /// ones of its group (if overridden), otherwise null (the settings are used). Protected, converted to plain text + /// only for the SNMP request of a scan (see ). + /// + /// Profile the tab is opened with. + /// Credentials, null if neither the profile nor its group overrides them. + public static SNMPSecureCredentials GetSNMPCredentials(ProfileInfo profile) + { + return GetSNMPCredentials(profile, ProfileManager.GetGroupByName(profile.Group)); + } + + /// + /// Gets the SNMP version and credentials of a profile and its group, see + /// . + /// + /// Profile. + /// Group of the profile, null if it can't be found. + /// Credentials, null if neither the profile nor its group overrides them. + public static SNMPSecureCredentials GetSNMPCredentials(ProfileInfo profile, GroupInfo group) + { + if (profile.IPScanner_OverrideSNMP) + return CreateCredentials(profile.IPScanner_SNMPVersion, profile.IPScanner_SNMPCommunity, + profile.IPScanner_SNMPSecurity, profile.IPScanner_SNMPUsername, + profile.IPScanner_SNMPAuthenticationProvider, profile.IPScanner_SNMPAuth, + profile.IPScanner_SNMPPrivacyProvider, profile.IPScanner_SNMPPriv); + + if (group is { IPScanner_OverrideSNMP: true }) + return CreateCredentials(group.IPScanner_SNMPVersion, group.IPScanner_SNMPCommunity, + group.IPScanner_SNMPSecurity, group.IPScanner_SNMPUsername, + group.IPScanner_SNMPAuthenticationProvider, group.IPScanner_SNMPAuth, + group.IPScanner_SNMPPrivacyProvider, group.IPScanner_SNMPPriv); + + return null; + } + + /// + /// Creates the credentials of the values of a profile or group (copies of the passwords, a change of the profile + /// doesn't change an open tab). + /// + private static SNMPSecureCredentials CreateCredentials(SNMPVersion version, SecureString community, + SNMPV3Security security, string username, SNMPV3AuthenticationProvider authenticationProvider, + SecureString auth, SNMPV3PrivacyProvider privacyProvider, SecureString priv) + { + return new SNMPSecureCredentials(version) + { + Community = community?.Copy(), + Security = security, + Username = username ?? string.Empty, + AuthenticationProvider = authenticationProvider, + Auth = auth?.Copy(), + PrivacyProvider = privacyProvider, + Priv = priv?.Copy() + }; + } +} diff --git a/Source/NETworkManager.Profiles/GroupInfo.cs b/Source/NETworkManager.Profiles/GroupInfo.cs index 1f9289df58..2124632e87 100644 --- a/Source/NETworkManager.Profiles/GroupInfo.cs +++ b/Source/NETworkManager.Profiles/GroupInfo.cs @@ -41,6 +41,17 @@ public GroupInfo(GroupInfo group) : this(group.Name) // Profiles Profiles = group.Profiles; + // IP Scanner + IPScanner_OverrideSNMP = group.IPScanner_OverrideSNMP; + IPScanner_SNMPVersion = group.IPScanner_SNMPVersion; + IPScanner_SNMPCommunity = group.IPScanner_SNMPCommunity; + IPScanner_SNMPSecurity = group.IPScanner_SNMPSecurity; + IPScanner_SNMPUsername = group.IPScanner_SNMPUsername; + IPScanner_SNMPAuthenticationProvider = group.IPScanner_SNMPAuthenticationProvider; + IPScanner_SNMPAuth = group.IPScanner_SNMPAuth; + IPScanner_SNMPPrivacyProvider = group.IPScanner_SNMPPrivacyProvider; + IPScanner_SNMPPriv = group.IPScanner_SNMPPriv; + // Port Scanner PortScanner_OverridePorts = group.PortScanner_OverridePorts; PortScanner_Ports = group.PortScanner_Ports; @@ -185,6 +196,27 @@ public GroupInfo(GroupInfo group) : this(group.Name) [XmlIgnore] public List Profiles { get; set; } + /// + /// Use the SNMP version and credentials below for the IP Scanner instead of the settings (a profile before its + /// group), see . + /// + public bool IPScanner_OverrideSNMP { get; set; } + + public SNMPVersion IPScanner_SNMPVersion { get; set; } = GlobalStaticConfiguration.IPScanner_SNMPVersion; + [XmlIgnore] public SecureString IPScanner_SNMPCommunity { get; set; } + public SNMPV3Security IPScanner_SNMPSecurity { get; set; } = GlobalStaticConfiguration.SNMP_Security; + public string IPScanner_SNMPUsername { get; set; } + + public SNMPV3AuthenticationProvider IPScanner_SNMPAuthenticationProvider { get; set; } = + GlobalStaticConfiguration.SNMP_AuthenticationProvider; + + [XmlIgnore] public SecureString IPScanner_SNMPAuth { get; set; } + + public SNMPV3PrivacyProvider IPScanner_SNMPPrivacyProvider { get; set; } = + GlobalStaticConfiguration.SNMP_PrivacyProvider; + + [XmlIgnore] public SecureString IPScanner_SNMPPriv { get; set; } + public bool PortScanner_OverridePorts { get; set; } /// diff --git a/Source/NETworkManager.Profiles/GroupInfoSerializable.cs b/Source/NETworkManager.Profiles/GroupInfoSerializable.cs index 01e9a03aa1..dd63f97750 100644 --- a/Source/NETworkManager.Profiles/GroupInfoSerializable.cs +++ b/Source/NETworkManager.Profiles/GroupInfoSerializable.cs @@ -18,6 +18,21 @@ public GroupInfoSerializable(GroupInfo profileGroup) : base(profileGroup) /// public new List Profiles { get; set; } + /// + /// Override the default IP Scanner snmp community to make it serializable. + /// + public new string IPScanner_SNMPCommunity { get; set; } + + /// + /// Override the default IP Scanner snmp auth to make it serializable. + /// + public new string IPScanner_SNMPAuth { get; set; } + + /// + /// Override the default IP Scanner snmp priv to make it serializable. + /// + public new string IPScanner_SNMPPriv { get; set; } + /// /// Override the default remote desktop password to make it serializable. /// diff --git a/Source/NETworkManager.Profiles/GroupViewManager.cs b/Source/NETworkManager.Profiles/GroupViewManager.cs index 7758b9c6f6..f45115dd3b 100644 --- a/Source/NETworkManager.Profiles/GroupViewManager.cs +++ b/Source/NETworkManager.Profiles/GroupViewManager.cs @@ -13,6 +13,7 @@ public static class GroupViewManager new GroupViewInfo(GroupViewName.General, new PackIconModern { Kind = PackIconModernKind.Box }), // Applications + new GroupViewInfo(GroupViewName.IPScanner, ApplicationManager.GetIcon(ApplicationName.IPScanner)), new GroupViewInfo(GroupViewName.PortScanner, ApplicationManager.GetIcon(ApplicationName.PortScanner)), new GroupViewInfo(GroupViewName.DNSLookup, ApplicationManager.GetIcon(ApplicationName.DNSLookup)), new GroupViewInfo(GroupViewName.RemoteDesktop, ApplicationManager.GetIcon(ApplicationName.RemoteDesktop)), diff --git a/Source/NETworkManager.Profiles/GroupViewName.cs b/Source/NETworkManager.Profiles/GroupViewName.cs index 153ef3d5cf..5f0de1cad6 100644 --- a/Source/NETworkManager.Profiles/GroupViewName.cs +++ b/Source/NETworkManager.Profiles/GroupViewName.cs @@ -3,6 +3,7 @@ public enum GroupViewName { General, + IPScanner, PortScanner, DNSLookup, RemoteDesktop, diff --git a/Source/NETworkManager.Profiles/ProfileFileData.cs b/Source/NETworkManager.Profiles/ProfileFileData.cs index d6b0057f02..bc399638d0 100644 --- a/Source/NETworkManager.Profiles/ProfileFileData.cs +++ b/Source/NETworkManager.Profiles/ProfileFileData.cs @@ -104,6 +104,15 @@ public List GroupsSerializable { Profiles = [.. g.Profiles.Where(p => !p.IsDynamic).Select(p => new ProfileInfoSerializable(p) { + IPScanner_SNMPCommunity = p.IPScanner_SNMPCommunity != null + ? Utilities.SecureStringHelper.ConvertToString(p.IPScanner_SNMPCommunity) + : string.Empty, + IPScanner_SNMPAuth = p.IPScanner_SNMPAuth != null + ? Utilities.SecureStringHelper.ConvertToString(p.IPScanner_SNMPAuth) + : string.Empty, + IPScanner_SNMPPriv = p.IPScanner_SNMPPriv != null + ? Utilities.SecureStringHelper.ConvertToString(p.IPScanner_SNMPPriv) + : string.Empty, RemoteDesktop_Password = p.RemoteDesktop_Password != null ? Utilities.SecureStringHelper.ConvertToString(p.RemoteDesktop_Password) : string.Empty, @@ -120,6 +129,15 @@ public List GroupsSerializable ? Utilities.SecureStringHelper.ConvertToString(p.SNMP_Priv) : string.Empty })], + IPScanner_SNMPCommunity = g.IPScanner_SNMPCommunity != null + ? Utilities.SecureStringHelper.ConvertToString(g.IPScanner_SNMPCommunity) + : string.Empty, + IPScanner_SNMPAuth = g.IPScanner_SNMPAuth != null + ? Utilities.SecureStringHelper.ConvertToString(g.IPScanner_SNMPAuth) + : string.Empty, + IPScanner_SNMPPriv = g.IPScanner_SNMPPriv != null + ? Utilities.SecureStringHelper.ConvertToString(g.IPScanner_SNMPPriv) + : string.Empty, RemoteDesktop_Password = g.RemoteDesktop_Password != null ? Utilities.SecureStringHelper.ConvertToString(g.RemoteDesktop_Password) : string.Empty, @@ -149,6 +167,15 @@ public List GroupsSerializable TagsCollection = (ps.TagsCollection == null || ps.TagsCollection.Count == 0) && !string.IsNullOrEmpty(ps.Tags) ? new Controls.ObservableSetCollection(ps.Tags.Split([';'], StringSplitOptions.RemoveEmptyEntries)) : ps.TagsCollection, + IPScanner_SNMPCommunity = !string.IsNullOrEmpty(ps.IPScanner_SNMPCommunity) + ? Utilities.SecureStringHelper.ConvertToSecureString(ps.IPScanner_SNMPCommunity) + : null, + IPScanner_SNMPAuth = !string.IsNullOrEmpty(ps.IPScanner_SNMPAuth) + ? Utilities.SecureStringHelper.ConvertToSecureString(ps.IPScanner_SNMPAuth) + : null, + IPScanner_SNMPPriv = !string.IsNullOrEmpty(ps.IPScanner_SNMPPriv) + ? Utilities.SecureStringHelper.ConvertToSecureString(ps.IPScanner_SNMPPriv) + : null, RemoteDesktop_Password = !string.IsNullOrEmpty(ps.RemoteDesktop_Password) ? Utilities.SecureStringHelper.ConvertToSecureString(ps.RemoteDesktop_Password) : null, @@ -165,6 +192,15 @@ public List GroupsSerializable ? Utilities.SecureStringHelper.ConvertToSecureString(ps.SNMP_Priv) : null })], + IPScanner_SNMPCommunity = !string.IsNullOrEmpty(gs.IPScanner_SNMPCommunity) + ? Utilities.SecureStringHelper.ConvertToSecureString(gs.IPScanner_SNMPCommunity) + : null, + IPScanner_SNMPAuth = !string.IsNullOrEmpty(gs.IPScanner_SNMPAuth) + ? Utilities.SecureStringHelper.ConvertToSecureString(gs.IPScanner_SNMPAuth) + : null, + IPScanner_SNMPPriv = !string.IsNullOrEmpty(gs.IPScanner_SNMPPriv) + ? Utilities.SecureStringHelper.ConvertToSecureString(gs.IPScanner_SNMPPriv) + : null, RemoteDesktop_Password = !string.IsNullOrEmpty(gs.RemoteDesktop_Password) ? Utilities.SecureStringHelper.ConvertToSecureString(gs.RemoteDesktop_Password) : null, diff --git a/Source/NETworkManager.Profiles/ProfileInfo.cs b/Source/NETworkManager.Profiles/ProfileInfo.cs index 2a7a123263..1c2e463fd2 100644 --- a/Source/NETworkManager.Profiles/ProfileInfo.cs +++ b/Source/NETworkManager.Profiles/ProfileInfo.cs @@ -58,6 +58,15 @@ public ProfileInfo(ProfileInfo profile) IPScanner_Enabled = profile.IPScanner_Enabled; IPScanner_InheritHost = profile.IPScanner_InheritHost; IPScanner_HostOrIPRange = profile.IPScanner_HostOrIPRange; + IPScanner_OverrideSNMP = profile.IPScanner_OverrideSNMP; + IPScanner_SNMPVersion = profile.IPScanner_SNMPVersion; + IPScanner_SNMPCommunity = profile.IPScanner_SNMPCommunity; + IPScanner_SNMPSecurity = profile.IPScanner_SNMPSecurity; + IPScanner_SNMPUsername = profile.IPScanner_SNMPUsername; + IPScanner_SNMPAuthenticationProvider = profile.IPScanner_SNMPAuthenticationProvider; + IPScanner_SNMPAuth = profile.IPScanner_SNMPAuth; + IPScanner_SNMPPrivacyProvider = profile.IPScanner_SNMPPrivacyProvider; + IPScanner_SNMPPriv = profile.IPScanner_SNMPPriv; // Port Scanner PortScanner_Enabled = profile.PortScanner_Enabled; @@ -315,6 +324,27 @@ public ProfileInfo(ProfileInfo profile) public bool IPScanner_InheritHost { get; set; } = true; public string IPScanner_HostOrIPRange { get; set; } + /// + /// Use the SNMP version and credentials below for the IP Scanner instead of the settings (a profile before its + /// group), see . + /// + public bool IPScanner_OverrideSNMP { get; set; } + + public SNMPVersion IPScanner_SNMPVersion { get; set; } = GlobalStaticConfiguration.IPScanner_SNMPVersion; + [XmlIgnore] public SecureString IPScanner_SNMPCommunity { get; set; } + public SNMPV3Security IPScanner_SNMPSecurity { get; set; } = GlobalStaticConfiguration.SNMP_Security; + public string IPScanner_SNMPUsername { get; set; } + + public SNMPV3AuthenticationProvider IPScanner_SNMPAuthenticationProvider { get; set; } = + GlobalStaticConfiguration.SNMP_AuthenticationProvider; + + [XmlIgnore] public SecureString IPScanner_SNMPAuth { get; set; } + + public SNMPV3PrivacyProvider IPScanner_SNMPPrivacyProvider { get; set; } = + GlobalStaticConfiguration.SNMP_PrivacyProvider; + + [XmlIgnore] public SecureString IPScanner_SNMPPriv { get; set; } + public bool PortScanner_Enabled { get; set; } public bool PortScanner_InheritHost { get; set; } = true; public string PortScanner_Host { get; set; } diff --git a/Source/NETworkManager.Profiles/ProfileInfoSerializable.cs b/Source/NETworkManager.Profiles/ProfileInfoSerializable.cs index 96e14fbe0f..2e684433ef 100644 --- a/Source/NETworkManager.Profiles/ProfileInfoSerializable.cs +++ b/Source/NETworkManager.Profiles/ProfileInfoSerializable.cs @@ -19,6 +19,21 @@ public ProfileInfoSerializable(ProfileInfo profile) : base(profile) } + /// + /// Override the default IP Scanner snmp community to make it serializable. + /// + public new string IPScanner_SNMPCommunity { get; set; } + + /// + /// Override the default IP Scanner snmp auth to make it serializable. + /// + public new string IPScanner_SNMPAuth { get; set; } + + /// + /// Override the default IP Scanner snmp priv to make it serializable. + /// + public new string IPScanner_SNMPPriv { get; set; } + /// /// Override the default remote desktop password to make it serializable. /// diff --git a/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs b/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs index 48ad2b3032..811786e00f 100644 --- a/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs +++ b/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs @@ -148,9 +148,11 @@ public static class GlobalStaticConfiguration public static ExportFileType WiFi_ExportFileType => ExportFileType.Csv; // Application: IP Scanner + public static bool IPScanner_ICMPEnabled => true; public static int IPScanner_ICMPAttempts => 2; public static int IPScanner_ICMPTimeout => 2000; public static int IPScanner_ICMPBuffer => 32; + public static bool IPScanner_OSGuessEnabled => true; public static bool IPScanner_ResolveHostname => true; public static bool IPScanner_PortScanEnabled => true; public static string IPScanner_PortScanPorts => "21; 22; 23; 53; 80; 135; 139; 389; 443; 445; 554; 631; 636; 3389; 5900; 5985; 7000; 8009; 8080; 8443; 9100; 62078"; @@ -160,8 +162,25 @@ public static class GlobalStaticConfiguration public static int IPScanner_MaxPortThreads => 16; public static bool IPScanner_NetBIOSEnabled => true; public static int IPScanner_NetBIOSTimeout => 2000; + public static bool IPScanner_ARPEnabled => true; public static bool IPScanner_ResolveMACAddress => true; + public static bool IPScanner_MDNSEnabled => false; + public static int IPScanner_MDNSTimeout => 2000; + public static bool IPScanner_WebPageEnabled => false; + public static string IPScanner_WebPagePorts => "443; 80; 8443; 8080"; + public static int IPScanner_WebPageTimeout => 2000; + public static bool IPScanner_SNMPEnabled => false; + public static SNMPVersion IPScanner_SNMPVersion => SNMPVersion.V2C; + public static string IPScanner_SNMPCommunity => "public"; + public static int IPScanner_SNMPTimeout => 2000; public static bool IPScanner_ExpandHostView => false; + + public static AutoRefreshTimeInfo IPScanner_AutoRefreshTime => + AutoRefreshTime.GetScanDefaults.First(x => x.Value == 5 && x.TimeUnit == TimeUnit.Minute); + + public static bool IPScanner_ShowNotificationPopup => true; + public static bool IPScanner_NotificationSound => false; + public static int IPScanner_NotificationCloseTime => 10; public static ExportFileType IPScanner_ExportFileType => ExportFileType.Csv; // Larger scans need a confirmation, even larger ones are refused (every IP address / port is kept in memory) public static long IPScanner_ConfirmHostCount => 65536; @@ -176,6 +195,13 @@ public static class GlobalStaticConfiguration // local (ephemeral) ports of Windows (16,384), like the maximum of the IP Scanner (256 x 32) public static int PortScanner_MaxConnections => 8192; public static int PortScanner_Timeout => 2000; + + public static AutoRefreshTimeInfo PortScanner_AutoRefreshTime => + AutoRefreshTime.GetScanDefaults.First(x => x.Value == 5 && x.TimeUnit == TimeUnit.Minute); + + public static bool PortScanner_ShowNotificationPopup => true; + public static bool PortScanner_NotificationSound => false; + public static int PortScanner_NotificationCloseTime => 10; public static int PortScanner_Attempts => 1; public static bool PortScanner_PingHostsBeforeScan => false; public static int PortScanner_ICMPAttempts => 2; @@ -198,7 +224,7 @@ public static class GlobalStaticConfiguration // Application: Ping Monitor (notifications) public static bool PingMonitor_ShowNotificationPopup => true; - public static bool PingMonitor_NotificationSound => true; + public static bool PingMonitor_NotificationSound => false; public static int PingMonitor_NotificationSuccessThreshold => 1; public static int PingMonitor_NotificationFailureThreshold => 3; public static int PingMonitor_NotificationCloseTime => 10; diff --git a/Source/NETworkManager.Settings/SettingsInfo.cs b/Source/NETworkManager.Settings/SettingsInfo.cs index b3892ce80d..2b1c134448 100644 --- a/Source/NETworkManager.Settings/SettingsInfo.cs +++ b/Source/NETworkManager.Settings/SettingsInfo.cs @@ -1220,6 +1220,83 @@ public bool IPScanner_ExpandHostView } } = GlobalStaticConfiguration.IPScanner_ExpandHostView; + /// + /// Interval of "Automatically scan every" (the checkbox is per tab and not saved, see the view model). + /// + public AutoRefreshTimeInfo IPScanner_AutoRefreshTime + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } = GlobalStaticConfiguration.IPScanner_AutoRefreshTime; + + /// + /// Show a notification when a repeated scan found changes. + /// + public bool IPScanner_ShowNotificationPopup + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } = GlobalStaticConfiguration.IPScanner_ShowNotificationPopup; + + /// + /// Play a sound when a repeated scan found changes. + /// + public bool IPScanner_NotificationSound + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } = GlobalStaticConfiguration.IPScanner_NotificationSound; + + /// + /// Seconds until the notification of a repeated scan is closed. + /// + public int IPScanner_NotificationCloseTime + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } = GlobalStaticConfiguration.IPScanner_NotificationCloseTime; + + public bool IPScanner_ICMPEnabled + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } = GlobalStaticConfiguration.IPScanner_ICMPEnabled; + public int IPScanner_ICMPTimeout { get; @@ -1259,6 +1336,19 @@ public int IPScanner_ICMPBuffer } } = GlobalStaticConfiguration.IPScanner_ICMPBuffer; + public bool IPScanner_OSGuessEnabled + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } = GlobalStaticConfiguration.IPScanner_OSGuessEnabled; + public ObservableCollection IPScanner_HostHistory { get; @@ -1338,6 +1428,19 @@ public int IPScanner_PortScanAttempts } } = GlobalStaticConfiguration.IPScanner_PortScanAttempts; + public bool IPScanner_ARPEnabled + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } = GlobalStaticConfiguration.IPScanner_ARPEnabled; + public bool IPScanner_NetBIOSEnabled { get; @@ -1377,6 +1480,123 @@ public bool IPScanner_ResolveMACAddress } } = GlobalStaticConfiguration.IPScanner_ResolveMACAddress; + public bool IPScanner_MDNSEnabled + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } = GlobalStaticConfiguration.IPScanner_MDNSEnabled; + + public int IPScanner_MDNSTimeout + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } = GlobalStaticConfiguration.IPScanner_MDNSTimeout; + + public bool IPScanner_WebPageEnabled + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } = GlobalStaticConfiguration.IPScanner_WebPageEnabled; + + public string IPScanner_WebPagePorts + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } = GlobalStaticConfiguration.IPScanner_WebPagePorts; + + public int IPScanner_WebPageTimeout + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } = GlobalStaticConfiguration.IPScanner_WebPageTimeout; + + public bool IPScanner_SNMPEnabled + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } = GlobalStaticConfiguration.IPScanner_SNMPEnabled; + + public SNMPVersion IPScanner_SNMPVersion + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } = GlobalStaticConfiguration.IPScanner_SNMPVersion; + + public string IPScanner_SNMPCommunity + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } = GlobalStaticConfiguration.IPScanner_SNMPCommunity; + + public int IPScanner_SNMPTimeout + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } = GlobalStaticConfiguration.IPScanner_SNMPTimeout; + public ObservableCollection IPScanner_CustomCommands { get; @@ -1528,6 +1748,70 @@ public int PortScanner_Timeout } } = GlobalStaticConfiguration.PortScanner_Timeout; + /// + /// Interval of "Automatically scan every" (the checkbox is per tab and not saved, see the view model). + /// + public AutoRefreshTimeInfo PortScanner_AutoRefreshTime + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } = GlobalStaticConfiguration.PortScanner_AutoRefreshTime; + + /// + /// Show a notification when a repeated scan found changes. + /// + public bool PortScanner_ShowNotificationPopup + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } = GlobalStaticConfiguration.PortScanner_ShowNotificationPopup; + + /// + /// Play a sound when a repeated scan found changes. + /// + public bool PortScanner_NotificationSound + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } = GlobalStaticConfiguration.PortScanner_NotificationSound; + + /// + /// Seconds until the notification of a repeated scan is closed. + /// + public int PortScanner_NotificationCloseTime + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } = GlobalStaticConfiguration.PortScanner_NotificationCloseTime; + public int PortScanner_Attempts { get; diff --git a/Source/NETworkManager.Tests/NETworkManager.Tests.csproj b/Source/NETworkManager.Tests/NETworkManager.Tests.csproj index bc61ad736b..897095982a 100644 --- a/Source/NETworkManager.Tests/NETworkManager.Tests.csproj +++ b/Source/NETworkManager.Tests/NETworkManager.Tests.csproj @@ -18,5 +18,6 @@ + diff --git a/Source/NETworkManager.Tests/Network/ExportManagerIPScannerTests.cs b/Source/NETworkManager.Tests/Network/ExportManagerIPScannerTests.cs new file mode 100644 index 0000000000..d5316b198d --- /dev/null +++ b/Source/NETworkManager.Tests/Network/ExportManagerIPScannerTests.cs @@ -0,0 +1,103 @@ +using System; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.NetworkInformation; +using System.Xml.Linq; +using NETworkManager.Models.Export; +using NETworkManager.Models.Network; +using Newtonsoft.Json.Linq; +using Xunit; + +namespace NETworkManager.Tests.Network; + +public class ExportManagerIPScannerTests +{ + /// + /// Columns of the CSV export - scripts read them by position: released columns must not move, new ones are + /// appended at the end. + /// + private const string CsvHeader = + "IsReachable,IPAddress,Hostname,PingStatus,Timestamp,Time,TTL,Bytes,PortStatus,Ports,NetBIOSIsReachable," + + "NetBIOSIPAddress,NetBIOSComputerName,NetBIOSUserName,NetBIOSGroupName,NetBIOSMACAddress,NetBIOSVendor," + + "MACAddress,Vendor,DNSHostname,DetectedBy,OSGuess,Role,MDNSHostname,WebTitle,WebURL,SNMPName,SNMPDescription," + + "SNMPLocation,WebStatusCode,WebServer,WebPoweredBy,WebRealm,WebRedirectURL,WebCertificateSubject," + + "WebCertificateIssuer,WebCertificateNotAfter,WebCertificateSelfSigned,Change,PreviousMACAddress,SNMPError"; + + private static readonly IPScannerResultOptions AllEnabled = new(true, true, true, true, true, true, true, true, true); + + private static string Export(ExportFileType fileType, params IPScannerHostInfo[] hosts) + { + return Export(fileType, AllEnabled, hosts); + } + + private static string Export(ExportFileType fileType, IPScannerResultOptions options, + params IPScannerHostInfo[] hosts) + { + var filePath = Path.GetTempFileName(); + + try + { + ExportManager.Export(filePath, fileType, hosts, options); + + return File.ReadAllText(filePath); + } + finally + { + File.Delete(filePath); + } + } + + private static IPScannerHostInfo CreateHost() + { + var ipAddress = IPAddress.Parse("10.0.0.1"); + + return new IPScannerHostInfo(ipAddress, true, new PingInfo(DateTime.Now, ipAddress, IPStatus.Success), false, + [], new NetBIOSInfo(ipAddress)); + } + + [Fact] + public void Csv_Header() + { + Assert.Equal(CsvHeader, Export(ExportFileType.Csv).Split(Environment.NewLine)[0]); + } + + [Fact] + public void Csv_OneValuePerColumn() + { + var row = Export(ExportFileType.Csv, CreateHost()).Split(Environment.NewLine)[1]; + + // No comma in the values of the host (empty values are quoted or empty) + Assert.Equal(CsvHeader.Split(',').Length, row.Split(',').Length); + } + + [Fact] + public void Xml_SameElementsAsCsv() + { + var host = XDocument.Parse(Export(ExportFileType.Xml, CreateHost())).Descendants(nameof(IPScannerHostInfo)) + .Single(); + + // Without ports: no element for the list (one element per port) + Assert.Equal(CsvHeader.Split(',').Where(x => x != "Ports"), host.Elements().Select(x => x.Name.LocalName)); + } + + [Fact] + public void Json_WebPageWithoutPortScan_Empty() + { + // Web pages are only read with the port scan - the values are empty (null), like the ones of a disabled probe + var options = AllEnabled with { PortScanEnabled = false }; + + var host = (JObject)JArray.Parse(Export(ExportFileType.Json, options, CreateHost())).Single(); + + Assert.Equal(JTokenType.Null, host["WebTitle"]!.Type); + Assert.Equal(JTokenType.Null, host["WebURL"]!.Type); + } + + [Fact] + public void Json_SameKeysAsCsv() + { + var host = (JObject)JArray.Parse(Export(ExportFileType.Json, CreateHost())).Single(); + + Assert.Equal(CsvHeader.Split(','), host.Properties().Select(x => x.Name)); + } +} diff --git a/Source/NETworkManager.Tests/Network/HostRangeHelperExclusionTests.cs b/Source/NETworkManager.Tests/Network/HostRangeHelperExclusionTests.cs new file mode 100644 index 0000000000..35e92ec8ed --- /dev/null +++ b/Source/NETworkManager.Tests/Network/HostRangeHelperExclusionTests.cs @@ -0,0 +1,124 @@ +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using NETworkManager.Models.Network; +using Xunit; + +namespace NETworkManager.Tests.Network; + +public class HostRangeHelperExclusionTests +{ + private static async Task ResolveAsync(string input) + { + var (hosts, _, _) = await HostRangeHelper.ResolveAsync(HostRangeHelper.CreateListFromInput(input), true, false, + CancellationToken.None); + + return [.. hosts.Select(x => x.ipAddress.ToString())]; + } + + [Theory] + [InlineData("10.0.0.0/29; !10.0.0.1", "10.0.0.0,10.0.0.2,10.0.0.3,10.0.0.4,10.0.0.5,10.0.0.6,10.0.0.7")] + [InlineData("10.0.0.0/29; !10.0.0.0/30", "10.0.0.4,10.0.0.5,10.0.0.6,10.0.0.7")] + [InlineData("10.0.0.0/29; !10.0.0.0/255.255.255.252", "10.0.0.4,10.0.0.5,10.0.0.6,10.0.0.7")] + [InlineData("10.0.0.0/29; !10.0.0.2-5", "10.0.0.0,10.0.0.1,10.0.0.6,10.0.0.7")] + [InlineData("10.0.0.0/29; !10.0.0.2 - 10.0.0.5", "10.0.0.0,10.0.0.1,10.0.0.6,10.0.0.7")] + [InlineData("10.0.0.0/29; !10.0.0.[0,7]", "10.0.0.1,10.0.0.2,10.0.0.3,10.0.0.4,10.0.0.5,10.0.0.6")] + [InlineData("!10.0.0.1; 10.0.0.1-3", "10.0.0.2,10.0.0.3")] + [InlineData("10.0.0.1-3; !10.0.0.0/8", "")] + [InlineData("10.0.0.1-3; !192.168.0.1", "10.0.0.1,10.0.0.2,10.0.0.3")] + [InlineData("2001:db8::1; 2001:db8::2; !2001:db8::1", "2001:db8::2")] + public async Task ResolveAsync_Exclusions(string input, string expected) + { + Assert.Equal(expected, string.Join(",", await ResolveAsync(input))); + } + + [Fact] + public async Task ResolveAsync_OnlyExclusions_NoHosts() + { + Assert.Empty(await ResolveAsync("!10.0.0.1")); + } + + [Theory] + [InlineData("10.0.0.1; !10.0.0.1", 1)] + [InlineData("10.0.0.0/30; !10.0.0.1-2", 2)] + [InlineData("10.0.0.1-3; !10.0.0.0/8", 3)] + [InlineData("10.0.0.1-3; !192.168.0.1", 0)] + [InlineData("10.0.0.1-3", 0)] + public async Task ResolveAsync_ExcludedCount(string input, int expected) + { + var (_, _, excludedCount) = await HostRangeHelper.ResolveAsync(HostRangeHelper.CreateListFromInput(input), + true, false, CancellationToken.None); + + Assert.Equal(expected, excludedCount); + } + + [Theory] + [InlineData("10.0.0.0/24; !10.0.0.1", 256)] + [InlineData("10.0.0.0/24; !10.0.0.0/8", 256)] + [InlineData("!10.0.0.0/8", 0)] + public void GetHostCount_IgnoresExclusions(string input, long expected) + { + Assert.Equal(expected, HostRangeHelper.GetHostCount(HostRangeHelper.CreateListFromInput(input))); + } + + [Theory] + [InlineData("10.0.0.1", HostRangeInputKind.IPAddress)] + [InlineData("2001:db8::1", HostRangeInputKind.IPAddress)] + [InlineData("fe80::1%3", HostRangeInputKind.IPAddress)] + [InlineData("10.0.0.0/24", HostRangeInputKind.IPv4Range)] + [InlineData("10.0.0.0/255.255.255.0", HostRangeInputKind.IPv4Range)] + [InlineData("10.0.0.1-100", HostRangeInputKind.IPv4Range)] + [InlineData("10.0.0.1-10.0.0.100", HostRangeInputKind.IPv4Range)] + [InlineData("10.0.[0-10,20].1", HostRangeInputKind.IPv4SpecialRange)] + [InlineData("server.example.com", HostRangeInputKind.Hostname)] + [InlineData("server.example.com/24", HostRangeInputKind.HostnameWithSubnet)] + [InlineData("server.example.com/255.255.255.0", HostRangeInputKind.HostnameWithSubnet)] + [InlineData("10.0.0.100-1", HostRangeInputKind.Invalid)] + [InlineData("10.0.0.100-10.0.0.1", HostRangeInputKind.Invalid)] + [InlineData("10.0.[10-0].1", HostRangeInputKind.Invalid)] + [InlineData("10.0.[10-0,5].1", HostRangeInputKind.Invalid)] + [InlineData("10.0.0.0/33", HostRangeInputKind.Invalid)] + [InlineData("-invalid-", HostRangeInputKind.Invalid)] + public void Parse_Kind(string input, HostRangeInputKind expected) + { + Assert.Equal(expected, HostRangeHelper.Parse(input).Kind); + } + + [Theory] + [InlineData("10.0.0.5/24", "10.0.0.0", "10.0.0.255")] + [InlineData("10.0.0.0/255.255.255.252", "10.0.0.0", "10.0.0.3")] + [InlineData("10.0.0.1-100", "10.0.0.1", "10.0.0.100")] + [InlineData("10.0.0.200-10.0.1.5", "10.0.0.200", "10.0.1.5")] + public void Parse_IPv4Range(string input, string start, string end) + { + var range = HostRangeHelper.Parse(input); + + Assert.Equal(start, range.Start.ToString()); + Assert.Equal(end, range.End.ToString()); + } + + [Fact] + public void Parse_HostnameWithSubnet() + { + var input = HostRangeHelper.Parse("server.example.com/24"); + + Assert.Equal("server.example.com", input.Hostname); + Assert.Equal("24", input.Subnet); + } + + [Theory] + [InlineData("10.0.0.0/24; 10.0.[1-2].[1-3]; 2001:db8::1; server.example.com/30", 256 + 6 + 1 + 4)] + [InlineData("10.0.0.5-1", 0)] + public void GetHostCount(string input, long expected) + { + Assert.Equal(expected, HostRangeHelper.GetHostCount(HostRangeHelper.CreateListFromInput(input))); + } + + [Theory] + [InlineData("!10.0.0.1", true)] + [InlineData("10.0.0.1", false)] + public void IsExclusion(string input, bool expected) + { + Assert.Equal(expected, HostRangeHelper.IsExclusion(input)); + } +} diff --git a/Source/NETworkManager.Tests/Network/IPScannerHostInfoTests.cs b/Source/NETworkManager.Tests/Network/IPScannerHostInfoTests.cs new file mode 100644 index 0000000000..3790891359 --- /dev/null +++ b/Source/NETworkManager.Tests/Network/IPScannerHostInfoTests.cs @@ -0,0 +1,52 @@ +using System; +using System.Linq; +using System.Net; +using System.Net.NetworkInformation; +using NETworkManager.Models.Network; +using Xunit; + +namespace NETworkManager.Tests.Network; + +public class IPScannerHostInfoTests +{ + /// + /// Every value is copied ( uses ), + /// also a value added later. + /// + [Fact] + public void Clone_CopiesAllValues() + { + var ipAddress = IPAddress.Parse("10.0.0.1"); + + var host = new IPScannerHostInfo(ipAddress, true, new PingInfo(DateTime.Now, ipAddress, IPStatus.Success), + true, [], new NetBIOSInfo(ipAddress)); + + // Lookups ended (e.g. canceled scan), the pending flags below are set again + host.EndPendingLookups(); + + // Every property that has a setter (the setters are internal) gets a value that is not the default + foreach (var property in typeof(IPScannerHostInfo).GetProperties().Where(x => x.SetMethod != null)) + { + object value = property.PropertyType switch + { + var type when type == typeof(string) => property.Name, + var type when type == typeof(bool) => true, + var type when type == typeof(int) => 42, + var type when type.IsEnum => Enum.GetValues(type).Cast().Last(), + var type when type == typeof(WebCertificateInfo) => + new WebCertificateInfo("CN", "Issuer", DateTime.UtcNow, false), + // Objects of the constructor (ping, ports, NetBIOS) + _ => null + }; + + if (value != null) + property.SetValue(host, value); + } + + var clone = host.Clone(); + + // Also the computed values (e.g. IsHostnamePending depends on the ended lookups) + foreach (var property in typeof(IPScannerHostInfo).GetProperties()) + Assert.Equal(property.GetValue(host), property.GetValue(clone)); + } +} diff --git a/Source/NETworkManager.Tests/Network/IPScannerHostRoleHelperTests.cs b/Source/NETworkManager.Tests/Network/IPScannerHostRoleHelperTests.cs new file mode 100644 index 0000000000..734f99466a --- /dev/null +++ b/Source/NETworkManager.Tests/Network/IPScannerHostRoleHelperTests.cs @@ -0,0 +1,87 @@ +using System; +using System.Net; +using NETworkManager.Models.Network; +using Xunit; + +namespace NETworkManager.Tests.Network; + +public class IPScannerHostRoleHelperTests +{ + private static NetworkInterfaceInfo CreateInterface(bool isOperational = true) + { + return new NetworkInterfaceInfo + { + IsOperational = isOperational, + IPv4Address = + [new Tuple(IPAddress.Parse("192.168.1.23"), IPAddress.Parse("255.255.255.0"))], + IPv6Address = [IPAddress.Parse("2001:db8::23")], + IPv6AddressLinkLocal = [IPAddress.Parse("fe80::23%12")], + IPv4Gateway = [IPAddress.Parse("192.168.1.1")], + IPv6Gateway = [IPAddress.Parse("fe80::1%12")], + DNSServer = [IPAddress.Parse("192.168.1.1"), IPAddress.Parse("192.168.1.2")], + DhcpServer = [IPAddress.Parse("192.168.1.1")] + }; + } + + [Theory] + [InlineData("192.168.1.23", IPScannerHostRole.LocalComputer)] + [InlineData("2001:db8::23", IPScannerHostRole.LocalComputer)] + [InlineData("fe80::23", IPScannerHostRole.LocalComputer)] + [InlineData("fe80::23%12", IPScannerHostRole.LocalComputer)] + [InlineData("192.168.1.1", + IPScannerHostRole.DefaultGateway | IPScannerHostRole.DNSServer | IPScannerHostRole.DHCPServer)] + [InlineData("fe80::1%12", IPScannerHostRole.DefaultGateway)] + [InlineData("fe80::1", IPScannerHostRole.DefaultGateway)] + // Another link: the same link-local address is another host + [InlineData("fe80::1%3", IPScannerHostRole.None)] + [InlineData("fe80::23%7", IPScannerHostRole.None)] + [InlineData("192.168.1.2", IPScannerHostRole.DNSServer)] + [InlineData("192.168.1.50", IPScannerHostRole.None)] + public void GetRole(string ipAddress, IPScannerHostRole expected) + { + var roles = IPScannerHostRoleHelper.GetRoles([CreateInterface()]); + + Assert.Equal(expected, IPScannerHostRoleHelper.GetRole(roles, IPAddress.Parse(ipAddress))); + } + + [Fact] + public void GetRole_SameLinkLocalAddressOnTwoInterfaces() + { + // fe80::1 is the gateway of interface 12 and a DNS server on interface 7 (another router) + var roles = IPScannerHostRoleHelper.GetRoles( + [ + CreateInterface(), + new NetworkInterfaceInfo + { + IsOperational = true, + DNSServer = [IPAddress.Parse("fe80::1%7"), IPAddress.Parse("fe80::53")] + } + ]); + + Assert.Equal(IPScannerHostRole.DefaultGateway, IPScannerHostRoleHelper.GetRole(roles, IPAddress.Parse("fe80::1%12"))); + Assert.Equal(IPScannerHostRole.DNSServer, IPScannerHostRoleHelper.GetRole(roles, IPAddress.Parse("fe80::1%7"))); + + // Interface not known: both + Assert.Equal(IPScannerHostRole.DefaultGateway | IPScannerHostRole.DNSServer, + IPScannerHostRoleHelper.GetRole(roles, IPAddress.Parse("fe80::1"))); + + // Configured without scope id: on every interface + Assert.Equal(IPScannerHostRole.DNSServer, IPScannerHostRoleHelper.GetRole(roles, IPAddress.Parse("fe80::53%12"))); + } + + [Fact] + public void GetRoles_NotOperational_Skipped() + { + Assert.Empty(IPScannerHostRoleHelper.GetRoles([CreateInterface(false)])); + } + + [Theory] + [InlineData(IPScannerHostRole.None, IPScannerHostRole.None)] + [InlineData(IPScannerHostRole.DHCPServer | IPScannerHostRole.DNSServer, IPScannerHostRole.DNSServer)] + [InlineData(IPScannerHostRole.DNSServer | IPScannerHostRole.DefaultGateway, IPScannerHostRole.DefaultGateway)] + [InlineData(IPScannerHostRole.LocalComputer | IPScannerHostRole.DNSServer, IPScannerHostRole.LocalComputer)] + public void GetPrimaryRole(IPScannerHostRole role, IPScannerHostRole expected) + { + Assert.Equal(expected, IPScannerHostRoleHelper.GetPrimaryRole(role)); + } +} diff --git a/Source/NETworkManager.Tests/Network/IPScannerNeighborCheckTests.cs b/Source/NETworkManager.Tests/Network/IPScannerNeighborCheckTests.cs new file mode 100644 index 0000000000..1b84f56550 --- /dev/null +++ b/Source/NETworkManager.Tests/Network/IPScannerNeighborCheckTests.cs @@ -0,0 +1,91 @@ +using System.Net; +using System.Net.NetworkInformation; +using System.Net.Sockets; +using NETworkManager.Models.Network; +using Xunit; + +namespace NETworkManager.Tests.Network; + +public class IPScannerNeighborCheckTests +{ + private const string HostMAC = "001122AABBCC"; + private const string GatewayMAC = "00AABBCCDDEE"; + + private static readonly string[] GatewayMACs = [GatewayMAC]; + + private static NeighborInfo CreateEntry(NeighborState state, string macAddress = HostMAC) + { + return new NeighborInfo(IPAddress.Parse("192.168.1.10"), PhysicalAddress.Parse(macAddress), false, 12, + "Ethernet", state, AddressFamily.InterNetwork); + } + + [Fact] + public void Check_NoEntry_IsUnreachable() + { + Assert.Equal(IPScannerNeighborCheckResult.Unreachable, IPScannerNeighborCheck.Check(null, false, false, GatewayMACs)); + } + + [Theory] + [InlineData(NeighborState.Reachable, IPScannerNeighborCheckResult.Reachable)] + [InlineData(NeighborState.Stale, IPScannerNeighborCheckResult.Verify)] + [InlineData(NeighborState.Delay, IPScannerNeighborCheckResult.Verify)] + [InlineData(NeighborState.Probe, IPScannerNeighborCheckResult.Verify)] + [InlineData(NeighborState.Permanent, IPScannerNeighborCheckResult.Unreachable)] + [InlineData(NeighborState.Incomplete, IPScannerNeighborCheckResult.Unreachable)] + [InlineData(NeighborState.Unreachable, IPScannerNeighborCheckResult.Unreachable)] + public void Check_State(NeighborState state, IPScannerNeighborCheckResult expected) + { + Assert.Equal(expected, IPScannerNeighborCheck.Check(CreateEntry(state), false, false, GatewayMACs)); + } + + [Theory] + [InlineData(NeighborState.Reachable)] + [InlineData(NeighborState.Stale)] + public void Check_MACOfGateway_IsProxyARP(NeighborState state) + { + Assert.Equal(IPScannerNeighborCheckResult.Unreachable, + IPScannerNeighborCheck.Check(CreateEntry(state, GatewayMAC), false, false, GatewayMACs)); + } + + [Fact] + public void Check_GatewayItself_IsReachable() + { + Assert.Equal(IPScannerNeighborCheckResult.Reachable, + IPScannerNeighborCheck.Check(CreateEntry(NeighborState.Reachable, GatewayMAC), false, true, GatewayMACs)); + } + + [Fact] + public void Check_ReachableBeforeTheProbes_IsVerified() + { + // The probes didn't resolve it again - may be from earlier traffic of a host that is gone now + Assert.Equal(IPScannerNeighborCheckResult.Verify, + IPScannerNeighborCheck.Check(CreateEntry(NeighborState.Reachable), true, false, GatewayMACs)); + } + + [Fact] + public void Check_NoGateway_IsReachable() + { + Assert.Equal(IPScannerNeighborCheckResult.Reachable, + IPScannerNeighborCheck.Check(CreateEntry(NeighborState.Reachable), false, false, [])); + } + + [Theory] + [InlineData("00AABBCCDDEE", true)] + [InlineData("00aabbccddee", true)] + [InlineData("001122AABBCC", false)] + public void IsProxyARPAnswer(string macAddress, bool expected) + { + Assert.Equal(expected, IPScannerNeighborCheck.IsProxyARPAnswer(macAddress, GatewayMACs)); + } + + [Theory] + [InlineData(0, NeighborState.Unreachable)] + [InlineData(4, NeighborState.Stale)] + [InlineData(5, NeighborState.Reachable)] + [InlineData(6, NeighborState.Permanent)] + public void NeighborState_MatchesWindowsValues(uint value, NeighborState expected) + { + // NL_NEIGHBOR_STATE (MIB_IPNET_ROW2.State) starts at 0 (NlnsUnreachable) + Assert.Equal(expected, (NeighborState)value); + } +} diff --git a/Source/NETworkManager.Tests/Network/IPScannerOSGuessHelperTests.cs b/Source/NETworkManager.Tests/Network/IPScannerOSGuessHelperTests.cs new file mode 100644 index 0000000000..d069cc6c58 --- /dev/null +++ b/Source/NETworkManager.Tests/Network/IPScannerOSGuessHelperTests.cs @@ -0,0 +1,35 @@ +using NETworkManager.Models.Network; +using Xunit; + +namespace NETworkManager.Tests.Network; + +public class IPScannerOSGuessHelperTests +{ + [Theory] + [InlineData(64, IPScannerOSGuess.LinuxUnix, 64, 0)] + [InlineData(61, IPScannerOSGuess.LinuxUnix, 64, 3)] + [InlineData(1, IPScannerOSGuess.LinuxUnix, 64, 63)] + [InlineData(128, IPScannerOSGuess.Windows, 128, 0)] + [InlineData(127, IPScannerOSGuess.Windows, 128, 1)] + [InlineData(65, IPScannerOSGuess.Windows, 128, 63)] + [InlineData(255, IPScannerOSGuess.NetworkDevice, 255, 0)] + [InlineData(250, IPScannerOSGuess.NetworkDevice, 255, 5)] + [InlineData(129, IPScannerOSGuess.NetworkDevice, 255, 126)] + public void GetFromTTL(int ttl, IPScannerOSGuess expectedOSGuess, int expectedInitialTTL, int expectedHops) + { + var (osGuess, initialTTL, hops) = IPScannerOSGuessHelper.GetFromTTL(ttl); + + Assert.Equal(expectedOSGuess, osGuess); + Assert.Equal(expectedInitialTTL, initialTTL); + Assert.Equal(expectedHops, hops); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(256)] + public void GetFromTTL_NoTTL_NoGuess(int ttl) + { + Assert.Equal((IPScannerOSGuess.None, 0, 0), IPScannerOSGuessHelper.GetFromTTL(ttl)); + } +} diff --git a/Source/NETworkManager.Tests/Network/IPScannerScanComparerTests.cs b/Source/NETworkManager.Tests/Network/IPScannerScanComparerTests.cs new file mode 100644 index 0000000000..1d45b183fd --- /dev/null +++ b/Source/NETworkManager.Tests/Network/IPScannerScanComparerTests.cs @@ -0,0 +1,132 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net; +using NETworkManager.Models.Network; +using Xunit; + +namespace NETworkManager.Tests.Network; + +public class IPScannerScanComparerTests +{ + private static readonly IPAddress A = IPAddress.Parse("10.0.0.1"); + private static readonly IPAddress B = IPAddress.Parse("10.0.0.2"); + private static readonly IPAddress C = IPAddress.Parse("10.0.0.3"); + private static readonly IPAddress V6 = IPAddress.Parse("fe80::1"); + + private static IPScannerBaselineHost Up(string mac = "") => new(true, "host", mac, "vendor"); + private static IPScannerBaselineHost Down() => new(false, string.Empty, string.Empty, string.Empty); + + private static Dictionary Scan(params (IPAddress, IPScannerBaselineHost)[] hosts) => + hosts.ToDictionary(x => x.Item1, x => x.Item2); + + [Fact] + public void SameHostsHaveNoChange() + { + var result = IPScannerScanComparer.Compare(Scan((A, Up("00:11")), (B, Up())), + Scan((A, Up("00:11")), (B, Up()))); + + Assert.Empty(result.Changes); + Assert.Empty(result.Gone); + } + + [Fact] + public void HostUpNowIsNew() + { + var result = IPScannerScanComparer.Compare(Scan((A, Up()), (B, Down())), Scan((A, Up()), (B, Up()), (C, Up()))); + + Assert.Equal(IPScannerHostChange.New, result.Changes[B].Change); + Assert.Equal(IPScannerHostChange.New, result.Changes[C].Change); + Assert.False(result.Changes.ContainsKey(A)); + } + + [Fact] + public void HostNotUpNowIsGone() + { + var previous = Up("00:11"); + var result = IPScannerScanComparer.Compare(Scan((A, previous), (B, Up()), (V6, Up())), + Scan((B, Down()))); + + Assert.Equal([A, B, V6], result.Gone.Select(x => x.IPAddress)); + Assert.Same(previous, result.Gone[0].Previous); + } + + [Fact] + public void HostDownBeforeAndNowIsNoChange() + { + var result = IPScannerScanComparer.Compare(Scan((A, Down())), Scan((A, Down()))); + + Assert.Empty(result.Changes); + Assert.Empty(result.Gone); + } + + [Fact] + public void OtherMACAddressIsMACChanged() + { + var result = IPScannerScanComparer.Compare(Scan((A, Up("00:11:22:33:44:55"))), + Scan((A, Up("66:77:88:99:AA:BB")))); + + Assert.Equal(IPScannerHostChange.MACChanged, result.Changes[A].Change); + Assert.Equal("00:11:22:33:44:55", result.Changes[A].PreviousMACAddress); + } + + [Theory] + [InlineData("", "00:11:22:33:44:55")] + [InlineData("00:11:22:33:44:55", "")] + [InlineData("00:11:22:33:44:aa", "00:11:22:33:44:AA")] + public void UnknownOrSameMACAddressIsNoChange(string previousMAC, string currentMAC) + { + var result = IPScannerScanComparer.Compare(Scan((A, Up(previousMAC))), Scan((A, Up(currentMAC)))); + + Assert.Empty(result.Changes); + } + + [Fact] + public void EmptyBaselineMarksAllUpHostsAsNew() + { + var result = IPScannerScanComparer.Compare(Scan(), Scan((A, Up()), (B, Down()))); + + Assert.Single(result.Changes); + Assert.Equal(IPScannerHostChange.New, result.Changes[A].Change); + } + + [Fact] + public void BaselineHasEveryHostWithItsState() + { + var gone = IPScannerHostInfo.CreateGone(A, Up("00:11")); + var up = new IPScannerHostInfo(B, true, new PingInfo(), false, [], new NetBIOSInfo(B)); + + var baseline = IPScannerScanComparer.CreateBaseline([gone, up]); + + Assert.Equal([A, B], baseline.Keys); + Assert.False(baseline[A].IsUp); + Assert.True(baseline[B].IsUp); + } + + [Fact] + public void GoneRowHasThePreviousValues() + { + var gone = IPScannerHostInfo.CreateGone(A, + new IPScannerBaselineHost(true, "nas.local", "00:11", "Synology", IPScannerHostRole.DefaultGateway)); + + Assert.False(gone.IsReachable); + Assert.Equal(IPScannerHostChange.Gone, gone.Change); + Assert.Equal("nas.local", gone.Hostname); + Assert.Equal("00:11", gone.MACAddress); + Assert.Equal(IPScannerHostRole.DefaultGateway, gone.Role); + Assert.False(gone.IsHostnamePending); + Assert.False(gone.IsMACAddressPending); + } + + [Fact] + public void MarkGoneShowsThePreviousValuesIfTheHostHasNone() + { + var row = new IPScannerHostInfo(A, false, new PingInfo(), false, [], new NetBIOSInfo(A)); + + row.MarkGone(new IPScannerBaselineHost(true, "nas.local", "00:11", "Synology")); + + Assert.Equal(IPScannerHostChange.Gone, row.Change); + Assert.Equal("nas.local", row.Hostname); + Assert.Equal("00:11", row.MACAddress); + Assert.Equal("Synology", row.Vendor); + } +} diff --git a/Source/NETworkManager.Tests/Network/MDNSResolverTests.cs b/Source/NETworkManager.Tests/Network/MDNSResolverTests.cs new file mode 100644 index 0000000000..fe78a7a5f5 --- /dev/null +++ b/Source/NETworkManager.Tests/Network/MDNSResolverTests.cs @@ -0,0 +1,151 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Text; +using NETworkManager.Models.Network; +using Xunit; + +namespace NETworkManager.Tests.Network; + +public class MDNSResolverTests +{ + private const ushort Id = 0x1234; + + private static byte[] EncodeName(string name) + { + var bytes = new List(); + + foreach (var label in name.Split('.')) + { + bytes.Add((byte)label.Length); + bytes.AddRange(Encoding.ASCII.GetBytes(label)); + } + + bytes.Add(0); + + return [.. bytes]; + } + + // Legacy unicast response: question repeated, answer name as compression pointer to the question (offset 12) + private static byte[] CreateResponse(ushort id, string hostname, ushort type = 12, bool compressed = true) + { + var question = CreateQuestion(); + + var response = new List + { + (byte)(id >> 8), (byte)id, 0x84, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00 + }; + + response.AddRange(question); + + // Answer: name (pointer to the question name), type, class (cache flush), TTL, data length, data + response.AddRange(compressed ? [0xC0, 0x0C] : question[..^4]); + response.AddRange([(byte)(type >> 8), (byte)type, 0x80, 0x01, 0x00, 0x00, 0x00, 0x78]); + + var data = EncodeName(hostname); + + response.AddRange([(byte)(data.Length >> 8), (byte)data.Length]); + response.AddRange(data); + + return [.. response]; + } + + private static byte[] CreateQuestion() + { + return [.. EncodeName("10.1.168.192.in-addr.arpa"), 0x00, 0x0C, 0x00, 0x01]; + } + + [Theory] + [InlineData("192.168.1.10", "10.1.168.192.in-addr.arpa")] + [InlineData("2001:db8::1", + "1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa")] + public void GetReverseName(string ipAddress, string expected) + { + Assert.Equal(expected, MDNSResolver.GetReverseName(IPAddress.Parse(ipAddress))); + } + + [Fact] + public void CreateReverseQuery() + { + var query = MDNSResolver.CreateReverseQuery(IPAddress.Parse("192.168.1.10"), Id); + + Assert.Equal(new byte[] { 0x12, 0x34, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, + query[..12]); + Assert.Equal(CreateQuestion(), query[12..]); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ParsePTRResponse(bool compressed) + { + Assert.Equal("macbook.local", + MDNSResolver.ParsePTRResponse(CreateResponse(Id, "macbook.local", compressed: compressed), Id)); + } + + [Fact] + public void ParsePTRResponse_ControlCharacters_Removed() + { + Assert.Equal("macbook.local", + MDNSResolver.ParsePTRResponse(CreateResponse(Id, "mac\u0000book\u0001.local\n"), Id)); + } + + [Fact] + public void ParsePTRResponse_IdZero_Accepted() + { + Assert.Equal("printer.local", MDNSResolver.ParsePTRResponse(CreateResponse(0, "printer.local"), Id)); + } + + [Fact] + public void ParsePTRResponse_OtherId_Empty() + { + Assert.Equal(string.Empty, MDNSResolver.ParsePTRResponse(CreateResponse(0x9999, "macbook.local"), Id)); + } + + [Fact] + public void ParsePTRResponse_NoPTR_Empty() + { + Assert.Equal(string.Empty, MDNSResolver.ParsePTRResponse(CreateResponse(Id, "macbook.local", 1), Id)); + } + + [Fact] + public void ParsePTRResponse_Query_Empty() + { + var query = MDNSResolver.CreateReverseQuery(IPAddress.Parse("192.168.1.10"), Id); + + Assert.Equal(string.Empty, MDNSResolver.ParsePTRResponse(query, Id)); + } + + [Theory] + [InlineData(0)] + [InlineData(5)] + [InlineData(20)] + [InlineData(60)] + public void ParsePTRResponse_Truncated_Empty(int length) + { + var response = CreateResponse(Id, "macbook.local"); + + Assert.Equal(string.Empty, MDNSResolver.ParsePTRResponse(response[..length], Id)); + } + + [Fact] + public void ParsePTRResponse_PointerLoop_Empty() + { + // Answer name points to itself + var response = CreateResponse(Id, "macbook.local"); + var answerOffset = 12 + CreateQuestion().Length; + + response[answerOffset] = 0xC0; + response[answerOffset + 1] = (byte)answerOffset; + + Assert.Equal(string.Empty, MDNSResolver.ParsePTRResponse(response, Id)); + } + + [Fact] + public void ParsePTRResponse_TrailingGarbage_Ignored() + { + var response = CreateResponse(Id, "macbook.local").Concat(new byte[] { 0xFF, 0xFF }).ToArray(); + + Assert.Equal("macbook.local", MDNSResolver.ParsePTRResponse(response, Id)); + } +} diff --git a/Source/NETworkManager.Tests/Network/NetworkInterfaceLocalSubnetsTests.cs b/Source/NETworkManager.Tests/Network/NetworkInterfaceLocalSubnetsTests.cs new file mode 100644 index 0000000000..771cb1c750 --- /dev/null +++ b/Source/NETworkManager.Tests/Network/NetworkInterfaceLocalSubnetsTests.cs @@ -0,0 +1,71 @@ +using System; +using System.Linq; +using System.Net; +using NETworkManager.Models.Network; +using Xunit; + +namespace NETworkManager.Tests.Network; + +public class NetworkInterfaceLocalSubnetsTests +{ + private static NetworkInterfaceInfo CreateInterface(string name, bool isOperational, + params (string ipAddress, string subnetmask)[] addresses) + { + return new NetworkInterfaceInfo + { + Name = name, + IsOperational = isOperational, + IPv4Address = addresses.Select(x => new Tuple(IPAddress.Parse(x.ipAddress), + x.subnetmask == null ? null : IPAddress.Parse(x.subnetmask))).ToArray() + }; + } + + [Fact] + public void GetLocalSubnets_NetworkAddressAndCidr() + { + var subnets = NetworkInterface.GetLocalSubnets([ + CreateInterface("Ethernet", true, ("192.168.1.23", "255.255.255.0"), ("10.10.5.7", "255.255.0.0")) + ]); + + Assert.Equal(["192.168.1.0/24 Ethernet", "10.10.0.0/16 Ethernet"], + subnets.Select(x => $"{x.Subnet} {x.InterfaceName}")); + Assert.All(subnets, x => Assert.False(x.IsLinkLocal)); + Assert.Equal("192.168.1.0/24 (Ethernet)", subnets[0].DisplayName); + } + + [Fact] + public void GetLocalSubnets_SkipsNotOperationalNoMaskAndSingleAddresses() + { + var subnets = NetworkInterface.GetLocalSubnets([ + CreateInterface("Down", false, ("192.168.2.1", "255.255.255.0")), + CreateInterface("VPN", true, ("10.8.0.2", "255.255.255.255"), ("10.9.0.2", null)), + CreateInterface("Wi-Fi", true, ("172.16.0.5", "255.255.240.0")) + ]); + + Assert.Equal(["172.16.0.0/20"], subnets.Select(x => x.Subnet)); + } + + [Fact] + public void GetLocalSubnets_LinkLocalAtTheEnd() + { + var subnets = NetworkInterface.GetLocalSubnets([ + CreateInterface("Ethernet 2", true, ("169.254.10.20", "255.255.0.0")), + CreateInterface("Ethernet", true, ("192.168.1.23", "255.255.255.0")) + ]); + + Assert.Equal(["192.168.1.0/24", "169.254.0.0/16"], subnets.Select(x => x.Subnet)); + Assert.True(subnets[1].IsLinkLocal); + } + + [Fact] + public void GetLocalSubnets_SameSubnetOncePerInterface() + { + var subnets = NetworkInterface.GetLocalSubnets([ + CreateInterface("Ethernet", true, ("192.168.1.23", "255.255.255.0"), ("192.168.1.24", "255.255.255.0")), + CreateInterface("Wi-Fi", true, ("192.168.1.50", "255.255.255.0")) + ]); + + Assert.Equal(["192.168.1.0/24 Ethernet", "192.168.1.0/24 Wi-Fi"], + subnets.Select(x => $"{x.Subnet} {x.InterfaceName}")); + } +} diff --git a/Source/NETworkManager.Tests/Network/PortScannerScanComparerTests.cs b/Source/NETworkManager.Tests/Network/PortScannerScanComparerTests.cs new file mode 100644 index 0000000000..1244abedd7 --- /dev/null +++ b/Source/NETworkManager.Tests/Network/PortScannerScanComparerTests.cs @@ -0,0 +1,74 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net; +using NETworkManager.Models.Lookup; +using NETworkManager.Models.Network; +using Xunit; + +namespace NETworkManager.Tests.Network; + +public class PortScannerScanComparerTests +{ + private static readonly IPAddress A = IPAddress.Parse("10.0.0.1"); + private static readonly IPAddress B = IPAddress.Parse("10.0.0.2"); + + private static PortScannerPortInfo Port(IPAddress ipAddress, int port, PortState state, string hostname = "host") => + new(ipAddress, hostname, port, new PortLookupInfo(port, TransportProtocol.Tcp), state); + + [Fact] + public void BaselineHasOnlyOpenPorts() + { + var baseline = PortScannerScanComparer.CreateBaseline([ + Port(A, 22, PortState.Open, "server"), Port(A, 80, PortState.Closed), Port(B, 443, PortState.TimedOut) + ]); + + Assert.Equal([(A, 22)], baseline.Keys); + Assert.Equal("server", baseline[(A, 22)]); + } + + [Fact] + public void SameOpenPortsHaveNoChange() + { + var baseline = PortScannerScanComparer.CreateBaseline([Port(A, 22, PortState.Open)]); + + var result = PortScannerScanComparer.Compare(baseline, [Port(A, 22, PortState.Open), Port(A, 80, PortState.Closed)]); + + Assert.Empty(result.Opened); + Assert.Empty(result.Closed); + Assert.Empty(result.Missing); + } + + [Fact] + public void PortOpenNowIsOpened() + { + var baseline = PortScannerScanComparer.CreateBaseline([Port(A, 22, PortState.Open)]); + + var result = PortScannerScanComparer.Compare(baseline, [Port(A, 22, PortState.Open), Port(A, 8000, PortState.Open)]); + + Assert.Equal([(A, 8000)], result.Opened); + } + + [Theory] + [InlineData(PortState.Closed)] + [InlineData(PortState.TimedOut)] + public void PortNotOpenNowIsClosed(PortState state) + { + var baseline = PortScannerScanComparer.CreateBaseline([Port(A, 3389, PortState.Open)]); + + var result = PortScannerScanComparer.Compare(baseline, [Port(A, 3389, state)]); + + Assert.Equal([(A, 3389)], result.Closed); + Assert.Empty(result.Missing); + } + + [Fact] + public void PortNotInTheResultIsMissing() + { + var baseline = PortScannerScanComparer.CreateBaseline([Port(B, 443, PortState.Open, "web")]); + + var result = PortScannerScanComparer.Compare(baseline, [Port(A, 22, PortState.Open)]); + + Assert.Equal([(B, 443, "web")], result.Missing); + Assert.Equal([(A, 22)], result.Opened); + } +} diff --git a/Source/NETworkManager.Tests/Network/SNMPSystemInfoResolverTests.cs b/Source/NETworkManager.Tests/Network/SNMPSystemInfoResolverTests.cs new file mode 100644 index 0000000000..a46e44ef3b --- /dev/null +++ b/Source/NETworkManager.Tests/Network/SNMPSystemInfoResolverTests.cs @@ -0,0 +1,91 @@ +using System.Collections.Generic; +using System.Linq; +using Lextm.SharpSnmpLib; +using Lextm.SharpSnmpLib.Messaging; +using NETworkManager.Models.Network; +using Xunit; + +namespace NETworkManager.Tests.Network; + +public class SNMPSystemInfoResolverTests +{ + private const int RequestId = 4711; + + private static byte[] CreateResponse(int requestId, ErrorCode errorCode, + params (string oid, ISnmpData data)[] values) + { + var variables = new List(); + + foreach (var (oid, data) in values) + variables.Add(new Variable(new ObjectIdentifier(oid), data)); + + return new ResponseMessage(requestId, VersionCode.V2, new OctetString("public"), errorCode, 0, variables) + .ToBytes(); + } + + [Fact] + public void CreateRequest() + { + var request = SNMPSystemInfoResolver.CreateRequest(RequestId, SNMPVersion.V1, "private"); + + Assert.Equal(VersionCode.V1, request.Version); + Assert.Equal("private", request.Community().ToString()); + Assert.Equal(RequestId, request.RequestId()); + Assert.Equal( + [SNMPSystemInfoResolver.SysNameOID, SNMPSystemInfoResolver.SysDescrOID, SNMPSystemInfoResolver.SysLocationOID], + request.Variables().Select(x => x.Id.ToString())); + } + + [Fact] + public void ParseResponse() + { + var response = CreateResponse(RequestId, ErrorCode.NoError, + (SNMPSystemInfoResolver.SysNameOID, new OctetString("switch-01")), + (SNMPSystemInfoResolver.SysDescrOID, new OctetString(" Switch\r\n Firmware 1.2\r\n")), + (SNMPSystemInfoResolver.SysLocationOID, new OctetString("Server room"))); + + Assert.Equal(new SNMPSystemInfo("switch-01", "Switch Firmware 1.2", "Server room"), + SNMPSystemInfoResolver.ParseResponse(response, RequestId)); + } + + [Fact] + public void ParseResponse_NoSuchObject_Empty() + { + var response = CreateResponse(RequestId, ErrorCode.NoError, + (SNMPSystemInfoResolver.SysNameOID, new OctetString("printer")), + (SNMPSystemInfoResolver.SysDescrOID, new NoSuchObject()), + (SNMPSystemInfoResolver.SysLocationOID, new NoSuchInstance())); + + Assert.Equal(new SNMPSystemInfo("printer", string.Empty, string.Empty), + SNMPSystemInfoResolver.ParseResponse(response, RequestId)); + } + + [Fact] + public void ParseResponse_OtherRequest_Null() + { + var response = CreateResponse(RequestId + 1, ErrorCode.NoError, + (SNMPSystemInfoResolver.SysNameOID, new OctetString("switch-01"))); + + Assert.Null(SNMPSystemInfoResolver.ParseResponse(response, RequestId)); + } + + [Fact] + public void ParseResponse_Error_EmptyValues() + { + // The host answered (it is reachable), but without values + var response = CreateResponse(RequestId, ErrorCode.NoSuchName, + (SNMPSystemInfoResolver.SysNameOID, new Null())); + + Assert.Equal(new SNMPSystemInfo(string.Empty, string.Empty, string.Empty), + SNMPSystemInfoResolver.ParseResponse(response, RequestId)); + } + + [Theory] + [InlineData(new byte[] { })] + [InlineData(new byte[] { 0x30, 0x03, 0x02, 0x01 })] + [InlineData(new byte[] { 0xFF, 0xFF, 0xFF })] + public void ParseResponse_Invalid_Null(byte[] response) + { + Assert.Null(SNMPSystemInfoResolver.ParseResponse(response, RequestId)); + } +} diff --git a/Source/NETworkManager.Tests/Network/SNMPSystemInfoResolverV3Tests.cs b/Source/NETworkManager.Tests/Network/SNMPSystemInfoResolverV3Tests.cs new file mode 100644 index 0000000000..67f33d5d30 --- /dev/null +++ b/Source/NETworkManager.Tests/Network/SNMPSystemInfoResolverV3Tests.cs @@ -0,0 +1,186 @@ +using System.Collections.Generic; +using System.Linq; +using Lextm.SharpSnmpLib; +using Lextm.SharpSnmpLib.Messaging; +using Lextm.SharpSnmpLib.Security; +using NETworkManager.Models.Network; +using Xunit; + +namespace NETworkManager.Tests.Network; + +/// +/// SNMPv3 of : discovery, request, answer and report (wrong credentials). +/// +public class SNMPSystemInfoResolverV3Tests +{ + private const int MessageId = 1234; + private const int RequestId = 4711; + + private const string UnknownUserNameOID = "1.3.6.1.6.3.15.1.1.3.0"; + private const string WrongDigestOID = "1.3.6.1.6.3.15.1.1.5.0"; + private const string UnknownEngineIdOID = "1.3.6.1.6.3.15.1.1.4.0"; + + private static readonly OctetString EngineId = new(new byte[] { 0x80, 0x00, 0x1F, 0x88, 0x80, 0x01, 0x02, 0x03 }); + + private static ReportMessage CreateReport(int messageId, string oid, string username = "") + { + return new ReportMessage(VersionCode.V3, + new Header(new Integer32(messageId), new Integer32(Messenger.MaxMessageSize), default(Levels)), + new SecurityParameters(EngineId, new Integer32(1), new Integer32(100), new OctetString(username), + OctetString.Empty, OctetString.Empty), + new Scope(EngineId, OctetString.Empty, + new ReportPdu(RequestId, ErrorCode.NoError, 0, + new List { new(new ObjectIdentifier(oid), new Counter32(1)) })), + DefaultPrivacyProvider.DefaultPair, null); + } + + private static byte[] CreateResponse(int messageId, int requestId, string username, + params (string oid, ISnmpData data)[] values) + { + var variables = values.Select(x => new Variable(new ObjectIdentifier(x.oid), x.data)).ToList(); + + return new ResponseMessage(VersionCode.V3, + new Header(new Integer32(messageId), new Integer32(Messenger.MaxMessageSize), default(Levels)), + new SecurityParameters(EngineId, new Integer32(1), new Integer32(100), new OctetString(username), + OctetString.Empty, OctetString.Empty), + new Scope(EngineId, OctetString.Empty, new ResponsePdu(requestId, ErrorCode.NoError, 0, variables)), + DefaultPrivacyProvider.DefaultPair, false, null) + .ToBytes(); + } + + private static UserRegistry CreateRegistry(string username) + { + return new UserRegistry([new User(new OctetString(username), DefaultPrivacyProvider.DefaultPair)]); + } + + [Fact] + public void ParseDiscoveryResponse() + { + var report = SNMPSystemInfoResolver.ParseDiscoveryResponse( + CreateReport(MessageId, UnknownEngineIdOID).ToBytes(), MessageId); + + Assert.NotNull(report); + Assert.Equal(EngineId, report.Parameters.EngineId); + } + + [Fact] + public void ParseDiscoveryResponse_OtherMessage_Null() + { + Assert.Null(SNMPSystemInfoResolver.ParseDiscoveryResponse( + CreateReport(MessageId + 1, UnknownEngineIdOID).ToBytes(), MessageId)); + } + + [Fact] + public void ParseDiscoveryResponse_NoReport_Null() + { + // An answer of v1 / v2c (e.g. another service on the port) is not a report + var response = new ResponseMessage(RequestId, VersionCode.V2, new OctetString("public"), ErrorCode.NoError, 0, + []).ToBytes(); + + Assert.Null(SNMPSystemInfoResolver.ParseDiscoveryResponse(response, MessageId)); + } + + [Theory] + [InlineData(SNMPV3Security.NoAuthNoPriv)] + [InlineData(SNMPV3Security.AuthNoPriv)] + [InlineData(SNMPV3Security.AuthPriv)] + public void CreateRequestV3(SNMPV3Security security) + { + var privacy = SNMPV3Helper.CreatePrivacyProvider(security, SNMPV3AuthenticationProvider.SHA256, + "authpassword", SNMPV3PrivacyProvider.AES, "privpassword"); + + var request = SNMPSystemInfoResolver.CreateRequestV3(MessageId, RequestId, "monitor", privacy, + CreateReport(MessageId, UnknownEngineIdOID)); + + Assert.Equal(VersionCode.V3, request.Version); + Assert.Equal(MessageId, request.MessageId()); + Assert.Equal("monitor", request.Parameters.UserName.ToString()); + + // The engine of the agent from the discovery + Assert.Equal(EngineId, request.Parameters.EngineId); + + // Encrypted, the variables can only be checked without privacy + if (security != SNMPV3Security.AuthPriv) + Assert.Equal( + [SNMPSystemInfoResolver.SysNameOID, SNMPSystemInfoResolver.SysDescrOID, SNMPSystemInfoResolver.SysLocationOID], + request.Variables().Select(x => x.Id.ToString())); + + // Can be sent + Assert.NotEmpty(request.ToBytes()); + } + + [Fact] + public void ParseResponseV3() + { + var response = CreateResponse(MessageId, RequestId, "monitor", + (SNMPSystemInfoResolver.SysNameOID, new OctetString("switch-01")), + (SNMPSystemInfoResolver.SysDescrOID, new OctetString("Switch")), + (SNMPSystemInfoResolver.SysLocationOID, new OctetString("Server room"))); + + Assert.Equal(new SNMPSystemInfo("switch-01", "Switch", "Server room"), + SNMPSystemInfoResolver.ParseResponseV3(response, MessageId, RequestId, CreateRegistry("monitor"))); + } + + [Fact] + public void ParseResponseV3_OtherMessage_Null() + { + var response = CreateResponse(MessageId + 1, RequestId, "monitor", + (SNMPSystemInfoResolver.SysNameOID, new OctetString("switch-01"))); + + Assert.Null(SNMPSystemInfoResolver.ParseResponseV3(response, MessageId, RequestId, CreateRegistry("monitor"))); + } + + [Theory] + [InlineData(UnknownUserNameOID, SNMPV3ErrorCode.UnknownUserName)] + [InlineData(WrongDigestOID, SNMPV3ErrorCode.AuthenticationFailed)] + [InlineData(UnknownEngineIdOID, SNMPV3ErrorCode.None)] + public void ParseResponseV3_Report_Error(string oid, SNMPV3ErrorCode expected) + { + // Wrong credentials: the host answered (it is reachable), empty values with the error + var report = CreateReport(MessageId, oid, "monitor").ToBytes(); + + Assert.Equal(new SNMPSystemInfo(string.Empty, string.Empty, string.Empty, expected), + SNMPSystemInfoResolver.ParseResponseV3(report, MessageId, RequestId, CreateRegistry("monitor"))); + } + + [Theory] + [InlineData(new byte[] { })] + [InlineData(new byte[] { 0x30, 0x03, 0x02, 0x01 })] + public void ParseResponseV3_Invalid_Null(byte[] response) + { + Assert.Null(SNMPSystemInfoResolver.ParseResponseV3(response, MessageId, RequestId, CreateRegistry("monitor"))); + Assert.Null(SNMPSystemInfoResolver.ParseDiscoveryResponse(response, MessageId)); + } + + [Theory] + [InlineData(SNMPV3Security.NoAuthNoPriv, typeof(DefaultPrivacyProvider))] + [InlineData(SNMPV3Security.AuthNoPriv, typeof(DefaultPrivacyProvider))] + [InlineData(SNMPV3Security.AuthPriv, typeof(AESPrivacyProvider))] + public void CreatePrivacyProvider(SNMPV3Security security, System.Type expected) + { + var privacy = SNMPV3Helper.CreatePrivacyProvider(security, SNMPV3AuthenticationProvider.SHA1, "authpassword", + SNMPV3PrivacyProvider.AES, "privpassword"); + + Assert.IsType(expected, privacy); + Assert.Equal(security == SNMPV3Security.NoAuthNoPriv, + privacy.AuthenticationProvider is DefaultAuthenticationProvider); + } + + [Fact] + public void Credentials_ToString_WithoutSecrets() + { + var credentials = new SNMPCredentials(SNMPVersion.V3) + { + Username = "monitor", + Auth = "secret-auth", + Priv = "secret-priv", + Community = "secret-community" + }; + + var text = credentials.ToString(); + + Assert.Contains("monitor", text); + Assert.DoesNotContain("secret", text); + Assert.DoesNotContain("secret", SNMPCredentials.FromCommunity(SNMPVersion.V2C, "secret-community").ToString()); + } +} diff --git a/Source/NETworkManager.Tests/Network/WebCertificateInfoTests.cs b/Source/NETworkManager.Tests/Network/WebCertificateInfoTests.cs new file mode 100644 index 0000000000..e5ef2fd4ca --- /dev/null +++ b/Source/NETworkManager.Tests/Network/WebCertificateInfoTests.cs @@ -0,0 +1,66 @@ +using System; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using NETworkManager.Models.Network; +using Xunit; + +namespace NETworkManager.Tests.Network; + +public class WebCertificateInfoTests +{ + private static X509Certificate2 CreateSelfSigned(string subject, DateTimeOffset notAfter, bool isCA = false) + { + using var key = RSA.Create(2048); + + var request = new CertificateRequest($"CN={subject}", key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + + // Required to issue certificates with it + if (isCA) + request.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true)); + + return request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), notAfter); + } + + [Fact] + public void FromCertificate_SelfSigned() + { + var notAfter = new DateTimeOffset(2030, 1, 2, 3, 4, 5, TimeSpan.Zero); + + using var certificate = CreateSelfSigned("FRITZ!Box", notAfter); + + var info = WebCertificateInfo.FromCertificate(certificate); + + Assert.Equal("FRITZ!Box", info.Subject); + Assert.Equal("FRITZ!Box", info.Issuer); + Assert.True(info.IsSelfSigned); + Assert.Equal(notAfter.UtcDateTime, info.NotAfter); + Assert.Equal(DateTimeKind.Utc, info.NotAfter.Kind); + Assert.False(info.IsExpired); + } + + [Fact] + public void FromCertificate_IssuedByCA() + { + using var ca = CreateSelfSigned("Example CA", DateTimeOffset.UtcNow.AddYears(5), true); + using var key = RSA.Create(2048); + + var request = new CertificateRequest("CN=nas.example.com", key, HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + + using var certificate = request.Create(ca, DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(30), + [1, 2, 3, 4]); + + var info = WebCertificateInfo.FromCertificate(certificate); + + Assert.Equal("nas.example.com", info.Subject); + Assert.Equal("Example CA", info.Issuer); + Assert.False(info.IsSelfSigned); + } + + [Fact] + public void IsExpired() + { + Assert.True(new WebCertificateInfo("a", "a", DateTime.UtcNow.AddDays(-1), true).IsExpired); + Assert.False(new WebCertificateInfo("a", "a", DateTime.UtcNow.AddDays(1), true).IsExpired); + } +} diff --git a/Source/NETworkManager.Tests/Network/WebConsoleUrlHelperTests.cs b/Source/NETworkManager.Tests/Network/WebConsoleUrlHelperTests.cs index 20f87c4a5f..7742edc9bd 100644 --- a/Source/NETworkManager.Tests/Network/WebConsoleUrlHelperTests.cs +++ b/Source/NETworkManager.Tests/Network/WebConsoleUrlHelperTests.cs @@ -53,4 +53,15 @@ public void DetectsWebPorts(int port, bool expected) { Assert.Equal(expected, WebConsoleUrlHelper.IsWebPort(port)); } + + [Theory] + [InlineData("10.0.0.1", 443, "https", "https://10.0.0.1/")] + [InlineData("10.0.0.1", 443, "http", "http://10.0.0.1:443/")] + [InlineData("10.0.0.1", 80, "https", "https://10.0.0.1:80/")] + [InlineData("10.0.0.1", 9443, "https", "https://10.0.0.1:9443/")] + [InlineData("2001:db8::1", 8081, "http", "http://[2001:db8::1]:8081/")] + public void CreateUrl_Scheme(string host, int port, string scheme, string expected) + { + Assert.Equal(expected, WebConsoleUrlHelper.CreateUrl(host, port, scheme)); + } } diff --git a/Source/NETworkManager.Tests/Network/WebPageResolverTests.cs b/Source/NETworkManager.Tests/Network/WebPageResolverTests.cs new file mode 100644 index 0000000000..88a8f47156 --- /dev/null +++ b/Source/NETworkManager.Tests/Network/WebPageResolverTests.cs @@ -0,0 +1,129 @@ +using System.Text; +using NETworkManager.Models.Network; +using Xunit; + +namespace NETworkManager.Tests.Network; + +public class WebPageResolverTests +{ + [Theory] + [InlineData("Router Login", "Router Login")] + [InlineData("Upper case", "Upper case")] + [InlineData("With attribute", "With attribute")] + [InlineData("\r\n Multi\r\n line \r\n", "Multi line")] + [InlineData("Tom & Jerry – "NAS"", "Tom & Jerry – \"NAS\"")] + [InlineData("FirstSecond", "First")] + [InlineData("No title", "")] + [InlineData("Not closed", "")] + [InlineData("", "")] + [InlineData(null, "")] + public void ParseTitle(string html, string expected) + { + Assert.Equal(expected, WebPageResolver.ParseTitle(html)); + } + + [Fact] + public void ParseTitle_TooLong_Shortened() + { + var title = WebPageResolver.ParseTitle($"<title>{new string('a', 500)}"); + + Assert.Equal(new string('a', 200) + "...", title); + } + + [Theory] + [InlineData("nginx/1.24.0", "nginx/1.24.0")] + [InlineData(" lighttpd 1.4 ", "lighttpd 1.4")] + [InlineData("PHP/8.2\r\nX-Injected: 1", "PHP/8.2X-Injected: 1")] + [InlineData("", "")] + [InlineData(null, "")] + public void CleanHeaderValue(string value, string expected) + { + Assert.Equal(expected, WebPageResolver.CleanHeaderValue(value)); + } + + [Theory] + [InlineData(new[] { "Basic realm=\"RT-AC68U\"" }, "RT-AC68U")] + [InlineData(new[] { "Digest realm=\"IP Camera\", nonce=\"abc\", qop=\"auth\"" }, "IP Camera")] + [InlineData(new[] { "Basic Realm=NAS" }, "NAS")] + [InlineData(new[] { "Negotiate", "Basic realm=\"Printer\"" }, "Printer")] + [InlineData(new[] { "Basic realm=\"\"" }, "")] + [InlineData(new[] { "Bearer" }, "")] + [InlineData(new string[0], "")] + public void ParseRealm(string[] headerValues, string expected) + { + Assert.Equal(expected, WebPageResolver.ParseRealm(headerValues)); + } + + [Theory] + [InlineData(443, "https", 2000, 2000)] + [InlineData(8443, "https", 2000, 2000)] + [InlineData(9443, "https", 2000, 1000)] + [InlineData(9443, "https", 500, 500)] + [InlineData(9443, "http", 2000, 2000)] + [InlineData(80, "http", 3000, 3000)] + public void GetTimeout(int port, string scheme, int timeout, int expected) + { + Assert.Equal(expected, WebPageResolver.GetTimeout(port, scheme, timeout)); + } + + [Theory] + [InlineData(80, new[] { "http" })] + [InlineData(8080, new[] { "http" })] + [InlineData(443, new[] { "https" })] + [InlineData(8443, new[] { "https" })] + [InlineData(9000, new[] { "https", "http" })] + public void GetSchemes(int port, string[] expected) + { + Assert.Equal(expected, WebPageResolver.GetSchemes(port)); + } + + [Fact] + public void Decode_DefaultUTF8() + { + Assert.Equal("Grüße", + WebPageResolver.Decode(Encoding.UTF8.GetBytes("Grüße"), null)); + } + + [Fact] + public void Decode_CharsetOfHeader() + { + Assert.Equal("Grüße", + WebPageResolver.Decode(Encoding.Latin1.GetBytes("Grüße"), "iso-8859-1")); + } + + [Fact] + public void Decode_CharsetOfMetaTag() + { + const string html = "Grüße"; + + Assert.Equal(html, WebPageResolver.Decode(Encoding.Latin1.GetBytes(html), null)); + } + + [Fact] + public void Decode_CodePageCharset() + { + // "Büro-Drucker €" in windows-1252 (not built into .NET, registered by WebPageResolver) + byte[] content = + [.. Encoding.ASCII.GetBytes("B"), 0xFC, .. Encoding.ASCII.GetBytes("ro-Drucker "), 0x80, + .. Encoding.ASCII.GetBytes("")]; + + Assert.Equal("Büro-Drucker €", WebPageResolver.Decode(content, "windows-1252")); + } + + [Fact] + public void Decode_CodePageCharsetOfMetaTag() + { + byte[] content = + [.. Encoding.ASCII.GetBytes("B"), 0xFC, + .. Encoding.ASCII.GetBytes("ro")]; + + Assert.Equal("Büro", WebPageResolver.Decode(content, null)); + } + + [Fact] + public void Decode_UnknownCharset_UTF8() + { + Assert.Equal("Test", + WebPageResolver.Decode(Encoding.UTF8.GetBytes("Test"), "unknown-charset")); + } +} diff --git a/Source/NETworkManager.Tests/NotificationStatusHelperTests.cs b/Source/NETworkManager.Tests/NotificationStatusHelperTests.cs new file mode 100644 index 0000000000..e2e99b2277 --- /dev/null +++ b/Source/NETworkManager.Tests/NotificationStatusHelperTests.cs @@ -0,0 +1,22 @@ +using NETworkManager.Models; +using Xunit; + +namespace NETworkManager.Tests; + +public class NotificationStatusHelperTests +{ + [Theory] + [InlineData(1, 0, 0, NotificationStatus.Up)] + [InlineData(5, 0, 0, NotificationStatus.Up)] + [InlineData(0, 1, 0, NotificationStatus.Down)] + [InlineData(1, 1, 0, NotificationStatus.UpAndDown)] + [InlineData(2, 3, 1, NotificationStatus.UpAndDown)] + [InlineData(0, 0, 1, NotificationStatus.Changed)] + [InlineData(1, 0, 1, NotificationStatus.Changed)] + [InlineData(0, 1, 1, NotificationStatus.Changed)] + [InlineData(0, 0, 0, NotificationStatus.Changed)] + public void GetsTheStatusOfTheChanges(int additions, int removals, int otherChanges, NotificationStatus expected) + { + Assert.Equal(expected, NotificationStatusHelper.FromChanges(additions, removals, otherChanges)); + } +} diff --git a/Source/NETworkManager.Tests/Profiles/IPScannerSNMPCredentialsTests.cs b/Source/NETworkManager.Tests/Profiles/IPScannerSNMPCredentialsTests.cs new file mode 100644 index 0000000000..759c49e5dc --- /dev/null +++ b/Source/NETworkManager.Tests/Profiles/IPScannerSNMPCredentialsTests.cs @@ -0,0 +1,151 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; +using NETworkManager.Models.Network; +using NETworkManager.Profiles; +using NETworkManager.Utilities; +using Xunit; +using IPScannerProfile = NETworkManager.Profiles.Application.IPScanner; + +namespace NETworkManager.Tests.Profiles; + +/// +/// SNMP override of the IP Scanner in profiles and groups: the profile before its group, the group before the +/// settings, and the secrets in the profile file. +/// +public class IPScannerSNMPCredentialsTests +{ + // Same options as ProfileManager + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.Never, + Converters = { new JsonStringEnumConverter() } + }; + + private static ProfileInfo CreateProfile(bool overrideSNMP) + { + return new ProfileInfo + { + Name = "Switches", + Group = "Site A", + IPScanner_OverrideSNMP = overrideSNMP, + IPScanner_SNMPVersion = SNMPVersion.V2C, + IPScanner_SNMPCommunity = SecureStringHelper.ConvertToSecureString("profile-community") + }; + } + + private static GroupInfo CreateGroup(bool overrideSNMP) + { + return new GroupInfo + { + Name = "Site A", + Profiles = [], + IPScanner_OverrideSNMP = overrideSNMP, + IPScanner_SNMPVersion = SNMPVersion.V3, + IPScanner_SNMPSecurity = SNMPV3Security.AuthPriv, + IPScanner_SNMPUsername = "monitor", + IPScanner_SNMPAuthenticationProvider = SNMPV3AuthenticationProvider.SHA256, + IPScanner_SNMPAuth = SecureStringHelper.ConvertToSecureString("group-auth"), + IPScanner_SNMPPrivacyProvider = SNMPV3PrivacyProvider.AES256, + IPScanner_SNMPPriv = SecureStringHelper.ConvertToSecureString("group-priv") + }; + } + + [Fact] + public void Profile_BeforeGroup() + { + var credentials = IPScannerProfile.GetSNMPCredentials(CreateProfile(true), CreateGroup(true)).ToCredentials(); + + Assert.Equal(SNMPVersion.V2C, credentials.Version); + Assert.Equal("profile-community", credentials.Community); + } + + [Fact] + public void Group_IfProfileDoesNotOverride() + { + var credentials = IPScannerProfile.GetSNMPCredentials(CreateProfile(false), CreateGroup(true)).ToCredentials(); + + Assert.Equal(SNMPVersion.V3, credentials.Version); + Assert.Equal(SNMPV3Security.AuthPriv, credentials.Security); + Assert.Equal("monitor", credentials.Username); + Assert.Equal(SNMPV3AuthenticationProvider.SHA256, credentials.AuthenticationProvider); + Assert.Equal("group-auth", credentials.Auth); + Assert.Equal(SNMPV3PrivacyProvider.AES256, credentials.PrivacyProvider); + Assert.Equal("group-priv", credentials.Priv); + } + + [Fact] + public void Protected_UntilTheScan() + { + var group = CreateGroup(true); + + var credentials = IPScannerProfile.GetSNMPCredentials(CreateProfile(false), group); + + // Copies of the passwords (a change of the profile doesn't change an open tab), no secrets in ToString + Assert.NotSame(group.IPScanner_SNMPAuth, credentials.Auth); + Assert.Equal("group-auth", SecureStringHelper.ConvertToString(credentials.Auth)); + Assert.DoesNotContain("group-", credentials.ToString()); + Assert.Contains("monitor", credentials.ToString()); + } + + [Fact] + public void Settings_IfNeitherOverrides() + { + Assert.Null(IPScannerProfile.GetSNMPCredentials(CreateProfile(false), CreateGroup(false))); + Assert.Null(IPScannerProfile.GetSNMPCredentials(CreateProfile(false), null)); + } + + [Fact] + public void Secrets_SavedAndLoaded() + { + var group = CreateGroup(true); + group.Profiles = [CreateProfile(true)]; + + var json = JsonSerializer.Serialize(new ProfileFileData { Groups = [group] }, JsonOptions); + var loaded = JsonSerializer.Deserialize(json, JsonOptions); + + var loadedGroup = loaded.Groups.Single(); + var loadedProfile = loadedGroup.Profiles.Single(); + + Assert.True(loadedGroup.IPScanner_OverrideSNMP); + Assert.Equal("monitor", loadedGroup.IPScanner_SNMPUsername); + Assert.Equal("group-auth", SecureStringHelper.ConvertToString(loadedGroup.IPScanner_SNMPAuth)); + Assert.Equal("group-priv", SecureStringHelper.ConvertToString(loadedGroup.IPScanner_SNMPPriv)); + + Assert.True(loadedProfile.IPScanner_OverrideSNMP); + Assert.Equal("profile-community", SecureStringHelper.ConvertToString(loadedProfile.IPScanner_SNMPCommunity)); + + // Not set: not saved as a secret + Assert.Null(loadedProfile.IPScanner_SNMPAuth); + } + + [Fact] + public void CopyConstructors_KeepTheOverride() + { + var profile = new ProfileInfo(CreateProfile(true)); + var group = new GroupInfo(CreateGroup(true)); + + Assert.True(profile.IPScanner_OverrideSNMP); + Assert.Equal("profile-community", SecureStringHelper.ConvertToString(profile.IPScanner_SNMPCommunity)); + Assert.True(group.IPScanner_OverrideSNMP); + Assert.Equal("group-priv", SecureStringHelper.ConvertToString(group.IPScanner_SNMPPriv)); + } + + [Fact] + public void OldProfileFile_WithoutOverride() + { + // A profile file of an older version has no IP Scanner SNMP values + const string json = """{ "Groups": [ { "Name": "Site A", "Profiles": [ { "Name": "Switches" } ] } ] }"""; + + var loaded = JsonSerializer.Deserialize(json, JsonOptions); + + var profile = loaded.Groups.Single().Profiles.Single(); + + Assert.False(profile.IPScanner_OverrideSNMP); + Assert.Null(IPScannerProfile.GetSNMPCredentials(profile, loaded.Groups.Single())); + Assert.Equal(new List(), [.. loaded.Groups.Where(x => x.IPScanner_OverrideSNMP).Select(x => x.Name)]); + } +} diff --git a/Source/NETworkManager.Tests/RunCommand/RunCommandTargetTests.cs b/Source/NETworkManager.Tests/RunCommand/RunCommandTargetTests.cs index 32ad9bc0ce..618eafbe75 100644 --- a/Source/NETworkManager.Tests/RunCommand/RunCommandTargetTests.cs +++ b/Source/NETworkManager.Tests/RunCommand/RunCommandTargetTests.cs @@ -17,6 +17,10 @@ public class RunCommandTargetTests [InlineData("10.0.0.1-10.0.0.100", RunCommandTargetKind.IPRange)] [InlineData("10.0.0.1-100", RunCommandTargetKind.IPRange)] [InlineData("10.0.[0-1].1", RunCommandTargetKind.IPRange)] + // Exclusions (e.g. "!10.0.0.1") make a list a range for the scanners + [InlineData("10.0.0.0/24;!10.0.0.1", RunCommandTargetKind.IPRange)] + [InlineData("10.0.0.1;10.0.0.2;!10.0.0.2", RunCommandTargetKind.IPRange)] + [InlineData("server.borntoberoot.net;!10.0.0.1", RunCommandTargetKind.IPRange)] [InlineData("10.0.0.0/24", RunCommandTargetKind.Subnet)] [InlineData("10.0.0.0/255.255.255.0", RunCommandTargetKind.Subnet)] [InlineData("2001:db8::/32", RunCommandTargetKind.Subnet)] @@ -68,6 +72,11 @@ public void HostsWithHostnames(string target) // Only host names without a domain can be any words (e.g. copied text) [InlineData("server01;server02")] [InlineData("foo;")] + [InlineData("foo;!bar")] + // Only exclusions, an invalid exclusion + [InlineData("!10.0.0.1")] + [InlineData("!10.0.0.1;!10.0.0.2")] + [InlineData("10.0.0.0/24;!server_01")] // Ports only from 1 to 65535 [InlineData("0")] [InlineData("99999")] diff --git a/Source/NETworkManager.Tests/Utilities/AutoRefreshTimeTests.cs b/Source/NETworkManager.Tests/Utilities/AutoRefreshTimeTests.cs new file mode 100644 index 0000000000..afff1d2300 --- /dev/null +++ b/Source/NETworkManager.Tests/Utilities/AutoRefreshTimeTests.cs @@ -0,0 +1,27 @@ +using System; +using NETworkManager.Utilities; +using Xunit; + +namespace NETworkManager.Tests.Utilities; + +public class AutoRefreshTimeTests +{ + [Theory] + [InlineData(5, TimeUnit.Second, 5)] + [InlineData(30, TimeUnit.Second, 30)] + [InlineData(1, TimeUnit.Minute, 60)] + [InlineData(5, TimeUnit.Minute, 300)] + [InlineData(1, TimeUnit.Hour, 3600)] + [InlineData(2, TimeUnit.Hour, 7200)] + public void CalculatesTimeSpan(int value, TimeUnit timeUnit, int expectedSeconds) + { + Assert.Equal(TimeSpan.FromSeconds(expectedSeconds), + AutoRefreshTime.CalculateTimeSpan(new AutoRefreshTimeInfo(value, timeUnit))); + } + + [Fact] + public void ThrowsForNoTimeUnit() + { + Assert.Throws(() => AutoRefreshTime.CalculateTimeSpan(new AutoRefreshTimeInfo(1, TimeUnit.None))); + } +} diff --git a/Source/NETworkManager.Tests/Utilities/DateTimeHelperTests.cs b/Source/NETworkManager.Tests/Utilities/DateTimeHelperTests.cs new file mode 100644 index 0000000000..d7428c2330 --- /dev/null +++ b/Source/NETworkManager.Tests/Utilities/DateTimeHelperTests.cs @@ -0,0 +1,23 @@ +using System; +using NETworkManager.Utilities; +using Xunit; + +namespace NETworkManager.Tests.Utilities; + +public class DateTimeHelperTests +{ + [Theory] + [InlineData(0, "00:00")] + [InlineData(5, "00:05")] + [InlineData(59, "00:59")] + [InlineData(272, "04:32")] + [InlineData(3599, "59:59")] + [InlineData(3600, "1:00:00")] + [InlineData(3725, "1:02:05")] + [InlineData(90000, "25:00:00")] + [InlineData(-3, "00:00")] + public void ConvertsTimeSpanToCountdown(int seconds, string expected) + { + Assert.Equal(expected, DateTimeHelper.TimeSpanToCountdownString(TimeSpan.FromSeconds(seconds))); + } +} diff --git a/Source/NETworkManager.Tests/Utilities/TaskHelperTests.cs b/Source/NETworkManager.Tests/Utilities/TaskHelperTests.cs new file mode 100644 index 0000000000..6821ff6d04 --- /dev/null +++ b/Source/NETworkManager.Tests/Utilities/TaskHelperTests.cs @@ -0,0 +1,46 @@ +using System; +using System.Diagnostics; +using System.Threading.Tasks; +using NETworkManager.Utilities; +using Xunit; + +namespace NETworkManager.Tests.Utilities; + +public class TaskHelperTests +{ + private const int Minimum = 300; + + [Fact] + public async Task FastActionTakesTheMinimum() + { + var stopwatch = Stopwatch.StartNew(); + + var result = await TaskHelper.WithMinimumDurationAsync(() => Task.FromResult(42), Minimum); + + Assert.Equal(42, result); + Assert.True(stopwatch.ElapsedMilliseconds >= Minimum - 20, $"{stopwatch.ElapsedMilliseconds} ms"); + } + + [Fact] + public async Task SlowActionIsNotDelayedFurther() + { + var stopwatch = Stopwatch.StartNew(); + + await TaskHelper.WithMinimumDurationAsync(() => Task.Delay(Minimum * 2), Minimum); + + // Minimum and action run at the same time: ~600 ms, not ~900 ms + Assert.True(stopwatch.ElapsedMilliseconds >= Minimum * 2 - 20, $"{stopwatch.ElapsedMilliseconds} ms"); + Assert.True(stopwatch.ElapsedMilliseconds < Minimum * 3 - 50, $"{stopwatch.ElapsedMilliseconds} ms"); + } + + [Fact] + public async Task ExceptionIsThrownAfterTheMinimum() + { + var stopwatch = Stopwatch.StartNew(); + + await Assert.ThrowsAsync(() => + TaskHelper.WithMinimumDurationAsync(() => throw new InvalidOperationException(), Minimum)); + + Assert.True(stopwatch.ElapsedMilliseconds >= Minimum - 20, $"{stopwatch.ElapsedMilliseconds} ms"); + } +} diff --git a/Source/NETworkManager.Utilities.WPF/AutoRefreshTimer.cs b/Source/NETworkManager.Utilities.WPF/AutoRefreshTimer.cs new file mode 100644 index 0000000000..e0636c8c70 --- /dev/null +++ b/Source/NETworkManager.Utilities.WPF/AutoRefreshTimer.cs @@ -0,0 +1,202 @@ +using System; +using System.ComponentModel; +using System.Runtime.CompilerServices; +using System.Threading.Tasks; +using System.Windows.Threading; + +namespace NETworkManager.Utilities.WPF; + +/// +/// Refreshes a view automatically (e.g. "Automatically update every" in Connections). The interval is the pause +/// after a refresh: the timer is stopped while refreshing, so refreshes never overlap. Paused while the view is +/// hidden (see ). Provides the time until the next refresh for a countdown +/// (). +/// +public sealed class AutoRefreshTimer : INotifyPropertyChanged +{ + private readonly DispatcherTimer _timer = new(); + private readonly DispatcherTimer _countdownTimer = new() { Interval = TimeSpan.FromSeconds(1) }; + private readonly Func _refresh; + private readonly Func _canRefresh; + + private bool _isPaused; + private bool _isRefreshing; + + /// + /// Creates a new auto refresh timer (disabled). Must be created on the UI thread. + /// + /// Refreshes the view. + /// + /// Optional: false skips a refresh (e.g. a manual refresh is running), it is tried again after the interval. + /// + public AutoRefreshTimer(Func refresh, Func canRefresh = null) + { + _refresh = refresh; + _canRefresh = canRefresh; + + _timer.Tick += Timer_Tick; + _countdownTimer.Tick += (_, _) => UpdateRemainingTime(); + } + + /// + /// Gets or sets a value indicating whether the view is refreshed automatically. Enabling starts the interval. + /// + public bool IsEnabled + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + + Restart(); + } + } + + /// + /// Gets or sets the time between the end of a refresh and the next one. A change starts the interval again. + /// + public TimeSpan Interval + { + get => _timer.Interval; + set + { + if (value == _timer.Interval) + return; + + _timer.Interval = value; + OnPropertyChanged(); + + Restart(); + } + } + + /// + /// Gets the time of the next refresh, null if no refresh is planned (disabled, paused or refreshing). + /// + public DateTime? NextRefresh + { + get; + private set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } + + /// + /// Gets the time until the next refresh (updated every second, for a countdown), null if no refresh is planned. + /// + public TimeSpan? RemainingTime + { + get; + private set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } + + /// + public event PropertyChangedEventHandler PropertyChanged; + + /// + /// Pauses the timer (the view was hidden). starts the interval again. + /// + public void Pause() + { + _isPaused = true; + + Restart(); + } + + /// + /// Resumes the timer after (the view is visible again) with the full interval. + /// + public void Resume() + { + _isPaused = false; + + Restart(); + } + + /// + /// Starts the interval again if a refresh should be planned (enabled, not paused, not refreshing), otherwise + /// stops the timer. + /// + private void Restart() + { + _timer.Stop(); + + // While refreshing, the timer is started when the refresh is finished (see Timer_Tick) + if (IsEnabled && !_isPaused && !_isRefreshing && _timer.Interval > TimeSpan.Zero) + { + _timer.Start(); + + NextRefresh = DateTime.Now + _timer.Interval; + _countdownTimer.Start(); + } + else + { + NextRefresh = null; + _countdownTimer.Stop(); + } + + UpdateRemainingTime(); + } + + private void UpdateRemainingTime() + { + if (NextRefresh is not { } nextRefresh) + { + RemainingTime = null; + return; + } + + // Rounded up to full seconds: the countdown shows 00:01 until the refresh starts, not 00:00 + var remaining = nextRefresh - DateTime.Now; + + RemainingTime = remaining > TimeSpan.Zero + ? TimeSpan.FromSeconds(Math.Ceiling(remaining.TotalSeconds)) + : TimeSpan.Zero; + } + + private async void Timer_Tick(object sender, EventArgs e) + { + // Try again after the interval (e.g. a manual refresh is running) + if (_canRefresh?.Invoke() == false) + { + Restart(); + return; + } + + _isRefreshing = true; + Restart(); + + try + { + await _refresh(); + } + finally + { + _isRefreshing = false; + + // Unless auto refresh was disabled or the view was hidden in the meantime + Restart(); + } + } + + private void OnPropertyChanged([CallerMemberName] string propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } +} diff --git a/Source/NETworkManager.Utilities.WPF/ReloadAnimationHelper.cs b/Source/NETworkManager.Utilities.WPF/ReloadAnimationHelper.cs index 28e6a31f31..c3876d5989 100644 --- a/Source/NETworkManager.Utilities.WPF/ReloadAnimationHelper.cs +++ b/Source/NETworkManager.Utilities.WPF/ReloadAnimationHelper.cs @@ -6,8 +6,18 @@ namespace NETworkManager.Utilities.WPF; +/// +/// Spins the icon of a refresh button while IsReloading is true: one turn per second, repeated until the +/// reload is finished - also if it takes longer (e.g. the Firewall rules). When it is finished, the current turn is +/// completed instead of snapping back to the start. +/// public static class ReloadAnimationHelper { + /// + /// Duration of one turn. + /// + private static readonly TimeSpan TurnDuration = TimeSpan.FromSeconds(1); + public static readonly DependencyProperty IsReloadingProperty = DependencyProperty.RegisterAttached( "IsReloading", @@ -28,21 +38,60 @@ private static void OnIsReloadingChanged(DependencyObject d, DependencyPropertyC if (isReloading) { - var rotate = new RotateTransform + // Continue a turn that is still being completed (reloaded again right after the last reload) + if (rect.RenderTransform is not RotateTransform rotate) { - CenterX = rect.Width / 2, - CenterY = rect.Height / 2 - }; + rotate = new RotateTransform(); + rect.RenderTransform = rotate; + } - rect.RenderTransform = rotate; + rotate.CenterX = rect.Width / 2; + rotate.CenterY = rect.Height / 2; - var animation = new DoubleAnimation(0, 720, new Duration(TimeSpan.FromSeconds(2))); + var from = rotate.Angle % 360; - rotate.BeginAnimation(RotateTransform.AngleProperty, animation); + rotate.BeginAnimation(RotateTransform.AngleProperty, + new DoubleAnimation(from, from + 360, new Duration(TurnDuration)) + { + RepeatBehavior = RepeatBehavior.Forever + }); } else { - rect.RenderTransform = null; + if (rect.RenderTransform is not RotateTransform rotate) + return; + + // Complete the current turn with the same speed + var angle = rotate.Angle % 360; + + if (angle <= 0) + { + Reset(rect, rotate); + return; + } + + var finish = new DoubleAnimation(angle, 360, + new Duration(TimeSpan.FromSeconds((360 - angle) / 360 * TurnDuration.TotalSeconds))); + + finish.Completed += (_, _) => + { + // Not if it is reloading again in the meantime + if (!GetIsReloading(rect)) + Reset(rect, rotate); + }; + + rotate.BeginAnimation(RotateTransform.AngleProperty, finish); } } + + /// + /// Removes the animation and the rotation of the icon. + /// + private static void Reset(Rectangle rect, RotateTransform rotate) + { + rotate.BeginAnimation(RotateTransform.AngleProperty, null); + + if (ReferenceEquals(rect.RenderTransform, rotate)) + rect.RenderTransform = null; + } } diff --git a/Source/NETworkManager.Utilities.WPF/ScanRepeater.cs b/Source/NETworkManager.Utilities.WPF/ScanRepeater.cs new file mode 100644 index 0000000000..788135c223 --- /dev/null +++ b/Source/NETworkManager.Utilities.WPF/ScanRepeater.cs @@ -0,0 +1,200 @@ +using System; +using System.ComponentModel; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading.Tasks; +using System.Windows.Data; + +namespace NETworkManager.Utilities.WPF; + +/// +/// Repeats a scan automatically ("Automatically scan every" in the IP Scanner and Port Scanner): after a completed +/// scan of the input, the next scan starts after the interval (the pause after a scan, the timer waits until the +/// scan it started is finished). A manual scan or a changed input is not repeated until it was scanned (see +/// ). The checkbox is per tab (not saved), the interval is saved. +/// +public sealed class ScanRepeater : INotifyPropertyChanged +{ + private readonly Func _startRepeatedScan; + private readonly Func _isScanRunning; + private readonly Action _saveInterval; + + // The last scan of the input was completed - it is repeated (if enabled) + private bool _isArmed; + private bool _isClosed; + + // The input was changed while a scan was running: that scan is not repeated (the next one would scan the new + // input without its confirmation, mixed with the rows of the old input) + private bool _isInputChangedDuringScan; + + // Completed by OnScanEnded, so the timer waits for the end of the scan it started + private TaskCompletionSource _scanCompletion; + + /// + /// Creates a new scan repeater (disabled). Must be created on the UI thread. + /// + /// Starts the repeated scan (returns when it is running or didn't start). + /// Checks if a scan (or rescan) is running - no repeated scan then. + /// Saved interval, the first interval if it is not in the list. + /// Saves a changed interval. + public ScanRepeater(Func startRepeatedScan, Func isScanRunning, AutoRefreshTimeInfo savedInterval, + Action saveInterval) + { + _startRepeatedScan = startRepeatedScan; + _isScanRunning = isScanRunning; + + Timer = new AutoRefreshTimer(RepeatAsync, () => !_isScanRunning()); + + Times = CollectionViewSource.GetDefaultView(AutoRefreshTime.GetScanDefaults); + + var times = Times.SourceCollection.Cast().ToList(); + + // Not in the list (e.g. a removed interval of an old settings file): the first one + SelectedTime = times.FirstOrDefault(x => + x.Value == savedInterval?.Value && x.TimeUnit == savedInterval?.TimeUnit) ?? times.First(); + + // Set after the first interval, so loading doesn't save it + _saveInterval = saveInterval; + } + + /// + /// Gets the timer, e.g. for the countdown to the next scan (). + /// + public AutoRefreshTimer Timer { get; } + + /// + /// Gets the intervals to choose from. + /// + public ICollectionView Times { get; } + + /// + /// Gets or sets a value indicating whether the scan is repeated (checkbox, per tab and not saved). Starts with + /// the next completed scan. + /// + public bool IsEnabled + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + + Update(); + } + } + + /// + /// Gets or sets the interval (the pause after a scan, saved). + /// + public AutoRefreshTimeInfo SelectedTime + { + get; + set + { + if (value == field || value == null) + return; + + field = value; + OnPropertyChanged(); + + Timer.Interval = AutoRefreshTime.CalculateTimeSpan(value); + _saveInterval?.Invoke(value); + } + } + + /// + /// Stops repeating until the next completed scan: a manual scan is started. + /// + public void Disarm() + { + _isArmed = false; + _isInputChangedDuringScan = false; + + Update(); + } + + /// + /// The input was changed (e.g. typed or selected from the history, also while a scan is running): not repeated + /// until it was scanned. + /// + public void OnInputChanged() + { + _isArmed = false; + + if (_isScanRunning()) + _isInputChangedDuringScan = true; + + Update(); + } + + /// + /// A scan ended (not a rescan of a part of the result). A completed scan is repeated; a repeated scan that + /// failed (e.g. a hostname could not be resolved once) is tried again after the interval, so a short network + /// problem doesn't end the monitoring. A canceled scan or a failed manual scan is not repeated. + /// + /// The scan of the input was completed (not canceled, no error). + /// The scan was canceled by the user. + public void OnScanEnded(bool isCompleted, bool isCanceled) + { + var isRepeatedScan = _scanCompletion != null; + + _isArmed = !_isInputChangedDuringScan && !isCanceled && (isCompleted || (isRepeatedScan && _isArmed)); + _isInputChangedDuringScan = false; + + Update(); + + // The timer waits for the end of the scan (see RepeatAsync) + _scanCompletion?.TrySetResult(); + _scanCompletion = null; + } + + /// + /// The tab is closed: no repeated scan in the background. + /// + public void Close() + { + _isClosed = true; + + Update(); + + _scanCompletion?.TrySetResult(); + _scanCompletion = null; + } + + private void Update() + { + Timer.IsEnabled = IsEnabled && _isArmed && !_isClosed; + } + + /// + /// Starts the repeated scan (tick of the timer) and waits until it is finished, so the interval starts after it. + /// + private async Task RepeatAsync() + { + var scanCompletion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _scanCompletion = scanCompletion; + + await _startRepeatedScan(); + + // Not started (e.g. no probe enabled, a port profile was deleted - the message is shown): tried again after the + // interval, like a failed scan (see OnScanEnded) + if (!_isScanRunning() && !scanCompletion.Task.IsCompleted) + { + scanCompletion.TrySetResult(); + _scanCompletion = null; + } + + await scanCompletion.Task; + } + + /// + public event PropertyChangedEventHandler PropertyChanged; + + private void OnPropertyChanged([CallerMemberName] string propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } +} diff --git a/Source/NETworkManager.Utilities/AutoRefreshTime.cs b/Source/NETworkManager.Utilities/AutoRefreshTime.cs index 2919b22e88..a7c514ae91 100644 --- a/Source/NETworkManager.Utilities/AutoRefreshTime.cs +++ b/Source/NETworkManager.Utilities/AutoRefreshTime.cs @@ -20,6 +20,19 @@ public static class AutoRefreshTime new(5, TimeUnit.Minute) }; + /// + /// Method returns a list with s to repeat a scan (IP Scanner, Port Scanner): a + /// scan takes seconds to minutes, the interval is the pause after it. + /// + public static IEnumerable GetScanDefaults => new List + { + new(1, TimeUnit.Minute), + new(5, TimeUnit.Minute), + new(15, TimeUnit.Minute), + new(30, TimeUnit.Minute), + new(1, TimeUnit.Hour) + }; + /// /// Method to calculate a based on . /// @@ -27,20 +40,12 @@ public static class AutoRefreshTime /// Returns the calculated . public static TimeSpan CalculateTimeSpan(AutoRefreshTimeInfo info) { - switch (info.TimeUnit) + return info.TimeUnit switch { - // Calculate the seconds - case TimeUnit.Second: - return new TimeSpan(0, 0, info.Value); - // Calculate the minutes - case TimeUnit.Minute: - return new TimeSpan(0, info.Value * 60, 0); - // Calculate the hours - case TimeUnit.Hour: - return new TimeSpan(info.Value * 60, 0, 0); - case TimeUnit.None: - default: - throw new Exception("Wrong time unit."); - } + TimeUnit.Second => TimeSpan.FromSeconds(info.Value), + TimeUnit.Minute => TimeSpan.FromMinutes(info.Value), + TimeUnit.Hour => TimeSpan.FromHours(info.Value), + _ => throw new Exception("Wrong time unit.") + }; } } \ No newline at end of file diff --git a/Source/NETworkManager.Utilities/DNSClient.cs b/Source/NETworkManager.Utilities/DNSClient.cs index 2e060feac6..eec198ce2f 100644 --- a/Source/NETworkManager.Utilities/DNSClient.cs +++ b/Source/NETworkManager.Utilities/DNSClient.cs @@ -174,6 +174,66 @@ public async Task ResolveAaaaAsync(string query) } } + /// + /// Resolve all IPv4 and IPv6 addresses (A and AAAA records) of a hostname or FQDN, e.g. to exclude every address + /// of a host. Both queries are sent at the same time. + /// + /// Hostname or FQDN as string like "example.com". + /// + /// IP addresses of the host (A records first), empty if the host has no record. Null if one of the queries + /// failed (e.g. timeout, SERVFAIL) - the addresses of the host are then not completely known. + /// + public async Task> ResolveAllAAndAaaaAsync(string query) + { + if (!_isConfigured) + throw new DNSClientNotConfiguredException(NotConfiguredMessage); + + var state = _state; + + query = AddDNSSuffixIfConfigured(query, state); + + var results = await Task.WhenAll(QueryIPAddressesAsync(query, QueryType.A, state), + QueryIPAddressesAsync(query, QueryType.AAAA, state)); + + return results.Any(x => x == null) ? null : [.. results.SelectMany(x => x)]; + } + + /// + /// Query the IP addresses of a hostname or FQDN for . + /// + /// Hostname or FQDN (with the DNS suffix, if configured). + /// or . + /// Resolver state snapshot captured at the start of the resolve call. + /// + /// IP addresses of the records, empty if there is no record (incl. NXDOMAIN), null if the query failed. + /// + private static async Task> QueryIPAddressesAsync(string query, QueryType queryType, + ResolverState state) + { + try + { + var result = await state.Client.QueryAsync(query, queryType); + + // NXDOMAIN: the host has no record (not a failure like a timeout or SERVFAIL) + if (result.HasError) + return IsNotFoundResponseCode(result.Header.ResponseCode) ? [] : null; + + // Only the records of the type (e.g. not the CNAME records of the answer) + return queryType == QueryType.A + ? [.. result.Answers.ARecords().Select(x => x.Address)] + : [.. result.Answers.AaaaRecords().Select(x => x.Address)]; + } + catch (DnsResponseException) + { + return null; + } + catch (Exception ex) + { + Log.Error($"Error while resolving {queryType} records (Query string is \"{query}\").", ex); + return null; + } + } + /// /// Resolve a CNAME from a hostname or FQDN. /// diff --git a/Source/NETworkManager.Utilities/DateTimeHelper.cs b/Source/NETworkManager.Utilities/DateTimeHelper.cs index 36cf287c44..4128e21ef7 100644 --- a/Source/NETworkManager.Utilities/DateTimeHelper.cs +++ b/Source/NETworkManager.Utilities/DateTimeHelper.cs @@ -26,4 +26,20 @@ public static string DateTimeToTimeString(DateTime dateTime) { return dateTime.ToString("HH:mm:ss"); } + + /// + /// Convert the time until an event (e.g. the next refresh) to a countdown: "mm:ss", "h:mm:ss" from one hour + /// (fixed width while counting down). Negative times are shown as "00:00". + /// + /// Remaining time. + /// Countdown, e.g. "04:32" or "1:00:00". + public static string TimeSpanToCountdownString(TimeSpan timeSpan) + { + if (timeSpan < TimeSpan.Zero) + timeSpan = TimeSpan.Zero; + + return timeSpan.TotalHours >= 1 + ? $"{(int)timeSpan.TotalHours}:{timeSpan.Minutes:00}:{timeSpan.Seconds:00}" + : $"{timeSpan.Minutes:00}:{timeSpan.Seconds:00}"; + } } \ No newline at end of file diff --git a/Source/NETworkManager.Utilities/TaskHelper.cs b/Source/NETworkManager.Utilities/TaskHelper.cs new file mode 100644 index 0000000000..fb3bb0abf7 --- /dev/null +++ b/Source/NETworkManager.Utilities/TaskHelper.cs @@ -0,0 +1,47 @@ +using System; +using System.Threading.Tasks; + +namespace NETworkManager.Utilities; + +/// +/// Runs an action that takes at least a minimum time, e.g. a manual refresh: the refresh button stays disabled (and +/// its icon spins) for the minimum time, so a refresh can't be spammed and the user sees that something happened. +/// The minimum runs at the same time as the action, a slower action isn't delayed further. +/// +public static class TaskHelper +{ + /// + /// Runs the action and returns its result when it is finished and the minimum time has passed. + /// + /// Action to run. + /// Minimum time in milliseconds. + /// Result of the action. An exception of the action is thrown after the minimum time too. + public static async Task WithMinimumDurationAsync(Func> action, int minimumMilliseconds) + { + var minimum = Task.Delay(minimumMilliseconds); + + try + { + return await action(); + } + finally + { + await minimum; + } + } + + /// + /// Runs the action and returns when it is finished and the minimum time has passed. + /// + /// Action to run. + /// Minimum time in milliseconds. + /// Task of the action. An exception of the action is thrown after the minimum time too. + public static Task WithMinimumDurationAsync(Func action, int minimumMilliseconds) + { + return WithMinimumDurationAsync(async () => + { + await action(); + return true; + }, minimumMilliseconds); + } +} diff --git a/Source/NETworkManager.Validators/MultipleHostsRangeValidator.cs b/Source/NETworkManager.Validators/MultipleHostsRangeValidator.cs index 2615fbb17c..7861349b70 100644 --- a/Source/NETworkManager.Validators/MultipleHostsRangeValidator.cs +++ b/Source/NETworkManager.Validators/MultipleHostsRangeValidator.cs @@ -1,11 +1,7 @@ using NETworkManager.Localization.Resources; using NETworkManager.Models.Network; -using NETworkManager.Utilities; using System; -using System.DirectoryServices.ActiveDirectory; using System.Globalization; -using System.Net; -using System.Text.RegularExpressions; using System.Windows.Controls; namespace NETworkManager.Validators; @@ -16,104 +12,29 @@ public override ValidationResult Validate(object value, CultureInfo cultureInfo) { var isValid = true; + // At least one input that is not an exclusion (e.g. not only "!10.0.0.1") + var hasInclusion = false; + if (value == null) return new ValidationResult(false, Strings.EnterValidIPScanRange); - foreach (var ipHostOrRange in ((string)value) + foreach (var input in ((string)value) .Replace(" ", "") .Split([';', '\r', '\n'], StringSplitOptions.RemoveEmptyEntries)) { - // 192.168.0.1 - if (RegexHelper.IPv4AddressRegex().IsMatch(ipHostOrRange)) - continue; - - // 192.168.0.0/24 - if (RegexHelper.IPv4AddressCidrRegex().IsMatch(ipHostOrRange)) - continue; - - // 192.168.0.0/255.255.255.0 - if (RegexHelper.IPv4AddressSubnetmaskRegex().IsMatch(ipHostOrRange)) - continue; - - // 192.168.0.1-100 - if (RegexHelper.IPv4AddressShortRangeRegex().IsMatch(ipHostOrRange)) - { - var shortRange = ipHostOrRange.Split('-'); - var shortBase = shortRange[0][..shortRange[0].LastIndexOf('.')]; - - if (ToUInt32(shortRange[0]) > ToUInt32($"{shortBase}.{shortRange[1]}")) - isValid = false; - - continue; - } - - // 192.168.0.0 - 192.168.0.100 - if (RegexHelper.IPv4AddressRangeRegex().IsMatch(ipHostOrRange)) - { - var range = ipHostOrRange.Split('-'); - - if (ToUInt32(range[0]) > ToUInt32(range[1])) - isValid = false; - - continue; - } - - // 192.168.[50-100].1 - if (RegexHelper.IPv4AddressSpecialRangeRegex().IsMatch(ipHostOrRange)) - { - var octets = ipHostOrRange.Split('.'); - - foreach (var octet in octets) - { - // Match [50-100] - if (!Regex.IsMatch(octet, RegexHelper.SpecialRangeRegex)) - continue; + // Exclusion (e.g. "!10.0.0.1"), validated like the other inputs + var isExclusion = HostRangeHelper.IsExclusion(input); - foreach (var numberOrRange in octet.Substring(1, octet.Length - 2).Split(',')) - { - if (!numberOrRange.Contains('-')) - continue; + if (!isExclusion) + hasInclusion = true; - // 50-100 --> {50, 100} - var rangeNumber = numberOrRange.Split('-'); - - if (int.Parse(rangeNumber[0]) > int.Parse(rangeNumber[1])) - isValid = false; - } - } - - continue; - } - - // 2001:db8:85a3::8a2e:370:7334 - if (Regex.IsMatch(ipHostOrRange, RegexHelper.IPv6AddressRegex)) - continue; - - // server-01.example.com - if (RegexHelper.HostnameOrDomainRegex().IsMatch(ipHostOrRange)) - continue; - - // server-01.example.com/24 - if (Regex.IsMatch(ipHostOrRange, RegexHelper.HostnameOrDomainWithCidrRegex)) - continue; - - // server-01.example.com/255.255.255.0 - if (Regex.IsMatch(ipHostOrRange, RegexHelper.HostnameOrDomainWithSubnetmaskRegex)) - continue; - - isValid = false; + // Same formats as the scan (also a range whose start is after its end is invalid) + if (HostRangeHelper.Parse(isExclusion ? input[1..] : input).Kind == HostRangeInputKind.Invalid) + isValid = false; } - return isValid + return isValid && hasInclusion ? ValidationResult.ValidResult : new ValidationResult(false, Strings.EnterValidIPScanRange); } - - /// - /// Converts an IPv4 address to its unsigned value, so ranges across 128.0.0.0 compare correctly. - /// - private static uint ToUInt32(string ipAddress) - { - return IPv4Address.ToUInt32(IPAddress.Parse(ipAddress)); - } } \ No newline at end of file diff --git a/Source/NETworkManager/ApplicationRequest.cs b/Source/NETworkManager/ApplicationRequest.cs index ad2953dbcf..7adc6d201f 100644 --- a/Source/NETworkManager/ApplicationRequest.cs +++ b/Source/NETworkManager/ApplicationRequest.cs @@ -1,4 +1,5 @@ -using DnsClient; +using System; +using DnsClient; using NETworkManager.Models; using NETworkManager.Models.Lookup; using NETworkManager.Models.RDAP; @@ -108,3 +109,25 @@ public sealed record LookupRequest() : ApplicationRequest(ApplicationName.Lookup /// public LookupType Type { get; init; } } + +/// +/// Request to show a tab of a tool (e.g. the IP Scanner tab that sent a notification). Only the tool is shown if the +/// tab was closed (or is in another window). +/// +public sealed record ShowTabRequest : ApplicationRequest +{ + /// + /// Creates a request to show a tab. + /// + /// Tool of the tab. + /// Id of the tab (see DragablzTabItem.Id). + public ShowTabRequest(ApplicationName application, Guid tabId) : base(application) + { + TabId = tabId; + } + + /// + /// Id of the tab to show. + /// + public Guid TabId { get; } +} diff --git a/Source/NETworkManager/Controls/AutoRefreshControl.xaml b/Source/NETworkManager/Controls/AutoRefreshControl.xaml new file mode 100644 index 0000000000..885aef87ac --- /dev/null +++ b/Source/NETworkManager/Controls/AutoRefreshControl.xaml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Source/NETworkManager/Controls/AutoRefreshControl.xaml.cs b/Source/NETworkManager/Controls/AutoRefreshControl.xaml.cs new file mode 100644 index 0000000000..09feb1bc78 --- /dev/null +++ b/Source/NETworkManager/Controls/AutoRefreshControl.xaml.cs @@ -0,0 +1,79 @@ +using System.Collections; +using System.Windows; +using NETworkManager.Localization.Resources; + +namespace NETworkManager.Controls; + +/// +/// Checkbox "Automatically update every" and the interval () +/// of a tool with an auto refresh (see ). +/// +public partial class AutoRefreshControl +{ + /// + /// Identifies the dependency property. + /// + public static readonly DependencyProperty IsCheckedProperty = DependencyProperty.Register(nameof(IsChecked), + typeof(bool), typeof(AutoRefreshControl), + new FrameworkPropertyMetadata(false, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault)); + + /// + /// Identifies the dependency property. + /// + public static readonly DependencyProperty ItemsSourceProperty = DependencyProperty.Register(nameof(ItemsSource), + typeof(IEnumerable), typeof(AutoRefreshControl), new PropertyMetadata(null)); + + /// + /// Identifies the dependency property. + /// + public static readonly DependencyProperty SelectedItemProperty = DependencyProperty.Register( + nameof(SelectedItem), typeof(object), typeof(AutoRefreshControl), + new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault)); + + /// + /// Identifies the dependency property. + /// + public static readonly DependencyProperty TextProperty = DependencyProperty.Register(nameof(Text), + typeof(string), typeof(AutoRefreshControl), new PropertyMetadata(Strings.AutomaticallyUpdateEvery)); + + public AutoRefreshControl() + { + InitializeComponent(); + } + + /// + /// Gets or sets a value indicating whether the auto refresh is enabled (two-way by default). + /// + public bool IsChecked + { + get => (bool)GetValue(IsCheckedProperty); + set => SetValue(IsCheckedProperty, value); + } + + /// + /// Gets or sets the intervals to choose from. + /// + public IEnumerable ItemsSource + { + get => (IEnumerable)GetValue(ItemsSourceProperty); + set => SetValue(ItemsSourceProperty, value); + } + + /// + /// Gets or sets the selected interval (two-way by default). + /// + public object SelectedItem + { + get => GetValue(SelectedItemProperty); + set => SetValue(SelectedItemProperty, value); + } + + /// + /// Gets or sets the text of the checkbox (default "Automatically update every"). + /// + public string Text + { + get => (string)GetValue(TextProperty); + set => SetValue(TextProperty, value); + } +} diff --git a/Source/NETworkManager/Controls/RefreshStatusControl.xaml b/Source/NETworkManager/Controls/RefreshStatusControl.xaml new file mode 100644 index 0000000000..6e3bb0f4d3 --- /dev/null +++ b/Source/NETworkManager/Controls/RefreshStatusControl.xaml @@ -0,0 +1,14 @@ + + + + diff --git a/Source/NETworkManager/Controls/RefreshStatusControl.xaml.cs b/Source/NETworkManager/Controls/RefreshStatusControl.xaml.cs new file mode 100644 index 0000000000..d4cf446bfb --- /dev/null +++ b/Source/NETworkManager/Controls/RefreshStatusControl.xaml.cs @@ -0,0 +1,124 @@ +using System; +using System.Windows; +using NETworkManager.Localization.Resources; +using NETworkManager.Utilities; + +namespace NETworkManager.Controls; + +/// +/// Status of a refresh in the header of a tool: nothing while refreshing (the refresh icon spins), a countdown +/// while the next automatic refresh is planned (), otherwise the time of the last +/// refresh. The tooltip shows the date and time of the last refresh. +/// +public partial class RefreshStatusControl +{ + /// + /// Identifies the dependency property. + /// + public static readonly DependencyProperty LastRefreshTimeProperty = DependencyProperty.Register( + nameof(LastRefreshTime), typeof(DateTime?), typeof(RefreshStatusControl), + new PropertyMetadata(null, OnChanged)); + + /// + /// Identifies the dependency property. + /// + public static readonly DependencyProperty RemainingTimeProperty = DependencyProperty.Register( + nameof(RemainingTime), typeof(TimeSpan?), typeof(RefreshStatusControl), + new PropertyMetadata(null, OnChanged)); + + /// + /// Identifies the dependency property. + /// + public static readonly DependencyProperty IsRefreshingProperty = DependencyProperty.Register( + nameof(IsRefreshing), typeof(bool), typeof(RefreshStatusControl), new PropertyMetadata(false, OnChanged)); + + /// + /// Identifies the dependency property. + /// + public static readonly DependencyProperty LastRefreshFormatProperty = DependencyProperty.Register( + nameof(LastRefreshFormat), typeof(string), typeof(RefreshStatusControl), + new PropertyMetadata(Strings.ReloadedAtX, OnChanged)); + + /// + /// Identifies the dependency property. + /// + public static readonly DependencyProperty RemainingTimeFormatProperty = DependencyProperty.Register( + nameof(RemainingTimeFormat), typeof(string), typeof(RefreshStatusControl), + new PropertyMetadata(Strings.NextRefreshInX, OnChanged)); + + public RefreshStatusControl() + { + InitializeComponent(); + } + + /// + /// Gets or sets the time of the last refresh, null before the first one. + /// + public DateTime? LastRefreshTime + { + get => (DateTime?)GetValue(LastRefreshTimeProperty); + set => SetValue(LastRefreshTimeProperty, value); + } + + /// + /// Gets or sets the time until the next automatic refresh, null if none is planned (see + /// ). + /// + public TimeSpan? RemainingTime + { + get => (TimeSpan?)GetValue(RemainingTimeProperty); + set => SetValue(RemainingTimeProperty, value); + } + + /// + /// Gets or sets a value indicating whether a refresh is running (nothing is shown). + /// + public bool IsRefreshing + { + get => (bool)GetValue(IsRefreshingProperty); + set => SetValue(IsRefreshingProperty, value); + } + + /// + /// Gets or sets the text of the last refresh with the time as {0} (default "Reloaded at {0}"). + /// + public string LastRefreshFormat + { + get => (string)GetValue(LastRefreshFormatProperty); + set => SetValue(LastRefreshFormatProperty, value); + } + + /// + /// Gets or sets the text of the countdown with the remaining time as {0} (default "Next refresh in + /// {0}"). + /// + public string RemainingTimeFormat + { + get => (string)GetValue(RemainingTimeFormatProperty); + set => SetValue(RemainingTimeFormatProperty, value); + } + + private static void OnChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + ((RefreshStatusControl)d).Update(); + } + + private void Update() + { + string text = null; + + if (!IsRefreshing) + { + if (RemainingTime is { } remainingTime) + text = string.Format(RemainingTimeFormat, DateTimeHelper.TimeSpanToCountdownString(remainingTime)); + else if (LastRefreshTime is { } lastRefreshTime) + text = string.Format(LastRefreshFormat, lastRefreshTime.ToShortTimeString()); + } + + TextBlockStatus.Text = text; + Visibility = text == null ? Visibility.Collapsed : Visibility.Visible; + + // Date and time, e.g. for a tool that is open for days + ToolTip = LastRefreshTime is { } time ? string.Format(LastRefreshFormat, time.ToString("G")) : null; + } +} diff --git a/Source/NETworkManager/Controls/SNMPCredentialsControl.xaml b/Source/NETworkManager/Controls/SNMPCredentialsControl.xaml new file mode 100644 index 0000000000..b18a45b371 --- /dev/null +++ b/Source/NETworkManager/Controls/SNMPCredentialsControl.xaml @@ -0,0 +1,208 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + + + + + + + + + + + + + - Lines { get; } // Timestamp shown in the header — captured when the notification is created, which is the // moment the status change occurred. @@ -69,18 +81,27 @@ private set #region Constructor - public NotificationWindow(PackIconMaterialKind iconKind, string iconColor, string title, string message, int closeTimeSeconds) + public NotificationWindow(NotificationInfo notification) { InitializeComponent(); DataContext = this; - IconKind = iconKind; - IconColor = iconColor; - NotificationTitle = title; - Message = message; + _request = notification.Request ?? new ApplicationRequest(notification.Application); + + ApplicationIconBrush = new VisualBrush(ApplicationManager.GetIcon(notification.Application)) + { + Stretch = Stretch.Uniform + }; + ApplicationTitle = ResourceTranslator.Translate(ResourceIdentifier.ApplicationName, notification.Application); + + (IconKind, IconBrush) = GetStatusIcon(notification.Status); + NotificationTitle = notification.Title; + Lines = [.. notification.Lines.Select(x => new NotificationLineItem( + string.IsNullOrEmpty(x.Text) ? string.Empty : x.Text + NotificationLine.Separator, x.Status, + GetStatusBrush(x.StatusKind)))]; Time = DateTime.Now.ToString("HH:mm:ss"); - TimeMax = closeTimeSeconds; - TimeRemaining = closeTimeSeconds; + TimeMax = notification.CloseTimeSeconds; + TimeRemaining = notification.CloseTimeSeconds; CloseCommand = new RelayCommand(_ => CloseWindow()); @@ -103,12 +124,66 @@ private void ShowMainWindow() if (System.Windows.Application.Current.MainWindow is MainWindow mainWindow && mainWindow.ShowWindowCommand.CanExecute(null)) mainWindow.ShowWindowCommand.Execute(null); + + // The tool (and the tab) that sent the notification + NavigationManager.Open(_request); } #endregion #region Methods + // Colors of the application (status icons, "Since last scan" of the scanners) + private static readonly Color UpColor = (Color)ColorConverter.ConvertFromString("#badc58"); + private static readonly Color DownColor = (Color)ColorConverter.ConvertFromString("#b95353"); + private static readonly Color ChangedColor = (Color)ColorConverter.ConvertFromString("#f1b856"); + + /// + /// Gets the icon and its brush of a status: up green, down red, up and down half green / half red (split along the + /// diagonal), changed orange. + /// + /// Status of the notification. + /// Icon and brush. + private static (PackIconMaterialKind kind, Brush brush) GetStatusIcon(NotificationStatus status) + { + var brush = GetStatusBrush(status); + + var kind = status switch + { + NotificationStatus.Up => PackIconMaterialKind.LanConnect, + NotificationStatus.Down => PackIconMaterialKind.LanDisconnect, + NotificationStatus.UpAndDown => PackIconMaterialKind.Lan, + _ => PackIconMaterialKind.LanPending + }; + + return (kind, brush); + } + + /// + /// Gets the brush of a status (status icon, status text of a line): up green, down red, up and down half green / + /// half red (split along the diagonal), changed orange. + /// + /// Status. + /// Frozen brush. + private static Brush GetStatusBrush(NotificationStatus status) + { + Brush brush = status switch + { + NotificationStatus.Up => new SolidColorBrush(UpColor), + NotificationStatus.Down => new SolidColorBrush(DownColor), + NotificationStatus.UpAndDown => new LinearGradientBrush(new GradientStopCollection + { + // Two stops at the same offset: a sharp diagonal edge instead of a gradient + new(UpColor, 0), new(UpColor, 0.5), new(DownColor, 0.5), new(DownColor, 1) + }, new Point(0, 0), new Point(1, 1)), + _ => new SolidColorBrush(ChangedColor) + }; + + brush.Freeze(); + + return brush; + } + /// /// Called by when the stack changes (a sibling closes or /// resizes) and this window may need to move. @@ -193,3 +268,12 @@ private async void Timer_Tick(object sender, EventArgs e) #endregion } + +/// +/// A line of the notification as shown: the text with the separator (empty if the line has only a status), the status +/// and its brush (see ). +/// +/// Text of the line with the separator, e.g. "10.0.0.5 (server) · ". +/// Status text, e.g. "New". +/// Brush of the status text. +public sealed record NotificationLineItem(string Prefix, string Status, Brush StatusBrush); diff --git a/Source/NETworkManager/ProfileDialogManager.cs b/Source/NETworkManager/ProfileDialogManager.cs index 035faa2749..25a25bb3ec 100644 --- a/Source/NETworkManager/ProfileDialogManager.cs +++ b/Source/NETworkManager/ProfileDialogManager.cs @@ -55,6 +55,15 @@ private static ProfileInfo ParseProfileInfo(ProfileViewModel instance) IPScanner_HostOrIPRange = instance.IPScanner_InheritHost ? instance.Host?.Trim() : instance.IPScanner_HostOrIPRange?.Trim(), + IPScanner_OverrideSNMP = instance.IPScanner_SNMP.IsOverridden, + IPScanner_SNMPVersion = instance.IPScanner_SNMP.Version, + IPScanner_SNMPCommunity = instance.IPScanner_SNMP.CommunityToSave, + IPScanner_SNMPSecurity = instance.IPScanner_SNMP.Security, + IPScanner_SNMPUsername = instance.IPScanner_SNMP.Username?.Trim(), + IPScanner_SNMPAuthenticationProvider = instance.IPScanner_SNMP.AuthenticationProvider, + IPScanner_SNMPAuth = instance.IPScanner_SNMP.AuthToSave, + IPScanner_SNMPPrivacyProvider = instance.IPScanner_SNMP.PrivacyProvider, + IPScanner_SNMPPriv = instance.IPScanner_SNMP.PrivToSave, // Port Scanner PortScanner_Enabled = instance.PortScanner_Enabled, @@ -250,26 +259,15 @@ private static ProfileInfo ParseProfileInfo(ProfileViewModel instance) SNMP_OverrideOIDAndMode = instance.SNMP_OverrideOIDAndMode, SNMP_OID = instance.SNMP_OID, SNMP_Mode = instance.SNMP_Mode, - SNMP_OverrideVersionAndAuth = instance.SNMP_OverrideVersionAndAuth, - SNMP_Version = instance.SNMP_Version, - SNMP_Community = - instance.SNMP_OverrideVersionAndAuth && instance.SNMP_Version != SNMPVersion.V3 - ? instance.SNMP_Community - : new SecureString(), - SNMP_Security = instance.SNMP_Security, - SNMP_Username = instance.SNMP_Username, - SNMP_AuthenticationProvider = instance.SNMP_AuthenticationProvider, - SNMP_Auth = instance.SNMP_OverrideVersionAndAuth && - instance.SNMP_Version == SNMPVersion.V3 && - instance.SNMP_Security != SNMPV3Security.NoAuthNoPriv - ? instance.SNMP_Auth - : new SecureString(), - SNMP_PrivacyProvider = instance.SNMP_PrivacyProvider, - SNMP_Priv = instance.SNMP_OverrideVersionAndAuth && - instance.SNMP_Version == SNMPVersion.V3 && - instance.SNMP_Security == SNMPV3Security.AuthPriv - ? instance.SNMP_Priv - : new SecureString(), + SNMP_OverrideVersionAndAuth = instance.SNMP_Credentials.IsOverridden, + SNMP_Version = instance.SNMP_Credentials.Version, + SNMP_Community = instance.SNMP_Credentials.CommunityToSave, + SNMP_Security = instance.SNMP_Credentials.Security, + SNMP_Username = instance.SNMP_Credentials.Username, + SNMP_AuthenticationProvider = instance.SNMP_Credentials.AuthenticationProvider, + SNMP_Auth = instance.SNMP_Credentials.AuthToSave, + SNMP_PrivacyProvider = instance.SNMP_Credentials.PrivacyProvider, + SNMP_Priv = instance.SNMP_Credentials.PrivToSave, // Wake on LAN WakeOnLAN_Enabled = instance.WakeOnLAN_Enabled, @@ -329,6 +327,17 @@ private static GroupInfo ParseGroupInfo(GroupViewModel instance) Profiles = profiles, + // IP Scanner + IPScanner_OverrideSNMP = instance.IPScanner_SNMP.IsOverridden, + IPScanner_SNMPVersion = instance.IPScanner_SNMP.Version, + IPScanner_SNMPCommunity = instance.IPScanner_SNMP.CommunityToSave, + IPScanner_SNMPSecurity = instance.IPScanner_SNMP.Security, + IPScanner_SNMPUsername = instance.IPScanner_SNMP.Username?.Trim(), + IPScanner_SNMPAuthenticationProvider = instance.IPScanner_SNMP.AuthenticationProvider, + IPScanner_SNMPAuth = instance.IPScanner_SNMP.AuthToSave, + IPScanner_SNMPPrivacyProvider = instance.IPScanner_SNMP.PrivacyProvider, + IPScanner_SNMPPriv = instance.IPScanner_SNMP.PrivToSave, + // Port Scanner PortScanner_OverridePorts = instance.PortScanner_OverridePorts, PortScanner_Ports = portScannerPorts, @@ -466,26 +475,15 @@ private static GroupInfo ParseGroupInfo(GroupViewModel instance) SNMP_OverrideOIDAndMode = instance.SNMP_OverrideOIDAndMode, SNMP_OID = instance.SNMP_OID, SNMP_Mode = instance.SNMP_Mode, - SNMP_OverrideVersionAndAuth = instance.SNMP_OverrideVersionAndAuth, - SNMP_Version = instance.SNMP_Version, - SNMP_Community = - instance.SNMP_OverrideVersionAndAuth && instance.SNMP_Version != SNMPVersion.V3 - ? instance.SNMP_Community - : new SecureString(), - SNMP_Security = instance.SNMP_Security, - SNMP_Username = instance.SNMP_Username, - SNMP_AuthenticationProvider = instance.SNMP_AuthenticationProvider, - SNMP_Auth = instance.SNMP_OverrideVersionAndAuth && - instance.SNMP_Version == SNMPVersion.V3 && - instance.SNMP_Security != SNMPV3Security.NoAuthNoPriv - ? instance.SNMP_Auth - : new SecureString(), - SNMP_PrivacyProvider = instance.SNMP_PrivacyProvider, - SNMP_Priv = instance.SNMP_OverrideVersionAndAuth && - instance.SNMP_Version == SNMPVersion.V3 && - instance.SNMP_Security == SNMPV3Security.AuthPriv - ? instance.SNMP_Priv - : new SecureString() + SNMP_OverrideVersionAndAuth = instance.SNMP_Credentials.IsOverridden, + SNMP_Version = instance.SNMP_Credentials.Version, + SNMP_Community = instance.SNMP_Credentials.CommunityToSave, + SNMP_Security = instance.SNMP_Credentials.Security, + SNMP_Username = instance.SNMP_Credentials.Username, + SNMP_AuthenticationProvider = instance.SNMP_Credentials.AuthenticationProvider, + SNMP_Auth = instance.SNMP_Credentials.AuthToSave, + SNMP_PrivacyProvider = instance.SNMP_Credentials.PrivacyProvider, + SNMP_Priv = instance.SNMP_Credentials.PrivToSave }; } diff --git a/Source/NETworkManager/Resources/Styles/ContextMenuStyles.xaml b/Source/NETworkManager/Resources/Styles/ContextMenuStyles.xaml index fc1583cf8d..39689b8d82 100644 --- a/Source/NETworkManager/Resources/Styles/ContextMenuStyles.xaml +++ b/Source/NETworkManager/Resources/Styles/ContextMenuStyles.xaml @@ -7,6 +7,8 @@ + + diff --git a/Source/NETworkManager/Resources/Styles/RectangleStyles.xaml b/Source/NETworkManager/Resources/Styles/RectangleStyles.xaml index 475520e48b..aa22eeb4bf 100644 --- a/Source/NETworkManager/Resources/Styles/RectangleStyles.xaml +++ b/Source/NETworkManager/Resources/Styles/RectangleStyles.xaml @@ -78,7 +78,8 @@ - + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + - - - - @@ -97,9 +122,157 @@ Margin="0,0,0,10" /> - - + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - -