Skip to content

Commit 8c6e3a9

Browse files
committed
Feature: Improve run command
1 parent 06ea91c commit 8c6e3a9

21 files changed

Lines changed: 288 additions & 63 deletions

File tree

‎Source/NETworkManager.Localization/Resources/Strings.Designer.cs‎

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Source/NETworkManager.Localization/Resources/Strings.resx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5001,4 +5001,7 @@ Cloudflare may log your IP address and network information. See Cloudflare's pri
50015001
<data name="RunCommandWatermark18" xml:space="preserve">
50025002
<value>Ping first, panic later...</value>
50035003
</data>
5004+
<data name="PortProfile" xml:space="preserve">
5005+
<value>Port profile</value>
5006+
</data>
50045007
</root>

‎Source/NETworkManager.Models/RunCommand/RunCommandTarget.cs‎

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,21 +33,33 @@ public static RunCommandTargetKind GetKind(string target)
3333
if (RegexHelper.MACAddressFirst3BytesWithSeparatorRegex().IsMatch(target))
3434
return RunCommandTargetKind.OUI;
3535

36+
// Only ports from 1 to 65535 (e.g. not "99999", which the port lookup searches as a service)
3637
if (RegexHelper.PortLookupEntriesRegex().IsMatch(target))
37-
return RunCommandTargetKind.Ports;
38+
return Regex.Matches(target, "[0-9]+").All(x => int.TryParse(x.Value, out var port) && port is > 0 and < 65536)
39+
? RunCommandTargetKind.Ports
40+
: RunCommandTargetKind.None;
3841

3942
if (RegexHelper.ASNumberWithPrefixRegex().IsMatch(target))
4043
return RunCommandTargetKind.ASN;
4144

4245
// Several hosts, IP ranges or subnets
4346
if (target.Contains(';'))
4447
{
45-
var parts = target.Split(';', StringSplitOptions.RemoveEmptyEntries);
48+
var kinds = target.Split(';', StringSplitOptions.RemoveEmptyEntries).Select(GetKind).ToList();
4649

47-
return parts.Length > 0 && parts.All(x => GetKind(x) is RunCommandTargetKind.Host
48-
or RunCommandTargetKind.Hostname or RunCommandTargetKind.IPRange or RunCommandTargetKind.Subnet)
49-
? RunCommandTargetKind.Hosts
50-
: RunCommandTargetKind.None;
50+
if (kinds.Count == 0 || kinds.Any(x => x is not (RunCommandTargetKind.Host or RunCommandTargetKind.Hostname
51+
or RunCommandTargetKind.IPRange or RunCommandTargetKind.Subnet)))
52+
return RunCommandTargetKind.None;
53+
54+
// Only host names without a domain (e.g. "foo;" or "a;b" from copied text) can also be any words, like a
55+
// single host name without a domain is not a list.
56+
if (kinds.All(x => x == RunCommandTargetKind.Hostname))
57+
return RunCommandTargetKind.None;
58+
59+
// With an IP range or subnet, it is a range for the scanners (e.g. not for a DNS lookup).
60+
return kinds.Any(x => x is RunCommandTargetKind.IPRange or RunCommandTargetKind.Subnet)
61+
? RunCommandTargetKind.IPRange
62+
: RunCommandTargetKind.Hosts;
5163
}
5264

5365
// Subnet with CIDR or subnet mask (e.g. 10.0.0.0/24, 10.0.0.0/255.255.255.0, 2001:db8::/32), like the input

‎Source/NETworkManager.Models/RunCommand/RunCommandTargetKind.cs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,12 @@ public enum RunCommandTargetKind
2222
Hostname,
2323

2424
/// <summary>
25-
/// Several hosts separated by ";".
25+
/// Several hosts separated by ";", at least one is an IP address or a host with a domain.
2626
/// </summary>
2727
Hosts,
2828

