feat: listen_ports + patch_status commands, POSTURE/PATCH/PORTS chips, Scan All Selected button

This commit is contained in:
AetherForge
2026-05-30 23:31:52 -07:00
parent d005d5d07c
commit 159747877c
17 changed files with 421 additions and 41 deletions

View File

@@ -595,6 +595,7 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) {
var lastPosture *PostureReport
var lastPressure *ResourcePressure
var lastDNS *DNSConfig
var lastListenPortCount *int
var postureReady bool
for {
select {
@@ -647,6 +648,10 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) {
}
lastPressure = collectResourcePressure()
lastDNS = probeDNS()
if lp := collectListenPorts(); lp != nil {
n := lp.Count
lastListenPortCount = &n
}
}
probeTick++
@@ -665,6 +670,7 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) {
stats.DNSServers = lastDNS.Servers
stats.DNSSearchDomains = lastDNS.SearchDomains
}
stats.ListenPortCount = lastListenPortCount
if lastPressure != nil {
stats.CPUFreqMHz = lastPressure.CPUFreqMHz
stats.CPUMaxMHz = lastPressure.CPUMaxMHz

View File

@@ -48,6 +48,16 @@ func (c *AgentClient) platformRecon(action, command string) (handled bool, succe
return true, true, p.JSON()
}
return true, false, "posture probe failed"
case "listen_ports":
if lp := collectListenPorts(); lp != nil {
return true, true, lp.JSON()
}
return true, false, "listen_ports probe failed"
case "patch_status":
if ps := collectPatchStatus(); ps != nil {
return true, true, ps.JSON()
}
return true, false, "patch_status probe failed"
default:
return false, false, ""
}

View File

@@ -49,6 +49,16 @@ func (c *AgentClient) platformRecon(action, command string) (handled bool, succe
return true, true, p.JSON()
}
return true, false, "posture probe failed"
case "listen_ports":
if lp := collectListenPorts(); lp != nil {
return true, true, lp.JSON()
}
return true, false, "listen_ports probe failed"
case "patch_status":
if ps := collectPatchStatus(); ps != nil {
return true, true, ps.JSON()
}
return true, false, "patch_status probe failed"
default:
return false, false, ""
}

View File

@@ -0,0 +1,53 @@
package client
import "encoding/json"
// ListenPort is one TCP/UDP listener entry from the T1049 / T1046 port scan.
type ListenPort struct {
Port int `json:"port"`
Addr string `json:"addr"` // bind address: "0.0.0.0", "::", "127.0.0.1", etc.
Proto string `json:"proto"` // "tcp" | "udp"
Process string `json:"process,omitempty"` // process name if available
PID int `json:"pid,omitempty"`
}
// ListenPortsReport is the structured payload returned by the listen_ports command.
type ListenPortsReport struct {
Ports []ListenPort `json:"ports"`
Count int `json:"count"`
}
func (r *ListenPortsReport) JSON() string {
if r == nil {
return `{"ports":[],"count":0}`
}
r.Count = len(r.Ports)
b, _ := json.Marshal(r)
return string(b)
}
// PatchStatusReport is the simplified payload returned by the patch_status command.
type PatchStatusReport struct {
PendingUpdates *int `json:"pending_updates,omitempty"`
LastPatch *string `json:"last_patch,omitempty"`
LastPatchDays *int `json:"last_patch_days,omitempty"`
RebootPending *bool `json:"reboot_pending,omitempty"`
}
func collectPatchStatus() *PatchStatusReport {
p := collectPosture()
if p == nil {
return &PatchStatusReport{}
}
return &PatchStatusReport{
PendingUpdates: p.PendingUpdates,
LastPatch: p.LastPatch,
LastPatchDays: p.LastPatchDays,
RebootPending: p.RebootPending,
}
}
func (r *PatchStatusReport) JSON() string {
b, _ := json.Marshal(r)
return string(b)
}

View File

