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
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:
198
server/internal/recon/relay_scan.go
Normal file
198
server/internal/recon/relay_scan.go
Normal 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
|
||||
}
|
||||
}
|
||||
19
server/internal/recon/relay_scan_test.go
Normal file
19
server/internal/recon/relay_scan_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user