Files
AetherForge/server/internal/api/fleet_handler.go

431 lines
11 KiB
Go

package api
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strconv"
"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
}
var (
xmrPriceMu sync.Mutex
xmrPriceCache *xmrPriceEntry
xmrPriceTTL = 10 * time.Minute
)
type FleetHandler struct {
db *db.Database
ws *WSHub
ai *AIHandler
pools *pool.Manager
alerts *alerts.Evaluator
defaultPool pool.Config
// 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) {
xmrPriceMu.Lock()
if xmrPriceCache != nil && time.Since(xmrPriceCache.fetchedAt) < xmrPriceTTL {
usd := xmrPriceCache.USD
at := xmrPriceCache.fetchedAt
xmrPriceMu.Unlock()
writeJSON(w, map[string]interface{}{
"usd": usd,
"fetched_at": at.UTC().Format(time.RFC3339),
"source": "coingecko",
})
return
}
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()}
xmrPriceMu.Lock()
xmrPriceCache = entry
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 err := f.ws.SendAgentCommand(id, req.Action, args); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
}
writeJSON(w, map[string]interface{}{
"success": true,
"agent_id": id,
"action": req.Action,
})
}
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.",
}
}
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
}