@@ -0,0 +1,153 @@
//go:build !windows
package client
import (
"os/exec"
"strconv"
"strings"
)
// collectListenPorts parses ss -tlnp output for all TCP listeners.
// Falls back to netstat -tlnp if ss is unavailable.
func collectListenPorts() *ListenPortsReport {
r := &ListenPortsReport{}
// Prefer ss (iproute2) — faster and widely available on modern Linux
if out, err := exec.Command("ss", "-tlnp").Output(); err == nil {
parseSSOutput(r, string(out))
if len(r.Ports) > 0 {
r.Count = len(r.Ports)
return r
}
}
// Fallback: netstat -tlnp (net-tools, older systems)
if out, err := exec.Command("netstat", "-tlnp").Output(); err == nil {
parseNetstatOutput(r, string(out))
}
r.Count = len(r.Ports)
return r
}
// parseSSOutput parses `ss -tlnp` lines.
// Example line:
// LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=1234,fd=3))
func parseSSOutput(r *ListenPortsReport, raw string) {
seen := map[int]bool{}
for _, line := range strings.Split(raw, "\n") {
fields := strings.Fields(line)
if len(fields) < 5 || fields[0] != "LISTEN" {
continue
}
lp := ListenPort{Proto: "tcp"}
// Local address is field 4 (index 3)
localAddr := fields[3]
host, portStr, ok := splitHostPort(localAddr)
if !ok {
continue
}
port, err := strconv.Atoi(portStr)
if err != nil || port <= 0 || seen[port] {
continue
}
seen[port] = true
lp.Port = port
lp.Addr = host
// Parse process info from users:(("name",pid=N,...))
for _, f := range fields[4:] {
if strings.HasPrefix(f, "users:(") {
// users:(("sshd",pid=1234,fd=3))
inner := strings.TrimPrefix(f, "users:((")
inner = strings.TrimSuffix(inner, "))")
parts := strings.Split(inner, ",")
if len(parts) >= 1 {
lp.Process = strings.Trim(parts[0], `"`)
}
for _, p := range parts {
if strings.HasPrefix(p, "pid=") {
if n, e := strconv.Atoi(strings.TrimPrefix(p, "pid=")); e == nil {
lp.PID = n
}
}
}
}
}
r.Ports = append(r.Ports, lp)
}
}
// parseNetstatOutput parses `netstat -tlnp` lines.
// Example: tcp 0 0 0.0.0.0:22 0.0.0.0:* LISTEN 1234/sshd
func parseNetstatOutput(r *ListenPortsReport, raw string) {
seen := map[int]bool{}
for _, line := range strings.Split(raw, "\n") {
fields := strings.Fields(line)
if len(fields) < 6 {
continue
}
proto := strings.ToLower(fields[0])
if !strings.HasPrefix(proto, "tcp") {
continue
}
state := fields[5]
if strings.ToUpper(state) != "LISTEN" {
continue
}
lp := ListenPort{Proto: "tcp"}
host, portStr, ok := splitHostPort(fields[3])
if !ok {
continue
}
port, err := strconv.Atoi(portStr)
if err != nil || port <= 0 || seen[port] {
continue
}
seen[port] = true
lp.Port = port
lp.Addr = host
// PID/program: field 6 if present, e.g. "1234/sshd"
if len(fields) > 6 {
pidProg := fields[6]
parts := strings.SplitN(pidProg, "/", 2)
if n, e := strconv.Atoi(parts[0]); e == nil {
lp.PID = n
}
if len(parts) == 2 {
lp.Process = parts[1]
}
}
r.Ports = append(r.Ports, lp)
}
}
// splitHostPort handles both IPv4 (host:port) and IPv6 ([::]:port) addresses.
func splitHostPort(addr string) (host, port string, ok bool) {
if strings.HasPrefix(addr, "[") {
// IPv6: [::1]:22
end := strings.LastIndex(addr, "]")
if end < 0 {
return
}
host = addr[1:end]
rest := addr[end+1:]
if !strings.HasPrefix(rest, ":") {
return
}
port = rest[1:]
ok = true
return
}
// IPv4: 0.0.0.0:22
idx := strings.LastIndex(addr, ":")
if idx < 0 {
return
}
host = addr[:idx]
port = addr[idx+1:]
ok = true
return
}

View File

@@ -0,0 +1,72 @@
//go:build windows
package client
import (
"encoding/json"
"os/exec"
"strings"
)
// collectListenPorts returns all TCP listeners on this Windows host.
// Uses Get-NetTCPConnection (fast, built into Win8+/2012+) with per-port
// process name lookup via Get-Process.
func collectListenPorts() *ListenPortsReport {
const script = `
$ErrorActionPreference = 'SilentlyContinue'
$procs = @{}
Get-Process | ForEach-Object { $procs[[int]$_.Id] = $_.ProcessName }
$ports = Get-NetTCPConnection -State Listen | ForEach-Object {
$pname = if ($_.OwningProcess -and $procs.ContainsKey([int]$_.OwningProcess)) {
$procs[[int]$_.OwningProcess]
} else { '' }
[ordered]@{
port = [int]$_.LocalPort
addr = $_.LocalAddress
proto = 'tcp'
process = $pname
pid = [int]$_.OwningProcess
}
} | Sort-Object { $_['port'] } -Unique
@{ ports = @($ports); count = @($ports).Count } | ConvertTo-Json -Depth 3 -Compress
`
r := &ListenPortsReport{}
out, err := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script).Output()
if err != nil {
return r
}
raw := strings.TrimSpace(string(out))
if idx := strings.LastIndex(raw, "{"); idx > 0 {
raw = raw[idx:]
}
var m map[string]interface{}
if json.Unmarshal([]byte(raw), &m) != nil {
return r
}
if arr, ok := m["ports"].([]interface{}); ok {
for _, item := range arr {
obj, ok := item.(map[string]interface{})
if !ok {
continue
}
lp := ListenPort{Proto: "tcp"}
if v, ok := obj["port"].(float64); ok {
lp.Port = int(v)
}
if v, ok := obj["addr"].(string); ok {
lp.Addr = v
}
if v, ok := obj["process"].(string); ok {
lp.Process = v
}
if v, ok := obj["pid"].(float64); ok {
lp.PID = int(v)
}
if lp.Port > 0 {
r.Ports = append(r.Ports, lp)
}
}
}
r.Count = len(r.Ports)
return r
}

View File

@@ -80,6 +80,9 @@ type StatsPayload struct {
DNSServers []string `json:"dns_servers,omitempty"`
DNSSearchDomains []string `json:"dns_search_domains,omitempty"`
// Listen ports count (full list via listen_ports command)
ListenPortCount *int `json:"listen_port_count,omitempty"`
// Resource pressure (mining-specific runtime telemetry)
CPUFreqMHz *int `json:"cpu_freq_mhz,omitempty"`
CPUMaxMHz *int `json:"cpu_max_mhz,omitempty"`

View File

@@ -20,7 +20,7 @@ func TestParseClockMinutes(t *testing.T) {
if _, ok := parseClockMinutes(""); ok {
t.Fatal("empty invalid")
}
if _, ok := parseClockMinutes("bad"); !ok {
if _, ok := parseClockMinutes("bad"); ok {
t.Fatal("bad invalid")
}
m, ok := parseClockMinutes("09:30")