4
0

DiscoverNetworkCommand.cs 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153
  1. using System.ComponentModel;
  2. using RackPeek.Domain.Discovery;
  3. using RackPeek.Domain.Resources;
  4. using RackPeek.Domain.Resources.Services.Networking;
  5. using Spectre.Console;
  6. using Spectre.Console.Cli;
  7. using NetworkCidr = RackPeek.Domain.Resources.Services.Networking.Cidr;
  8. namespace Shared.Rcl.Commands.Discovery;
  9. public sealed class DiscoverNetworkSettings : DiscoverSettings {
  10. private IReadOnlyList<int>? _resolvedPorts;
  11. [CommandOption("--cidr <CIDR>")]
  12. [Description("Subnet to sweep, e.g. 192.168.1.0/24. Defaults to this machine's own subnet.")]
  13. public string? Cidr { get; init; }
  14. [CommandOption("--ports <LIST>")]
  15. [Description("TCP ports probed to catch hosts that ignore ping, e.g. 22,80,443. " +
  16. "Defaults to a curated homelab list.")]
  17. public string? Ports { get; init; }
  18. [CommandOption("--timeout <MS>")]
  19. [Description("Milliseconds to wait on each port probe.")]
  20. public int Timeout { get; init; } = 500;
  21. [CommandOption("--parallel <N>")]
  22. [Description("How many hosts to probe at once.")]
  23. public int Parallel { get; init; } = 128;
  24. /// <summary>The parsed --cidr, or null when it was omitted or does not parse.</summary>
  25. public NetworkCidr? ParsedCidr =>
  26. NetworkCidr.TryParse(Cidr, out NetworkCidr parsed) ? parsed : null;
  27. public IReadOnlyList<int> ResolvedPorts =>
  28. _resolvedPorts ??= string.IsNullOrWhiteSpace(Ports)
  29. ? WellKnownPorts.Defaults
  30. : ParsePorts(Ports) ?? WellKnownPorts.Defaults;
  31. public override ValidationResult Validate() {
  32. if (Cidr != null) {
  33. if (ParsedCidr is not { } parsed)
  34. return ValidationResult.Error(
  35. $"'{Cidr}' is not a usable CIDR block. Use e.g. --cidr 192.168.1.0/24");
  36. if (parsed.Prefix < NetworkScanner.MinPrefix)
  37. return ValidationResult.Error(
  38. $"/{parsed.Prefix} is more than 65,534 hosts. Narrow the sweep to /{NetworkScanner.MinPrefix} or smaller.");
  39. }
  40. if (Ports != null && ParsePorts(Ports) == null)
  41. return ValidationResult.Error(
  42. $"'{Ports}' is not a usable port list. Use e.g. --ports 22,80,443");
  43. if (Timeout is < 1 or > 60_000)
  44. return ValidationResult.Error("--timeout must be between 1 and 60000 milliseconds.");
  45. if (Parallel is < 1 or > 1024)
  46. return ValidationResult.Error("--parallel must be between 1 and 1024.");
  47. return base.Validate();
  48. }
  49. private static IReadOnlyList<int>? ParsePorts(string list) {
  50. var ports = new List<int>();
  51. foreach (var part in list.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) {
  52. if (!int.TryParse(part, out var port) || port is < 1 or > 65_535)
  53. return null;
  54. ports.Add(port);
  55. }
  56. return ports.Count == 0 ? null : ports;
  57. }
  58. }
  59. /// <summary>
  60. /// Sweeps a subnet and emits every answering host as a System resource — the
  61. /// collector for machines nothing else can describe: no agent, no API, just an
  62. /// address that answers.
  63. /// </summary>
  64. public sealed class DiscoverNetworkCommand(INetworkProbe probe)
  65. : AsyncCommand<DiscoverNetworkSettings> {
  66. protected override async Task<int> ExecuteAsync(
  67. CommandContext context,
  68. DiscoverNetworkSettings settings,
  69. CancellationToken cancellationToken) {
  70. if (!probe.IsSupported) {
  71. // Without this, the browser console's sandboxed sockets would swallow every
  72. // probe and the command would report an empty network as if it were true.
  73. AnsiConsole.MarkupLine(
  74. "[red]Network scanning is not supported on this platform.[/] " +
  75. "Run rpk on a machine attached to the network instead.");
  76. return 1;
  77. }
  78. Cidr cidr;
  79. if (settings.ParsedCidr is { } requested) {
  80. cidr = requested;
  81. }
  82. else {
  83. Cidr? detected = probe.LocalSubnet();
  84. if (detected == null) {
  85. AnsiConsole.MarkupLine(
  86. "[red]Could not detect this machine's subnet.[/] Pass --cidr, e.g. --cidr 192.168.1.0/24");
  87. return 1;
  88. }
  89. // The same cap --cidr gets: a VPN or CGNAT interface can carry a /10, and
  90. // auto-detection must never be the way around the sweep limit.
  91. if (detected.Value.Prefix < NetworkScanner.MinPrefix) {
  92. AnsiConsole.MarkupLine(
  93. $"[red]This machine's subnet is {Markup.Escape(detected.Value.ToString())} — more than " +
  94. $"65,534 hosts.[/] Pass --cidr with a narrower block, e.g. --cidr 192.168.1.0/24");
  95. return 1;
  96. }
  97. cidr = detected.Value;
  98. }
  99. var options = new NetworkScanOptions {
  100. Cidr = cidr,
  101. Ports = settings.ResolvedPorts,
  102. PortTimeout = TimeSpan.FromMilliseconds(settings.Timeout),
  103. Concurrency = settings.Parallel
  104. };
  105. var targets = NetworkScanner.EnumerateTargets(cidr).Count();
  106. AnsiConsole.MarkupLine(
  107. $"[grey]Sweeping {Markup.Escape(cidr.ToString())} — {targets} address(es), " +
  108. $"ping + {options.Ports.Count} TCP port(s)…[/]");
  109. IReadOnlyList<NetworkHostFact> hosts = await NetworkScanner.ScanAsync(probe, options, cancellationToken);
  110. var withoutMac = hosts.Count(h => h.Mac == null);
  111. if (withoutMac > 0)
  112. AnsiConsole.MarkupLine(
  113. $"[grey]{withoutMac} host(s) had no ARP entry, so their identity is seeded on the IP " +
  114. "address — a DHCP re-lease will make them look like new machines.[/]");
  115. List<Resource> resources = NetworkScanMapper.ToResources(hosts);
  116. return await DiscoveryOutput.EmitAsync(resources, settings, cancellationToken);
  117. }
  118. }