Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions Source/NETworkManager.Controls/ComboBoxPasteBehavior.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
using System;
using System.Linq;
using System.Runtime.InteropServices;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;

namespace NETworkManager.Controls;

/// <summary>
/// Attached property that converts multiline text (e.g. a column pasted from Excel) into a
/// semicolon-separated single line when pasted into an editable <see cref="ComboBox"/>.
///
/// Without this the WPF TextBox inside the ComboBox (<see cref="TextBox.AcceptsReturn"/> is
/// <c>false</c>) silently drops everything after the first line when multiline text is pasted.
/// The conversion is applied by reading the clipboard, replacing the editable text box's
/// selection with the converted text, and marking <see cref="ApplicationCommands.Paste"/> as
/// handled - the system clipboard itself is never modified, so other applications (or a
/// subsequent paste elsewhere) are unaffected.
/// </summary>
public static class ComboBoxPasteBehavior
{
public static readonly DependencyProperty ConvertMultilineToSemicolonProperty =
DependencyProperty.RegisterAttached(
"ConvertMultilineToSemicolon",
typeof(bool),
typeof(ComboBoxPasteBehavior),
new PropertyMetadata(false, OnConvertMultilineToSemicolonChanged));

public static void SetConvertMultilineToSemicolon(UIElement element, bool value) =>
element.SetValue(ConvertMultilineToSemicolonProperty, value);

public static bool GetConvertMultilineToSemicolon(UIElement element) =>
(bool)element.GetValue(ConvertMultilineToSemicolonProperty);

private static void OnConvertMultilineToSemicolonChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is not ComboBox comboBox)
return;

if ((bool)e.NewValue)
CommandManager.AddPreviewExecutedHandler(comboBox, OnPreviewExecuted);
else
CommandManager.RemovePreviewExecutedHandler(comboBox, OnPreviewExecuted);
}

