DiscoverNetworkCommand.cs 6.1 KB

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