2929
/// <summary>
30-
/// IPv4 range (e.g. "10.0.0.1-10.0.0.100", "10.0.[0-1].1").
30+
/// IPv4 range (e.g. "10.0.0.1-10.0.0.100", "10.0.[0-1].1"), or several hosts with at least one range or subnet.
3131
/// </summary>
3232
IPRange,
3333

‎Source/NETworkManager.Settings/GlobalStaticConfiguration.cs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ public static class GlobalStaticConfiguration
9090
public static int HotKey_RunCommandModifier => 3;
9191

9292
// Settings: Run command
93+
public static int RunCommand_HistoryEntries => 20;
9394
public static bool RunCommand_ShowKeyHints => true;
9495

9596
// Settings: Update

‎Source/NETworkManager.Settings/SettingsInfo.cs‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -863,6 +863,19 @@ public ObservableCollection<string> RunCommand_History
863863
}
864864
} = [];
865865

866+
public int RunCommand_HistoryEntries
867+
{
868+
get;
869+
set
870+
{
871+
if (value == field)
872+
return;
873+
874+
field = value;
875+
OnPropertyChanged();
876+
}
877+
} = GlobalStaticConfiguration.RunCommand_HistoryEntries;
878+
866879
public bool RunCommand_SuggestClipboard
867880
{
868881
get;

‎Source/NETworkManager.Tests/RunCommand/RunCommandTargetTests.cs‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ public class RunCommandTargetTests
1111
[InlineData("server-01.borntoberoot.net", RunCommandTargetKind.Host)]
1212
[InlineData("borntoberoot.net", RunCommandTargetKind.Host)]
1313
[InlineData("10.0.0.1;10.0.0.2", RunCommandTargetKind.Hosts)]
14-
[InlineData("borntoberoot.net;10.0.0.0/24", RunCommandTargetKind.Hosts)]
14+
// A list with a range or subnet is a range (for the scanners, not for a DNS lookup).
15+
[InlineData("borntoberoot.net;10.0.0.0/24", RunCommandTargetKind.IPRange)]
16+
[InlineData("10.0.0.0/24;10.0.0.1", RunCommandTargetKind.IPRange)]
1517
[InlineData("10.0.0.1-10.0.0.100", RunCommandTargetKind.IPRange)]
1618
[InlineData("10.0.0.1-100", RunCommandTargetKind.IPRange)]
1719
[InlineData("10.0.[0-1].1", RunCommandTargetKind.IPRange)]
@@ -46,8 +48,8 @@ public void Hostname(string target)
4648
}
4749