private static void OnPreviewExecuted(object sender, ExecutedRoutedEventArgs e)
{
// Keyboard shortcut (Ctrl+V) and the paste context menu item both route through
// ApplicationCommands.Paste. Editing commands don't need to be handled here.
if (e.Command != ApplicationCommands.Paste)
return;

// The command originates from the focused editable text box inside the ComboBox
// template, which is where the pasted text actually needs to be inserted.
if (e.OriginalSource is not TextBox textBox)
return;

string text;

try
{
if (!Clipboard.ContainsText())
return;

text = Clipboard.GetText();
}
catch (ExternalException)
{
// Clipboard is temporarily locked by another process - fall back to the default paste.
return;
}

// Only intervene when there is actual multiline content (e.g. a column pasted from
// Excel). Otherwise let the default paste command handle it as usual.
if (!text.Contains('\n') && !text.Contains('\r'))
return;

var converted = string.Join(";", text
.Replace("\r\n", "\n")
.Replace('\r', '\n')
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
.Select(x => x.Trim()));

// Replace the current selection with the converted text ourselves and mark the command
// handled, instead of letting the default paste run - this avoids touching the system
// clipboard entirely (no risk of losing other clipboard formats or racing a paste
// elsewhere).
var selectionStart = textBox.SelectionStart;
var textBefore = textBox.Text[..selectionStart];
var textAfter = textBox.Text[(selectionStart + textBox.SelectionLength)..];

textBox.Text = textBefore + converted + textAfter;
textBox.SelectionStart = selectionStart + converted.Length;
textBox.SelectionLength = 0;

e.Handled = true;
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@
<value>SERVER-01 or 10.0.0.10</value>
</data>
<data name="ExampleHostRange" xml:space="preserve">
<value>192.168.178.0/24; 10.0.0.0 - 10.0.0.9; 10.0.[0-9,20].[1-2]; server-01.borntoberoot.net/24</value>
<value>192.168.178.0/24; 10.8.0.0-100; 10.8.[0-9,254].1; server-01.borntoberoot.net/28</value>
</data>
<data name="ExampleIPv4Address" xml:space="preserve">
<value>10.0.0.10</value>
Expand Down
66 changes: 39 additions & 27 deletions Source/NETworkManager.Models/Network/HostRangeHelper.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using NETworkManager.Utilities;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
Expand All @@ -17,18 +18,40 @@ namespace NETworkManager.Models.Network;
public static class HostRangeHelper
{
/// <summary>
/// Create a list of hosts from a string input like "10.0.0.1; example.com; 10.0.0.0/24"
/// 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).
/// </summary>
/// <param name="hosts">Hosts like "10.0.0.1; example.com; 10.0.0.0/24"</param>
/// <param name="hosts">Hosts like "10.0.0.1; example.com; 10.0.0.0/24" or newline-separated lines</param>
/// <returns>List of hosts.</returns>
public static IEnumerable<string> CreateListFromInput(string hosts)
{
return hosts.Replace(" ", "").Split(';')
.Where(x => !string.IsNullOrEmpty(x))
return hosts.Replace(" ", "")
.Split([';', '\r', '\n'], StringSplitOptions.RemoveEmptyEntries)
.Select(x => x.Trim())
.ToArray();
}

/// <summary>
/// Adds every IPv4 address in the inclusive range [<paramref name="start" />, <paramref name="end" />] to
/// <paramref name="hostsBag" />. Iterates using the unsigned IPv4 value widened to <see cref="long" /> so the
/// inclusive upper bound stays representable even for the last address in the 32-bit space
/// (255.255.255.255), which would otherwise overflow <see cref="int" /> arithmetic.
/// </summary>
private static void AddIPv4RangeToBag(IPAddress start, IPAddress end,
ConcurrentBag<(IPAddress ipAddress, string hostname)> hostsBag, CancellationToken ct)
{
var from = (long)unchecked((uint)IPv4Address.ToInt32(start));
var to = (long)unchecked((uint)IPv4Address.ToInt32(end));

Parallel.For(from, to + 1, (i, state) =>
{
if (ct.IsCancellationRequested)
state.Break();

hostsBag.Add((IPv4Address.FromInt32(unchecked((int)i)), string.Empty));
});
}

public static async Task<(List<(IPAddress ipAddress, string hostname)> hosts, List<string> hostnamesNotResolved)>
ResolveAsync(IEnumerable<string> hosts, bool dnsResolveHostnamePreferIPv4, CancellationToken cancellationToken)
{
Expand All @@ -54,29 +77,25 @@ public static IEnumerable<string> CreateListFromInput(string hosts)
case var _ when RegexHelper.IPv4AddressSubnetmaskRegex().IsMatch(host):
var network = IPNetwork2.Parse(host);

Parallel.For(IPv4Address.ToInt32(network.Network), IPv4Address.ToInt32(network.Broadcast) + 1,
(i, state) =>
{
if (ct.IsCancellationRequested)
state.Break();
AddIPv4RangeToBag(network.Network, network.Broadcast, hostsBag, ct);

hostsBag.Add((IPv4Address.FromInt32(i), string.Empty));
});
break;

// 192.168.0.1-100
case var _ when RegexHelper.IPv4AddressShortRangeRegex().IsMatch(host):
var shortRange = host.Split('-');
var shortBase = shortRange[0][..shortRange[0].LastIndexOf('.')];

AddIPv4RangeToBag(IPAddress.Parse(shortRange[0]),
IPAddress.Parse($"{shortBase}.{shortRange[1]}"), hostsBag, ct);

break;

// 192.168.0.0 - 192.168.0.100
case var _ when RegexHelper.IPv4AddressRangeRegex().IsMatch(host):
var range = host.Split('-');

Parallel.For(IPv4Address.ToInt32(IPAddress.Parse(range[0])),
IPv4Address.ToInt32(IPAddress.Parse(range[1])) + 1, (i, state) =>
{
if (ct.IsCancellationRequested)
state.Break();

hostsBag.Add((IPv4Address.FromInt32(i), string.Empty));
});
AddIPv4RangeToBag(IPAddress.Parse(range[0]), IPAddress.Parse(range[1]), hostsBag, ct);

break;

Expand Down Expand Up @@ -170,14 +189,7 @@ public static IEnumerable<string> CreateListFromInput(string hosts)
network = IPNetwork2.Parse(
$"{dnsResultWithSubnet.Value}/{hostAndSubnet[1]}");

Parallel.For(IPv4Address.ToInt32(network.Network),
IPv4Address.ToInt32(network.Broadcast) + 1, (i, state) =>
{
if (ct.IsCancellationRequested)
state.Break();

hostsBag.Add((IPv4Address.FromInt32(i), string.Empty));
});
AddIPv4RangeToBag(network.Network, network.Broadcast, hostsBag, ct);
}
else
{
Expand Down
20 changes: 18 additions & 2 deletions Source/NETworkManager.Utilities/RegexHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,30 @@ public static partial class RegexHelper
public static partial Regex IPv4AddressExtractRegex();

/// <summary>
/// Provides a compiles regular expression that matches IPv4 address ranges in the format "start-end" like
/// Represents a regular expression pattern that matches valid shorthand IPv4 address ranges like
/// "192.168.178.1-100" (base IP + last octet range).
/// </summary>
private const string IPv4AddressShortRangeValues =
@"((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\-(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)";

/// <summary>
/// Provides a compiled regular expression that matches IPv4 address ranges in the format "start-end" like
/// "192.168.178.0-192.168.178.255".
/// </summary>
/// </summary>
/// <returns>A <see cref="Regex"/> instance that matches strings representing IPv4 address ranges, such as
/// "192.168.1.1-192.168.1.100".</returns>
[GeneratedRegex($"^{IPv4AddressValues}-{IPv4AddressValues}$")]
public static partial Regex IPv4AddressRangeRegex();

/// <summary>
/// Provides a compiled regular expression that matches shorthand IPv4 address ranges like
/// "192.168.178.1-100" (base IP followed by a last-octet range).
/// </summary>
/// <returns>A <see cref="Regex"/> instance that matches strings representing shorthand IPv4 address ranges,
/// such as "192.168.1.1-100" (192.168.1.1 to 192.168.1.100).</returns>
[GeneratedRegex($"^{IPv4AddressShortRangeValues}$")]
public static partial Regex IPv4AddressShortRangeRegex();

/// <summary>
/// Provides a compiled regular expression that matches valid IPv4 subnet mask like "255.255.0.0".
/// </summary>
Expand Down
18 changes: 17 additions & 1 deletion Source/NETworkManager.Validators/MultipleHostsRangeValidator.cs
Original file line number Diff line number Diff line change
@@ -1,6 +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;
Expand All @@ -18,7 +19,9 @@ public override ValidationResult Validate(object value, CultureInfo cultureInfo)
if (value == null)
return new ValidationResult(false, Strings.EnterValidIPScanRange);

foreach (var ipHostOrRange in ((string)value).Replace(" ", "").Split(';'))
foreach (var ipHostOrRange in ((string)value)
.Replace(" ", "")
.Split([';', '\r', '\n'], StringSplitOptions.RemoveEmptyEntries))
{
// 192.168.0.1
if (RegexHelper.IPv4AddressRegex().IsMatch(ipHostOrRange))
Expand All @@ -32,6 +35,19 @@ public override ValidationResult Validate(object value, CultureInfo cultureInfo)
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 (IPv4Address.ToInt32(IPAddress.Parse(shortRange[0])) >
IPv4Address.ToInt32(IPAddress.Parse($"{shortBase}.{shortRange[1]}")))
isValid = false;

continue;
}

// 192.168.0.0 - 192.168.0.100
if (RegexHelper.IPv4AddressRangeRegex().IsMatch(ipHostOrRange))
{
Expand Down
1 change: 1 addition & 0 deletions Source/NETworkManager/Views/IPScannerView.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
ItemsSource="{Binding Path=HostHistoryView}"
mah:TextBoxHelper.Watermark="{x:Static Member=localization:StaticStrings.ExampleHostRange}"
IsReadOnly="{Binding Path=IsRunning}"
controls:ComboBoxPasteBehavior.ConvertMultilineToSemicolon="True"
Style="{StaticResource ResourceKey=HistoryComboBox}">
<ComboBox.Text>
<Binding Path="Host" Mode="TwoWay" UpdateSourceTrigger="PropertyChanged">
Expand Down
1 change: 1 addition & 0 deletions Source/NETworkManager/Views/PingMonitorHostView.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@
ItemsSource="{Binding Path=HostHistoryView}"
mah:TextBoxHelper.Watermark="{x:Static Member=localization:StaticStrings.ExampleHostRange}"
IsReadOnly="{Binding Path=IsRunning}"
controls:ComboBoxPasteBehavior.ConvertMultilineToSemicolon="True"
Style="{StaticResource ResourceKey=HistoryComboBox}">
<ComboBox.Text>
<Binding Path="Host" Mode="TwoWay" UpdateSourceTrigger="PropertyChanged">
Expand Down
2 changes: 2 additions & 0 deletions Source/NETworkManager/Views/PortScannerView.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
ItemsSource="{Binding HostHistoryView}"
mah:TextBoxHelper.Watermark="{x:Static localization:StaticStrings.ExampleHostRange}"
IsReadOnly="{Binding Path=IsRunning}"
controls:ComboBoxPasteBehavior.ConvertMultilineToSemicolon="True"
Style="{StaticResource HistoryComboBox}">
<ComboBox.Text>
<Binding Path="Host" Mode="TwoWay" UpdateSourceTrigger="PropertyChanged">
Expand All @@ -79,6 +80,7 @@
ItemsSource="{Binding PortsHistoryView}"
mah:TextBoxHelper.Watermark="{x:Static localization:StaticStrings.ExamplePortScanRange}"
IsReadOnly="{Binding Path=IsRunning}"
controls:ComboBoxPasteBehavior.ConvertMultilineToSemicolon="True"
Style="{StaticResource HistoryComboBox}">
<ComboBox.Text>
<Binding Path="Ports" Mode="TwoWay" UpdateSourceTrigger="PropertyChanged">
Expand Down
5 changes: 3 additions & 2 deletions Website/docs/application/ip-scanner.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ With the **IP Scanner** you can scan for active devices based on the hostname or
| -------------------------------- | -------------------------------------------------------------------------------------------- |
| `10.0.0.1` | Single IP address (`10.0.0.1`) |
| `10.0.0.100 - 10.0.0.199` | All IP addresses in a given range (`10.0.0.100`, `10.0.0.101`, ..., `10.0.0.199`) |
| `10.0.0.100-199` | All IP addresses in a given range, shorthand for the last octet (`10.0.0.100`, ..., `10.0.0.199`) |
| `10.0.0.0/23` | All IP addresses in a subnet (`10.0.0.0`, ..., `10.0.1.255`) |
| `10.0.0.0/255.255.254.0` | All IP addresses in a subnet (`10.0.0.0`, ..., `10.0.1.255`) |
| `10.0.[0-9,20].[1-2]` | Multiple IP addresses like (`10.0.0.1`, `10.0.0.2`, `10.0.1.1`, ...,`10.0.9.2`, `10.0.20.1`) |
Expand All @@ -34,9 +35,9 @@ With the **IP Scanner** you can scan for active devices based on the hostname or

:::note

Multiple inputs can be combined with a semicolon (`;`).
Multiple inputs can be combined with a semicolon (`;`). A column pasted from Excel (one entry per line) is converted to the semicolon-separated form automatically.

Example: `10.0.0.0/24; 10.0.[10-20]1`
Example: `10.0.0.0/24; 10.0.[10-20].1; 10.0.0.100-199`

:::

Expand Down
5 changes: 3 additions & 2 deletions Website/docs/application/ping-monitor.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ ICMP (Internet Control Message Protocol) is a network-layer protocol used to sen
| -------------------------------- | ------------------------------------------------------------------------------------------ |
| `10.0.0.1` | Single IP address (`10.0.0.1`) |
| `10.0.0.100 - 10.0.0.199` | All IP addresses in a given range (`10.0.0.100`, `10.0.0.101`, ..., `10.0.0.199`) |
| `10.0.0.100-199` | All IP addresses in a given range, shorthand for the last octet (`10.0.0.100`, ..., `10.0.0.199`) |
| `10.0.0.0/23` | All IP addresses in a subnet (`10.0.0.0`, ..., `10.0.1.255`) |
| `10.0.0.0/255.255.254.0` | All IP addresses in a subnet (`10.0.0.0`, ..., `10.0.1.255`) |
| `10.0.[0-9,20].[1-2]` | Multiple IP addresses like (`10.0.0.1`, `10.0.0.2`, `10.0.1.1`, ...,`10.0.9.2`, `10.0.20.1`) |
Expand All @@ -31,9 +32,9 @@ ICMP (Internet Control Message Protocol) is a network-layer protocol used to sen

:::note

Multiple inputs can be combined with a semicolon (`;`).
Multiple inputs can be combined with a semicolon (`;`). A column pasted from Excel (one entry per line) is converted to the semicolon-separated form automatically.

Example: `10.0.0.0/24; 10.0.[10-20]1`
Example: `10.0.0.0/24; 10.0.[10-20].1; 10.0.0.100-199`

:::

Expand Down
Loading