4850
[Theory]
49-
[InlineData("server01;server02")]
5051
[InlineData("10.0.0.1;server01")]
52+
[InlineData("server01;server02.borntoberoot.net")]
5153
public void HostsWithHostnames(string target)
5254
{
5355
Assert.Equal(RunCommandTargetKind.Hosts, RunCommandTarget.GetKind(target));
@@ -63,6 +65,13 @@ public void HostsWithHostnames(string target)
6365
[InlineData("server_01")]
6466
[InlineData("@server")]
6567
[InlineData("10.0.0.1;server_01")]
68+
// Only host names without a domain can be any words (e.g. copied text)
69+
[InlineData("server01;server02")]
70+
[InlineData("foo;")]
71+
// Ports only from 1 to 65535
72+
[InlineData("0")]
73+
[InlineData("99999")]
74+
[InlineData("22;99999")]
6675
// A shortened or partly typed IPv4 address
6776
[InlineData("10.1")]
6877
[InlineData("192.168.1")]

‎Source/NETworkManager.Tests/Utilities/RegexHelperTests.cs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ public void MACAddressFirst3Bytes(string input, bool isMatch)
3737
[InlineData("::ffff:192.168.0.1/96", true)]
3838
[InlineData("sss2001:db8::/32", false)]
3939
[InlineData("2001:db8::/129", false)]
40+
// Whitespace and zone index can't be parsed as a subnet
41+
[InlineData("2001:db8:: /32", false)]
42+
[InlineData("fe80::1%eth0/64", false)]
4043
public void IPv6AddressCidr(string input, bool isMatch)
4144
{
4245
Assert.Equal(isMatch, Regex.IsMatch(input, RegexHelper.IPv6AddressCidrRegex));

‎Source/NETworkManager.Utilities/RegexHelper.cs‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -154,10 +154,11 @@ public static partial class RegexHelper
154154
public const string IPv6AddressRegex =
155155
@"(?:^|(?<=\s))(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))(?=\s|$)";
156156

157-
// Match a subnet like 2001:0db8::/64 (also with an embedded IPv4 address like ::ffff:192.168.0.1/96)
157+
// Match a subnet like 2001:0db8::/64 (also with an embedded IPv4 address like ::ffff:192.168.0.1/96). Without
158+
// whitespace and zone index (e.g. "%eth0"), which a subnet can't be parsed with.
158159
// ReSharper disable once InconsistentNaming
159160
public const string IPv6AddressCidrRegex =
160-
@"^\s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))(%.+)?\s*(\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])){1}?$";
161+
@"^((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))(\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])){1}?$";
161162

162163
// Match a range like [0-255], [0,2,4] and [2,4-6]
163164
public const string SpecialRangeRegex =

‎Source/NETworkManager/RunCommandManager.cs‎

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,23 @@ public static IEnumerable<string> GetDNSServers()
115115
return SettingsManager.Current.DNSLookup_DNSServers.Select(x => x.Name);
116116
}
117117

118+
/// <summary>
119+
/// Gets the port profiles of the Port Scanner (e.g. "Webserver"), which can be used instead of ports.
120+
/// </summary>
121+
/// <returns>Port profiles.</returns>
122+
public static IEnumerable<PortProfileInfo> GetPortProfiles()
123+
{
124+
return SettingsManager.Current.PortScanner_PortProfiles;
125+
}
126+
127+
/// <summary>
128+
/// Gets the port profile with the name (case-insensitive), or null if there is none.
129+
/// </summary>
130+
private static PortProfileInfo GetPortProfile(string name)
131+
{
132+
return GetPortProfiles().FirstOrDefault(x => string.Equals(x.Name, name, StringComparison.OrdinalIgnoreCase));
133+
}
134+
118135
/// <summary>
119136
/// Gets the translated name of the application (or settings) of a command.
120137
/// </summary>
@@ -148,13 +165,14 @@ public static IEnumerable<ProfileInfo> GetProfiles(RunCommandDefinition command)
148165
/// </summary>
149166
/// <param name="target">Target (e.g. "10.0.0.1").</param>
150167
/// <param name="commands">Available commands.</param>
168+
/// <param name="kind">Kind of the target if it is already known, otherwise it is detected.</param>
151169
/// <returns>Commands with the input to run, in the default order of the kind of the target.</returns>
152170
public static IEnumerable<(RunCommandDefinition Command, string Text)> GetCommandsForTarget(string target,
153-
IReadOnlyList<RunCommandDefinition> commands)
171+
IReadOnlyList<RunCommandDefinition> commands, RunCommandTargetKind? kind = null)
154172
{
155-
var kind = RunCommandTarget.GetKind(target);
173+
kind ??= RunCommandTarget.GetKind(target);
156174

157-
if (!TargetApplications.TryGetValue(kind, out var applications))
175+
if (!TargetApplications.TryGetValue(kind.Value, out var applications))
158176
yield break;
159177

160178
foreach (var application in applications)
@@ -210,6 +228,15 @@ public static string TryCreateRequest(RunCommandParseResult result, ProfileInfo
210228
{
211229
request = null;
212230

231+
// A profile selected in the suggestions is run with its settings, also if its name can't be typed (e.g. it
232+
// starts with a quote, which is parsed as a quoted name).
233+
if (profile != null && result.Command is { Type: RunCommandType.Application })
234+
{
235+
request = new ApplicationRequest(result.Command.Application) { Profile = profile };
236+
237+
return null;
238+
}
239+
213240
var error = GetParseErrorMessage(result);
214241

215242
if (error != null)
@@ -223,6 +250,10 @@ public static string TryCreateRequest(RunCommandParseResult result, ProfileInfo
223250
{
224251
if (profile == null)
225252
{
253+
// The profiles are not loaded until the (encrypted) profile file is unlocked.
254+
if (ConfigurationManager.Current.ProfileManagerShowUnlock)
255+
return Strings.UnlockTheProfileFileMessage;
256+
226257
// The name is compared like it is parsed (without surrounding whitespace and quotes).
227258
var profiles = GetProfiles(result.Command)
228259
.Where(x => string.Equals(x.Name.Trim().Trim('"').Trim(), result.ProfileName,
@@ -307,6 +338,10 @@ ApplicationName.Traceroute or ApplicationName.PowerShell or ApplicationName.PuTT
307338
if (error != null)
308339
return error;
309340

341+
// The Port Scanner needs ports (or a port profile), a new tab has none.
342+
if (result.Command.Argument == RunCommandArgument.Ports && result.Argument == null)
343+
return Strings.EnterValidPortOrPortRange;
344+
310345
if (result.Argument != null)
311346
{
312347
ValidationRule argumentValidator = result.Command.Argument switch
@@ -319,6 +354,8 @@ ApplicationName.Traceroute or ApplicationName.PowerShell or ApplicationName.PuTT
319354
error = result.Command.Argument switch
320355
{
321356
RunCommandArgument.DNSServer => ValidateDNSServer(result.Argument),
357+
// A port profile (e.g. "Webserver") or ports
358+
RunCommandArgument.Ports when GetPortProfile(result.Argument) != null => null,
322359
RunCommandArgument.Subnet => ValidateSubnetArgument(result.Target, result.Argument),
323360
_ => Validate(argumentValidator, result.Argument)
324361
};
@@ -409,7 +446,12 @@ private static ApplicationRequest CreateRequest(RunCommandParseResult result)
409446
// Without a type, the DNS lookup uses PTR for IP addresses and its selected type for hosts.
410447
QueryType = result.Type != null ? Enum.Parse<QueryType>(result.Type, true) : null
411448
},
412-
ApplicationName.PortScanner => new PortScannerRequest { Target = result.Target, Ports = result.Argument },
449+
// A port profile is used with its ports.
450+
ApplicationName.PortScanner => new PortScannerRequest
451+
{
452+
Target = result.Target,
453+
Ports = GetPortProfile(result.Argument)?.Ports ?? result.Argument
454+
},
413455
ApplicationName.WakeOnLAN => new WakeOnLANRequest { Target = result.Target, Broadcast = result.Argument },
414456
ApplicationName.RDAP => new RDAPRequest { Target = result.Target, QueryType = GetRDAPQueryType(result.Type) },
415457
// The argument is a new subnet mask (Subnetting) or a second subnet (Wide Subnet).
@@ -444,8 +486,12 @@ static RunCommandSyntaxPart Argument(string name) =>
444486
var commands = ApplicationManager.GetNames().Where(x => x != ApplicationName.None).Select(name => name switch
445487
{
446488
ApplicationName.IPScanner => Create(name, ["ipscan"], [Target(Strings.IPRange)]),
447-
ApplicationName.PortScanner => Create(name, ["portscan"], [Target(Strings.Hosts), Argument(Strings.Ports)],
448-
RunCommandArgument.Ports),
489+
ApplicationName.PortScanner => Create(name, ["portscan"],
490+
[
491+
Target(Strings.Hosts),
492+
RunCommandSyntaxPart.Required(RunCommandSyntaxPartKind.Argument,
493+
$"{Strings.Ports} | {Strings.PortProfile}")
494+
], RunCommandArgument.Ports),
449495
ApplicationName.PingMonitor => Create(name, ["ping"], [Target(Strings.Hosts)]),
450496
ApplicationName.Traceroute => Create(name, ["trace", "tracert"], [Target(Strings.Host)]),
451497
ApplicationName.DNSLookup => Create(name, ["dns", "nslookup"],

0 commit comments

Comments
 (0)