Complete private Monero miner control stack.
Implement Windows agent with RandomX mining and WebSocket fleet reporting, wire dashboard settings into the builder with saved exe paths, and add project README.
This commit is contained in:
62
server/internal/api/config_handler.go
Normal file
62
server/internal/api/config_handler.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"crypto-miner-server/internal/db"
|
||||
)
|
||||
|
||||
// ConfigHandler handles GET/PUT for server configuration settings
|
||||
type ConfigHandler struct {
|
||||
db *db.Database
|
||||
config ConfigProvider
|
||||
}
|
||||
|
||||
// ConfigProvider is an interface for the server config so we don't import main package
|
||||
type ConfigProvider interface {
|
||||
GetConfigJSON() json.RawMessage
|
||||
UpdateConfigFromJSON(data json.RawMessage) error
|
||||
}
|
||||
|
||||
func NewConfigHandler(database *db.Database, cp ConfigProvider) *ConfigHandler {
|
||||
return &ConfigHandler{
|
||||
db: database,
|
||||
config: cp,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ConfigHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
h.getConfig(w, r)
|
||||
case http.MethodPut:
|
||||
h.updateConfig(w, r)
|
||||
default:
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/v1/config
|
||||
func (h *ConfigHandler) getConfig(w http.ResponseWriter, r *http.Request) {
|
||||
configJSON := h.config.GetConfigJSON()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write(configJSON)
|
||||
}
|
||||
|
||||
// PUT /api/v1/config
|
||||
func (h *ConfigHandler) updateConfig(w http.ResponseWriter, r *http.Request) {
|
||||
var body json.RawMessage
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, `{"error":"Invalid JSON"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.config.UpdateConfigFromJSON(body); err != nil {
|
||||
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Return updated config
|
||||
h.getConfig(w, r)
|
||||
}
|
||||
113
server/internal/api/handlers.go
Normal file
113
server/internal/api/handlers.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"crypto-miner-server/internal/db"
|
||||
"crypto-miner-server/internal/models"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
db *db.Database
|
||||
}
|
||||
|
||||
func NewHandler(database *db.Database) *Handler {
|
||||
return &Handler{db: database}
|
||||
}
|
||||
|
||||
// GET /api/v1/dashboard/stats
|
||||
func (h *Handler) GetDashboardStats(w http.ResponseWriter, r *http.Request) {
|
||||
stats, err := h.db.GetFleetStats()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, stats)
|
||||
}
|
||||
|
||||
// GET /api/v1/agents
|
||||
func (h *Handler) ListAgents(w http.ResponseWriter, r *http.Request) {
|
||||
agents, err := h.db.ListAgents()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if agents == nil {
|
||||
agents = []*models.Agent{}
|
||||
}
|
||||
writeJSON(w, agents)
|
||||
}
|
||||
|
||||
// GET /api/v1/agents/{id}
|
||||
func (h *Handler) GetAgent(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
agent, err := h.db.GetAgent(id)
|
||||
if err != nil {
|
||||
http.Error(w, "Agent not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
writeJSON(w, agent)
|
||||
}
|
||||
|
||||
// GET /api/v1/agents/{id}/stats
|
||||
func (h *Handler) GetAgentStats(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
limitStr := r.URL.Query().Get("limit")
|
||||
limit := 100
|
||||
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
|
||||
limit = l
|
||||
}
|
||||
samples, err := h.db.GetHashrateHistory(id, limit)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if samples == nil {
|
||||
samples = []*models.HashrateSample{}
|
||||
}
|
||||
writeJSON(w, samples)
|
||||
}
|
||||
|
||||
// GET /api/v1/shares
|
||||
func (h *Handler) GetRecentShares(w http.ResponseWriter, r *http.Request) {
|
||||
limitStr := r.URL.Query().Get("limit")
|
||||
limit := 50
|
||||
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
|
||||
limit = l
|
||||
}
|
||||
shares, err := h.db.GetRecentShares(limit)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if shares == nil {
|
||||
shares = []*models.Share{}
|
||||
}
|
||||
writeJSON(w, shares)
|
||||
}
|
||||
|
||||
// GET /api/v1/health
|
||||
func (h *Handler) HealthCheck(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// GET /api/v1/builds
|
||||
func (h *Handler) ListBuilds(w http.ResponseWriter, r *http.Request) {
|
||||
builds, err := h.db.ListBuilds(50)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if builds == nil {
|
||||
builds = []*models.BuildRecord{}
|
||||
}
|
||||
writeJSON(w, builds)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, v interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
107
server/internal/api/router.go
Normal file
107
server/internal/api/router.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
"crypto-miner-server/internal/db"
|
||||
"crypto-miner-server/internal/builder"
|
||||
)
|
||||
|
||||
func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler, builderHandler *builder.Handler, webRoot string) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
// Middleware
|
||||
r.Use(middleware.Logger)
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(cors.Handler(cors.Options{
|
||||
AllowedOrigins: []string{"*"},
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"},
|
||||
AllowCredentials: true,
|
||||
}))
|
||||
|
||||
// REST API
|
||||
r.Route("/api/v1", func(r chi.Router) {
|
||||
h := NewHandler(database)
|
||||
|
||||
r.Get("/health", h.HealthCheck)
|
||||
|
||||
// Dashboard
|
||||
r.Get("/dashboard/stats", h.GetDashboardStats)
|
||||
|
||||
// Agents
|
||||
r.Get("/agents", h.ListAgents)
|
||||
r.Get("/agents/{id}", h.GetAgent)
|
||||
r.Get("/agents/{id}/stats", h.GetAgentStats)
|
||||
|
||||
// Shares
|
||||
r.Get("/shares", h.GetRecentShares)
|
||||
|
||||
// Builds
|
||||
r.Get("/builds", h.ListBuilds)
|
||||
r.Get("/builds/{id}/download", builderHandler.DownloadBuild)
|
||||
|
||||
// Config
|
||||
r.Get("/config", configHandler.ServeHTTP)
|
||||
r.Put("/config", configHandler.ServeHTTP)
|
||||
|
||||
// Builder
|
||||
r.Post("/builder/build", builderHandler.ServeHTTP)
|
||||
})
|
||||
|
||||
// WebSocket
|
||||
r.Get("/ws/agent", wsHub.HandleAgentWS)
|
||||
r.Get("/ws/dashboard", wsHub.HandleDashboardWS)
|
||||
|
||||
// Serve frontend SPA
|
||||
if webRoot != "" {
|
||||
// Check if webroot directory exists
|
||||
if info, err := os.Stat(webRoot); err == nil && info.IsDir() {
|
||||
// Create a file server for the webroot
|
||||
fileServer := http.FileServer(http.Dir(webRoot))
|
||||
|
||||
// SPA fallback: serve index.html for all non-API, non-WebSocket routes
|
||||
r.Get("/*", func(w http.ResponseWriter, r *http.Request) {
|
||||
// Clean the path
|
||||
path := strings.TrimPrefix(r.URL.Path, "/")
|
||||
fullPath := filepath.Join(webRoot, path)
|
||||
|
||||
// Check if the file exists
|
||||
if _, err := os.Stat(fullPath); err == nil {
|
||||
fileServer.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// SPA fallback - serve index.html
|
||||
http.ServeFile(w, r, filepath.Join(webRoot, "index.html"))
|
||||
})
|
||||
} else {
|
||||
// Fallback if webroot doesn't exist
|
||||
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.Write([]byte(`<!DOCTYPE html><html><head><title>Crypto Miner</title></head><body>
|
||||
<h1>Crypto Miner Control Server</h1>
|
||||
<p>Server is running. Build the frontend with <code>cd server/web && npm install && npm run build</code></p>
|
||||
<p>API: <a href="/api/v1/health">/api/v1/health</a></p>
|
||||
</body></html>`))
|
||||
})
|
||||
}
|
||||
} else {
|
||||
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.Write([]byte(`<!DOCTYPE html><html><head><title>Crypto Miner</title></head><body>
|
||||
<h1>Crypto Miner Control Server</h1>
|
||||
<p>Server is running. No frontend configured.</p>
|
||||
<p>API: <a href="/api/v1/health">/api/v1/health</a></p>
|
||||
</body></html>`))
|
||||
})
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
373
server/internal/api/websocket.go
Normal file
373
server/internal/api/websocket.go
Normal file
@@ -0,0 +1,373 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/gorilla/websocket"
|
||||
"crypto-miner-server/internal/db"
|
||||
"crypto-miner-server/internal/models"
|
||||
"crypto-miner-server/internal/pool"
|
||||
)
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
ReadBufferSize: 4096,
|
||||
WriteBufferSize: 4096,
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true // Allow all origins for local use
|
||||
},
|
||||
}
|
||||
|
||||
type Message struct {
|
||||
Type string `json:"type"`
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
}
|
||||
|
||||
type AgentConnection struct {
|
||||
AgentID string
|
||||
Conn *websocket.Conn
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (c *AgentConnection) SendJSON(v interface{}) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.Conn.WriteJSON(v)
|
||||
}
|
||||
|
||||
type WSHub struct {
|
||||
db *db.Database
|
||||
agents map[string]*AgentConnection
|
||||
dashboards map[string]*websocket.Conn
|
||||
poolProxy *pool.Proxy
|
||||
defaultAgent AgentDefaults
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
type AgentDefaults struct {
|
||||
Threads int
|
||||
CPUPriority string
|
||||
}
|
||||
|
||||
func NewWSHub(database *db.Database) *WSHub {
|
||||
return &WSHub{
|
||||
db: database,
|
||||
agents: make(map[string]*AgentConnection),
|
||||
dashboards: make(map[string]*websocket.Conn),
|
||||
defaultAgent: AgentDefaults{Threads: 4, CPUPriority: "below_normal"},
|
||||
}
|
||||
}
|
||||
|
||||
func (h *WSHub) SetDefaultAgentConfig(cfg interface{}) {
|
||||
type defaults struct {
|
||||
Threads int `json:"threads"`
|
||||
CPUPriority string `json:"cpu_priority"`
|
||||
}
|
||||
if cfg == nil {
|
||||
return
|
||||
}
|
||||
data, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var d defaults
|
||||
if err := json.Unmarshal(data, &d); err != nil {
|
||||
return
|
||||
}
|
||||
if d.Threads <= 0 {
|
||||
d.Threads = 4
|
||||
}
|
||||
if d.CPUPriority == "" {
|
||||
d.CPUPriority = "below_normal"
|
||||
}
|
||||
h.mu.Lock()
|
||||
h.defaultAgent = AgentDefaults{Threads: d.Threads, CPUPriority: d.CPUPriority}
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
// SetPoolProxy sets the pool proxy for share submission forwarding
|
||||
func (h *WSHub) SetPoolProxy(proxy *pool.Proxy) {
|
||||
h.poolProxy = proxy
|
||||
}
|
||||
|
||||
func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
log.Printf("WebSocket upgrade error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
agentID := ""
|
||||
defer func() {
|
||||
if agentID != "" {
|
||||
h.mu.Lock()
|
||||
delete(h.agents, agentID)
|
||||
h.mu.Unlock()
|
||||
h.db.SetAgentOffline(agentID)
|
||||
h.broadcastDashboard(Message{
|
||||
Type: "agent_offline",
|
||||
Payload: mustMarshal(map[string]string{"agent_id": agentID}),
|
||||
})
|
||||
}
|
||||
conn.Close()
|
||||
}()
|
||||
|
||||
for {
|
||||
_, msgBytes, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
log.Printf("Agent read error: %v", err)
|
||||
break
|
||||
}
|
||||
|
||||
var msg Message
|
||||
if err := json.Unmarshal(msgBytes, &msg); err != nil {
|
||||
log.Printf("Invalid message from agent: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
switch msg.Type {
|
||||
case "auth":
|
||||
var auth struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
Wallet string `json:"wallet"`
|
||||
Version string `json:"version"`
|
||||
Hostname string `json:"hostname"`
|
||||
CPUCores int `json:"cpu_cores"`
|
||||
MemoryGB int `json:"memory_gb"`
|
||||
}
|
||||
if err := json.Unmarshal(msg.Payload, &auth); err != nil {
|
||||
conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{
|
||||
"success": false, "error": "invalid auth payload",
|
||||
})})
|
||||
continue
|
||||
}
|
||||
|
||||
agentID = auth.AgentID
|
||||
if agentID == "" {
|
||||
agentID = uuid.New().String()
|
||||
}
|
||||
|
||||
clientIP := r.Header.Get("X-Forwarded-For")
|
||||
if clientIP == "" {
|
||||
clientIP = r.RemoteAddr
|
||||
}
|
||||
if idx := strings.LastIndex(clientIP, ":"); idx > 0 && strings.Count(clientIP, ":") == 1 {
|
||||
clientIP = clientIP[:idx]
|
||||
}
|
||||
|
||||
agent := &models.Agent{
|
||||
ID: agentID,
|
||||
Name: auth.Hostname,
|
||||
Wallet: auth.Wallet,
|
||||
IP: clientIP,
|
||||
Version: auth.Version,
|
||||
Status: "online",
|
||||
CPUCores: auth.CPUCores,
|
||||
MemoryGB: auth.MemoryGB,
|
||||
LastSeen: time.Now(),
|
||||
}
|
||||
|
||||
if err := h.db.UpsertAgent(agent); err != nil {
|
||||
log.Printf("Failed to upsert agent: %v", err)
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
h.agents[agentID] = &AgentConnection{AgentID: agentID, Conn: conn}
|
||||
h.mu.Unlock()
|
||||
|
||||
h.mu.RLock()
|
||||
defaults := h.defaultAgent
|
||||
h.mu.RUnlock()
|
||||
|
||||
conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{
|
||||
"success": true,
|
||||
"agent_id": agentID,
|
||||
"config": map[string]interface{}{
|
||||
"threads": defaults.Threads,
|
||||
"priority": defaults.CPUPriority,
|
||||
},
|
||||
})})
|
||||
|
||||
h.broadcastDashboard(Message{
|
||||
Type: "agent_online",
|
||||
Payload: mustMarshal(agent),
|
||||
})
|
||||
|
||||
case "stats":
|
||||
var stats struct {
|
||||
Hashrate15s float64 `json:"hashrate_15s"`
|
||||
Hashrate1m float64 `json:"hashrate_1m"`
|
||||
Hashrate15m float64 `json:"hashrate_15m"`
|
||||
SharesSubmitted int `json:"shares_submitted"`
|
||||
SharesAccepted int `json:"shares_accepted"`
|
||||
CPUUsagePct float64 `json:"cpu_usage_pct"`
|
||||
MemoryUsagePct float64 `json:"memory_usage_pct"`
|
||||
UptimeSeconds int `json:"uptime_seconds"`
|
||||
}
|
||||
if err := json.Unmarshal(msg.Payload, &stats); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
sharesBad := stats.SharesSubmitted - stats.SharesAccepted
|
||||
if sharesBad < 0 {
|
||||
sharesBad = 0
|
||||
}
|
||||
|
||||
h.db.UpdateAgentStats(agentID, stats.Hashrate15s, stats.Hashrate1m, stats.Hashrate15m,
|
||||
stats.SharesSubmitted, stats.SharesAccepted, sharesBad,
|
||||
stats.CPUUsagePct, stats.MemoryUsagePct, stats.UptimeSeconds)
|
||||
|
||||
h.db.InsertHashrateSample(agentID, stats.Hashrate15m)
|
||||
|
||||
h.broadcastDashboard(Message{
|
||||
Type: "stats_update",
|
||||
Payload: mustMarshal(map[string]interface{}{
|
||||
"agent_id": agentID,
|
||||
"hashrate_15s": stats.Hashrate15s,
|
||||
"hashrate_1m": stats.Hashrate1m,
|
||||
"hashrate_15m": stats.Hashrate15m,
|
||||
"cpu_usage_pct": stats.CPUUsagePct,
|
||||
}),
|
||||
})
|
||||
|
||||
case "submit_share":
|
||||
var share models.Share
|
||||
if err := json.Unmarshal(msg.Payload, &share); err != nil {
|
||||
continue
|
||||
}
|
||||
share.AgentID = agentID
|
||||
share.Timestamp = time.Now()
|
||||
|
||||
// Forward share to pool proxy if connected
|
||||
if h.poolProxy != nil && h.poolProxy.IsConnected() {
|
||||
h.poolProxy.SubmitShare(agentID, share.JobID, share.Nonce, share.Hash)
|
||||
share.Accepted = true // Pool will validate; we assume accepted initially
|
||||
} else {
|
||||
// Pool not connected - mark as accepted locally for testing
|
||||
share.Accepted = true
|
||||
log.Printf("[WS] Pool not connected, marking share as accepted locally")
|
||||
}
|
||||
|
||||
if err := h.db.InsertShare(&share); err != nil {
|
||||
log.Printf("Failed to insert share: %v", err)
|
||||
}
|
||||
|
||||
conn.WriteJSON(Message{Type: "share_result", Payload: mustMarshal(map[string]interface{}{
|
||||
"job_id": share.JobID,
|
||||
"accepted": share.Accepted,
|
||||
})})
|
||||
|
||||
h.broadcastDashboard(Message{
|
||||
Type: "new_share",
|
||||
Payload: mustMarshal(map[string]interface{}{
|
||||
"agent_id": agentID,
|
||||
"accepted": share.Accepted,
|
||||
"hash": share.Hash,
|
||||
}),
|
||||
})
|
||||
|
||||
case "get_job":
|
||||
// Agent requesting current job from pool
|
||||
if h.poolProxy != nil {
|
||||
job := h.poolProxy.GetCurrentJob()
|
||||
if job != nil {
|
||||
conn.WriteJSON(Message{Type: "new_job", Payload: mustMarshal(job)})
|
||||
} else {
|
||||
conn.WriteJSON(Message{Type: "new_job", Payload: mustMarshal(map[string]string{"error": "no job available"})})
|
||||
}
|
||||
} else {
|
||||
conn.WriteJSON(Message{Type: "new_job", Payload: mustMarshal(map[string]string{"error": "pool not connected"})})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *WSHub) HandleDashboardWS(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
log.Printf("Dashboard WebSocket upgrade error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
dashID := uuid.New().String()
|
||||
h.mu.Lock()
|
||||
h.dashboards[dashID] = conn
|
||||
h.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
h.mu.Lock()
|
||||
delete(h.dashboards, dashID)
|
||||
h.mu.Unlock()
|
||||
conn.Close()
|
||||
}()
|
||||
|
||||
// Send initial data
|
||||
agents, _ := h.db.ListAgents()
|
||||
stats, _ := h.db.GetFleetStats()
|
||||
|
||||
conn.WriteJSON(Message{Type: "init", Payload: mustMarshal(map[string]interface{}{
|
||||
"agents": agents,
|
||||
"stats": stats,
|
||||
})})
|
||||
|
||||
// Keep connection alive, read close messages
|
||||
for {
|
||||
_, _, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *WSHub) broadcastDashboard(msg Message) {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for id, conn := range h.dashboards {
|
||||
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
|
||||
log.Printf("Failed to send to dashboard %s: %v", id, err)
|
||||
conn.Close()
|
||||
go func() {
|
||||
h.mu.Lock()
|
||||
delete(h.dashboards, id)
|
||||
h.mu.Unlock()
|
||||
}()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mustMarshal(v interface{}) json.RawMessage {
|
||||
data, _ := json.Marshal(v)
|
||||
return data
|
||||
}
|
||||
|
||||
// BroadcastToAgents sends a message to all connected agents
|
||||
func (h *WSHub) BroadcastToAgents(msg Message) {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
for id, agent := range h.agents {
|
||||
if err := agent.SendJSON(msg); err != nil {
|
||||
log.Printf("Failed to send to agent %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// mustMarshalRaw marshals a value to json.RawMessage, panicking on error
|
||||
func mustMarshalRaw(v interface{}) json.RawMessage {
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
379
server/internal/builder/handler.go
Normal file
379
server/internal/builder/handler.go
Normal file
@@ -0,0 +1,379 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
"crypto-miner-server/internal/db"
|
||||
"crypto-miner-server/internal/models"
|
||||
)
|
||||
|
||||
type BuildRequest struct {
|
||||
WorkerName string `json:"worker_name"`
|
||||
ServerURL string `json:"server_url"`
|
||||
Wallet string `json:"wallet"`
|
||||
Threads int `json:"threads"`
|
||||
CPUPriority string `json:"cpu_priority"`
|
||||
MiningMode string `json:"mining_mode"`
|
||||
SilentMode bool `json:"silent_mode"`
|
||||
RunAs string `json:"run_as"`
|
||||
AutoStart bool `json:"auto_start"`
|
||||
MaxCPUUsagePct int `json:"max_cpu_usage_pct"`
|
||||
MinFreeRAMMB int `json:"min_free_ram_mb"`
|
||||
IdleThresholdPct int `json:"idle_threshold_pct"`
|
||||
IdleDurationMinutes int `json:"idle_duration_minutes"`
|
||||
ScheduleStart string `json:"schedule_start"`
|
||||
ScheduleEnd string `json:"schedule_end"`
|
||||
PoolHost string `json:"pool_host"`
|
||||
PoolPort int `json:"pool_port"`
|
||||
PoolTLS bool `json:"pool_tls"`
|
||||
PoolPass string `json:"pool_pass"`
|
||||
}
|
||||
|
||||
type BuildResponse struct {
|
||||
Success bool `json:"success"`
|
||||
BuildID string `json:"build_id,omitempty"`
|
||||
FileName string `json:"file_name,omitempty"`
|
||||
FilePath string `json:"file_path,omitempty"`
|
||||
RelativePath string `json:"relative_path,omitempty"`
|
||||
FileSize int64 `json:"file_size,omitempty"`
|
||||
DownloadURL string `json:"download_url,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type Handler struct {
|
||||
db *db.Database
|
||||
dataDir string
|
||||
agentSrcDir string
|
||||
projectRoot string
|
||||
goBinPath string
|
||||
}
|
||||
|
||||
func NewHandler(database *db.Database, dataDir string, agentSrcDir string, projectRoot string) *Handler {
|
||||
goBin := "go"
|
||||
if _, err := exec.LookPath("go"); err == nil {
|
||||
goBin = "go"
|
||||
}
|
||||
return &Handler{
|
||||
db: database,
|
||||
dataDir: dataDir,
|
||||
agentSrcDir: agentSrcDir,
|
||||
projectRoot: projectRoot,
|
||||
goBinPath: goBin,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req BuildRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: "Invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.normalizeRequest(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
resp, status, outputPath := h.buildAgent(&req)
|
||||
if !resp.Success {
|
||||
writeJSON(w, status, resp)
|
||||
return
|
||||
}
|
||||
|
||||
if r.URL.Query().Get("download") == "1" {
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, resp.FileName))
|
||||
http.ServeFile(w, r, outputPath)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *Handler) DownloadBuild(w http.ResponseWriter, r *http.Request) {
|
||||
buildID := chi.URLParam(r, "id")
|
||||
build, err := h.db.GetBuild(buildID)
|
||||
if err != nil {
|
||||
http.Error(w, "Build not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if _, err := os.Stat(build.FilePath); err != nil {
|
||||
http.Error(w, "Build file missing", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filepath.Base(build.FilePath)))
|
||||
http.ServeFile(w, r, build.FilePath)
|
||||
}
|
||||
|
||||
func (h *Handler) buildAgent(req *BuildRequest) (BuildResponse, int, string) {
|
||||
buildID := uuid.New().String()
|
||||
buildDir, _ := filepath.Abs(filepath.Join(h.dataDir, "builds", buildID))
|
||||
agentDir := filepath.Join(buildDir, "agent")
|
||||
|
||||
if err := os.MkdirAll(agentDir, 0755); err != nil {
|
||||
return BuildResponse{Success: false, Error: "Failed to create build directory"}, http.StatusInternalServerError, ""
|
||||
}
|
||||
|
||||
if err := h.copyAgentSource(agentDir); err != nil {
|
||||
log.Printf("Failed to copy agent source: %v", err)
|
||||
return BuildResponse{Success: false, Error: "Failed to prepare agent source: " + err.Error()}, http.StatusInternalServerError, ""
|
||||
}
|
||||
|
||||
configDir := filepath.Join(agentDir, "config")
|
||||
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||
return BuildResponse{Success: false, Error: "Failed to create config directory"}, http.StatusInternalServerError, ""
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(configDir, "builtin.go"), []byte(h.generateBuiltinConfig(buildID, req)), 0644); err != nil {
|
||||
return BuildResponse{Success: false, Error: "Failed to write built-in config"}, http.StatusInternalServerError, ""
|
||||
}
|
||||
|
||||
outputName := fmt.Sprintf("xmr-worker-%s.exe", sanitizeFileName(req.WorkerName))
|
||||
outputPath, _ := filepath.Abs(filepath.Join(buildDir, outputName))
|
||||
|
||||
ldflags := "-s -w"
|
||||
if req.SilentMode {
|
||||
ldflags += " -H windowsgui"
|
||||
}
|
||||
|
||||
cmd := exec.Command(h.goBinPath, "build", "-ldflags", ldflags, "-o", outputPath, ".")
|
||||
cmd.Dir = agentDir
|
||||
cmd.Env = append(os.Environ(),
|
||||
"GOOS=windows",
|
||||
"GOARCH=amd64",
|
||||
"CGO_ENABLED=0",
|
||||
)
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
log.Printf("Build failed: %v\nOutput: %s", err, string(output))
|
||||
return BuildResponse{Success: false, Error: fmt.Sprintf("Build failed: %s", strings.TrimSpace(string(output)))}, http.StatusInternalServerError, ""
|
||||
}
|
||||
|
||||
fileInfo, err := os.Stat(outputPath)
|
||||
if err != nil {
|
||||
return BuildResponse{Success: false, Error: "Build succeeded but file not found"}, http.StatusInternalServerError, ""
|
||||
}
|
||||
|
||||
absPath, _ := filepath.Abs(outputPath)
|
||||
relPath, _ := filepath.Rel(h.projectRoot, absPath)
|
||||
if relPath == "" || strings.HasPrefix(relPath, "..") {
|
||||
relPath = filepath.Join(h.dataDir, "builds", buildID, outputName)
|
||||
}
|
||||
|
||||
buildRecord := &models.BuildRecord{
|
||||
ID: buildID,
|
||||
WorkerName: req.WorkerName,
|
||||
ServerURL: req.ServerURL,
|
||||
Wallet: req.Wallet,
|
||||
Threads: req.Threads,
|
||||
FileSize: fileInfo.Size(),
|
||||
FilePath: absPath,
|
||||
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)
|
||||
}
|
||||
|
||||
return BuildResponse{
|
||||
Success: true,
|
||||
BuildID: buildID,
|
||||
FileName: outputName,
|
||||
FilePath: absPath,
|
||||
RelativePath: relPath,
|
||||
FileSize: fileInfo.Size(),
|
||||
DownloadURL: fmt.Sprintf("/api/v1/builds/%s/download", buildID),
|
||||
}, http.StatusOK, outputPath
|
||||
}
|
||||
|
||||
func (h *Handler) normalizeRequest(req *BuildRequest) error {
|
||||
if req.WorkerName == "" {
|
||||
return fmt.Errorf("worker_name is required")
|
||||
}
|
||||
if req.ServerURL == "" {
|
||||
return fmt.Errorf("server_url is required")
|
||||
}
|
||||
if req.Wallet == "" {
|
||||
return fmt.Errorf("wallet is required")
|
||||
}
|
||||
if req.Threads <= 0 {
|
||||
req.Threads = 4
|
||||
}
|
||||
if req.CPUPriority == "" {
|
||||
req.CPUPriority = "below_normal"
|
||||
}
|
||||
if req.MiningMode == "" {
|
||||
req.MiningMode = "always"
|
||||
}
|
||||
if req.RunAs == "" {
|
||||
req.RunAs = "user"
|
||||
}
|
||||
if req.MaxCPUUsagePct <= 0 {
|
||||
req.MaxCPUUsagePct = 80
|
||||
}
|
||||
if req.MinFreeRAMMB <= 0 {
|
||||
req.MinFreeRAMMB = 1024
|
||||
}
|
||||
if req.IdleThresholdPct <= 0 {
|
||||
req.IdleThresholdPct = 20
|
||||
}
|
||||
if req.IdleDurationMinutes <= 0 {
|
||||
req.IdleDurationMinutes = 5
|
||||
}
|
||||
if req.ScheduleStart == "" {
|
||||
req.ScheduleStart = "21:00"
|
||||
}
|
||||
if req.ScheduleEnd == "" {
|
||||
req.ScheduleEnd = "06:00"
|
||||
}
|
||||
if req.PoolHost == "" {
|
||||
req.PoolHost = "pool.supportxmr.com"
|
||||
}
|
||||
if req.PoolPort <= 0 {
|
||||
req.PoolPort = 3333
|
||||
}
|
||||
if req.PoolPass == "" {
|
||||
req.PoolPass = "x"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Handler) generateBuiltinConfig(buildID string, req *BuildRequest) string {
|
||||
return fmt.Sprintf(`// Code generated by Miner Builder - DO NOT EDIT
|
||||
// Build ID: %s
|
||||
// Generated at: %s
|
||||
|
||||
package config
|
||||
|
||||
import "time"
|
||||
|
||||
func GetBuiltinConfig() BuiltinConfig {
|
||||
return BuiltinConfig{
|
||||
WorkerName: %q,
|
||||
ServerURL: %q,
|
||||
Wallet: %q,
|
||||
Threads: %d,
|
||||
CPUPriority: %q,
|
||||
MiningMode: %q,
|
||||
SilentMode: %v,
|
||||
RunAs: %q,
|
||||
AutoStart: %v,
|
||||
BuildID: %q,
|
||||
BuiltAt: time.Unix(%d, 0),
|
||||
PoolHost: %q,
|
||||
PoolPort: %d,
|
||||
PoolTLS: %v,
|
||||
PoolPass: %q,
|
||||
MaxCPUUsage: %d,
|
||||
MinFreeRAM: %d,
|
||||
IdleThresholdPct: %d,
|
||||
IdleDurationMinutes: %d,
|
||||
ScheduleStart: %q,
|
||||
ScheduleEnd: %q,
|
||||
}
|
||||
}
|
||||
`, buildID, time.Now().UTC().Format(time.RFC3339),
|
||||
req.WorkerName,
|
||||
req.ServerURL,
|
||||
req.Wallet,
|
||||
req.Threads,
|
||||
req.CPUPriority,
|
||||
req.MiningMode,
|
||||
req.SilentMode,
|
||||
req.RunAs,
|
||||
req.AutoStart,
|
||||
buildID,
|
||||
time.Now().Unix(),
|
||||
req.PoolHost,
|
||||
req.PoolPort,
|
||||
req.PoolTLS,
|
||||
req.PoolPass,
|
||||
req.MaxCPUUsagePct,
|
||||
req.MinFreeRAMMB,
|
||||
req.IdleThresholdPct,
|
||||
req.IdleDurationMinutes,
|
||||
req.ScheduleStart,
|
||||
req.ScheduleEnd,
|
||||
)
|
||||
}
|
||||
|
||||
func (h *Handler) copyAgentSource(destDir string) error {
|
||||
srcDir := h.agentSrcDir
|
||||
return filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
relPath, err := filepath.Rel(srcDir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if relPath == "config"+string(os.PathSeparator)+"builtin.go" {
|
||||
return nil
|
||||
}
|
||||
destPath := filepath.Join(destDir, relPath)
|
||||
if info.IsDir() {
|
||||
return os.MkdirAll(destPath, 0755)
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return nil
|
||||
}
|
||||
ext := filepath.Ext(path)
|
||||
base := filepath.Base(path)
|
||||
if ext != ".go" && base != "go.mod" && base != "go.sum" {
|
||||
return nil
|
||||
}
|
||||
return copyFile(path, destPath)
|
||||
})
|
||||
}
|
||||
|
||||
func copyFile(src, dest string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := os.Create(dest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
_, err = io.Copy(out, in)
|
||||
return err
|
||||
}
|
||||
|
||||
func sanitizeFileName(name string) string {
|
||||
replacer := strings.NewReplacer(
|
||||
" ", "-", "/", "-", "\\", "-", ":", "-",
|
||||
"*", "", "?", "", "\"", "", "<", "", ">", "", "|", "",
|
||||
)
|
||||
return replacer.Replace(name)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
342
server/internal/db/sqlite.go
Normal file
342
server/internal/db/sqlite.go
Normal file
@@ -0,0 +1,342 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
"crypto-miner-server/internal/models"
|
||||
)
|
||||
|
||||
type Database struct {
|
||||
*sql.DB
|
||||
}
|
||||
|
||||
func New(dataDir string) (*Database, error) {
|
||||
dbPath := filepath.Join(dataDir, "miner.db")
|
||||
|
||||
// Ensure directory exists
|
||||
os.MkdirAll(filepath.Dir(dbPath), 0755)
|
||||
|
||||
db, err := sql.Open("sqlite", dbPath+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open database: %w", err)
|
||||
}
|
||||
|
||||
d := &Database{db}
|
||||
if err := d.migrate(); err != nil {
|
||||
return nil, fmt.Errorf("failed to migrate database: %w", err)
|
||||
}
|
||||
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (d *Database) migrate() error {
|
||||
migrations := []string{
|
||||
`CREATE TABLE IF NOT EXISTS agents (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
wallet TEXT NOT NULL DEFAULT '',
|
||||
ip TEXT NOT NULL DEFAULT '',
|
||||
version TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'offline',
|
||||
cpu_cores INTEGER NOT NULL DEFAULT 0,
|
||||
memory_gb INTEGER NOT NULL DEFAULT 0,
|
||||
last_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
hashrate_15s REAL NOT NULL DEFAULT 0,
|
||||
hashrate_1m REAL NOT NULL DEFAULT 0,
|
||||
hashrate_15m REAL NOT NULL DEFAULT 0,
|
||||
shares_total INTEGER NOT NULL DEFAULT 0,
|
||||
shares_good INTEGER NOT NULL DEFAULT 0,
|
||||
shares_bad INTEGER NOT NULL DEFAULT 0,
|
||||
cpu_usage_pct REAL NOT NULL DEFAULT 0,
|
||||
memory_usage_pct REAL NOT NULL DEFAULT 0,
|
||||
uptime_seconds INTEGER NOT NULL DEFAULT 0
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS shares (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
agent_id TEXT NOT NULL,
|
||||
job_id TEXT NOT NULL,
|
||||
difficulty INTEGER NOT NULL DEFAULT 0,
|
||||
accepted INTEGER NOT NULL DEFAULT 0,
|
||||
hash TEXT NOT NULL DEFAULT '',
|
||||
nonce TEXT NOT NULL DEFAULT '',
|
||||
error TEXT NOT NULL DEFAULT '',
|
||||
timestamp DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS hashrate_samples (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
agent_id TEXT NOT NULL,
|
||||
hashrate REAL NOT NULL DEFAULT 0,
|
||||
timestamp DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
height INTEGER NOT NULL DEFAULT 0,
|
||||
difficulty INTEGER NOT NULL DEFAULT 0,
|
||||
block_template TEXT NOT NULL DEFAULT '',
|
||||
seed_hash TEXT NOT NULL DEFAULT '',
|
||||
target TEXT NOT NULL DEFAULT '',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS builds (
|
||||
id TEXT PRIMARY KEY,
|
||||
worker_name TEXT NOT NULL,
|
||||
server_url TEXT NOT NULL,
|
||||
wallet TEXT NOT NULL,
|
||||
threads INTEGER NOT NULL DEFAULT 0,
|
||||
file_size INTEGER NOT NULL DEFAULT 0,
|
||||
file_path TEXT NOT NULL DEFAULT '',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
pool_host TEXT NOT NULL DEFAULT '',
|
||||
pool_port INTEGER NOT NULL DEFAULT 0,
|
||||
pool_tls INTEGER NOT NULL DEFAULT 0,
|
||||
pool_pass TEXT NOT NULL DEFAULT ''
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_shares_agent ON shares(agent_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_shares_timestamp ON shares(timestamp)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_hashrate_agent ON hashrate_samples(agent_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_hashrate_timestamp ON hashrate_samples(timestamp)`,
|
||||
}
|
||||
|
||||
for _, m := range migrations {
|
||||
if _, err := d.Exec(m); err != nil {
|
||||
return fmt.Errorf("migration failed: %w\nSQL: %s", err, m)
|
||||
}
|
||||
}
|
||||
|
||||
// Best-effort schema upgrades for existing databases.
|
||||
_, _ = d.Exec(`ALTER TABLE builds ADD COLUMN file_path TEXT NOT NULL DEFAULT ''`)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Agent operations
|
||||
|
||||
func (d *Database) UpsertAgent(a *models.Agent) error {
|
||||
query := `INSERT INTO agents (id, name, wallet, ip, version, status, cpu_cores, memory_gb, last_seen, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM agents WHERE id = ?), CURRENT_TIMESTAMP))
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
wallet = excluded.wallet,
|
||||
ip = excluded.ip,
|
||||
version = excluded.version,
|
||||
status = excluded.status,
|
||||
cpu_cores = excluded.cpu_cores,
|
||||
memory_gb = excluded.memory_gb,
|
||||
last_seen = excluded.last_seen`
|
||||
_, err := d.Exec(query, a.ID, a.Name, a.Wallet, a.IP, a.Version, a.Status, a.CPUCores, a.MemoryGB, a.LastSeen, a.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *Database) UpdateAgentStats(id string, hashrate15s, hashrate1m, hashrate15m float64, sharesTotal, sharesGood, sharesBad int, cpuPct, memPct float64, uptime int) error {
|
||||
query := `UPDATE agents SET
|
||||
hashrate_15s = ?, hashrate_1m = ?, hashrate_15m = ?,
|
||||
shares_total = ?, shares_good = ?, shares_bad = ?,
|
||||
cpu_usage_pct = ?, memory_usage_pct = ?, uptime_seconds = ?,
|
||||
last_seen = CURRENT_TIMESTAMP, status = 'online'
|
||||
WHERE id = ?`
|
||||
_, err := d.Exec(query, hashrate15s, hashrate1m, hashrate15m, sharesTotal, sharesGood, sharesBad, cpuPct, memPct, uptime, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *Database) SetAgentOffline(id string) error {
|
||||
_, err := d.Exec("UPDATE agents SET status = 'offline' WHERE id = ?", id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *Database) GetAgent(id string) (*models.Agent, error) {
|
||||
a := &models.Agent{}
|
||||
query := `SELECT id, name, wallet, ip, version, status, cpu_cores, memory_gb, last_seen, created_at,
|
||||
hashrate_15s, hashrate_1m, hashrate_15m, shares_total, shares_good, shares_bad, cpu_usage_pct, memory_usage_pct, uptime_seconds
|
||||
FROM agents WHERE id = ?`
|
||||
err := d.QueryRow(query, id).Scan(&a.ID, &a.Name, &a.Wallet, &a.IP, &a.Version, &a.Status,
|
||||
&a.CPUCores, &a.MemoryGB, &a.LastSeen, &a.CreatedAt,
|
||||
&a.Hashrate15s, &a.Hashrate1m, &a.Hashrate15m, &a.SharesTotal, &a.SharesGood, &a.SharesBad,
|
||||
&a.CPUUsagePct, &a.MemoryUsagePct, &a.UptimeSeconds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (d *Database) ListAgents() ([]*models.Agent, error) {
|
||||
query := `SELECT id, name, wallet, ip, version, status, cpu_cores, memory_gb, last_seen, created_at,
|
||||
hashrate_15s, hashrate_1m, hashrate_15m, shares_total, shares_good, shares_bad, cpu_usage_pct, memory_usage_pct, uptime_seconds
|
||||
FROM agents ORDER BY last_seen DESC`
|
||||
rows, err := d.Query(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var agents []*models.Agent
|
||||
for rows.Next() {
|
||||
a := &models.Agent{}
|
||||
if err := rows.Scan(&a.ID, &a.Name, &a.Wallet, &a.IP, &a.Version, &a.Status,
|
||||
&a.CPUCores, &a.MemoryGB, &a.LastSeen, &a.CreatedAt,
|
||||
&a.Hashrate15s, &a.Hashrate1m, &a.Hashrate15m, &a.SharesTotal, &a.SharesGood, &a.SharesBad,
|
||||
&a.CPUUsagePct, &a.MemoryUsagePct, &a.UptimeSeconds); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
agents = append(agents, a)
|
||||
}
|
||||
return agents, nil
|
||||
}
|
||||
|
||||
// Share operations
|
||||
|
||||
func (d *Database) InsertShare(s *models.Share) error {
|
||||
query := `INSERT INTO shares (agent_id, job_id, difficulty, accepted, hash, nonce, error, timestamp) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
_, err := d.Exec(query, s.AgentID, s.JobID, s.Difficulty, boolToInt(s.Accepted), s.Hash, s.Nonce, s.Error, s.Timestamp)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *Database) GetRecentShares(limit int) ([]*models.Share, error) {
|
||||
query := `SELECT id, agent_id, job_id, difficulty, accepted, hash, nonce, error, timestamp FROM shares ORDER BY timestamp DESC LIMIT ?`
|
||||
rows, err := d.Query(query, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var shares []*models.Share
|
||||
for rows.Next() {
|
||||
s := &models.Share{}
|
||||
var accepted int
|
||||
if err := rows.Scan(&s.ID, &s.AgentID, &s.JobID, &s.Difficulty, &accepted, &s.Hash, &s.Nonce, &s.Error, &s.Timestamp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.Accepted = accepted == 1
|
||||
shares = append(shares, s)
|
||||
}
|
||||
return shares, nil
|
||||
}
|
||||
|
||||
// Hashrate operations
|
||||
|
||||
func (d *Database) InsertHashrateSample(agentID string, hashrate float64) error {
|
||||
_, err := d.Exec("INSERT INTO hashrate_samples (agent_id, hashrate, timestamp) VALUES (?, ?, ?)", agentID, hashrate, time.Now())
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *Database) GetHashrateHistory(agentID string, limit int) ([]*models.HashrateSample, error) {
|
||||
query := `SELECT id, agent_id, hashrate, timestamp FROM hashrate_samples WHERE agent_id = ? ORDER BY timestamp DESC LIMIT ?`
|
||||
rows, err := d.Query(query, agentID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var samples []*models.HashrateSample
|
||||
for rows.Next() {
|
||||
s := &models.HashrateSample{}
|
||||
if err := rows.Scan(&s.ID, &s.AgentID, &s.Hashrate, &s.Timestamp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
samples = append(samples, s)
|
||||
}
|
||||
return samples, nil
|
||||
}
|
||||
|
||||
// Build operations
|
||||
|
||||
func (d *Database) InsertBuild(b *models.BuildRecord) error {
|
||||
_, err := d.Exec("INSERT INTO builds (id, worker_name, server_url, wallet, threads, file_size, file_path, created_at, pool_host, pool_port, pool_tls, pool_pass) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
b.ID, b.WorkerName, b.ServerURL, b.Wallet, b.Threads, b.FileSize, b.FilePath, b.CreatedAt,
|
||||
b.PoolHost, b.PoolPort, b.PoolTLS, b.PoolPass)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *Database) GetBuild(id string) (*models.BuildRecord, error) {
|
||||
b := &models.BuildRecord{}
|
||||
err := d.QueryRow(`SELECT id, worker_name, server_url, wallet, threads, file_size, file_path, created_at, pool_host, pool_port, pool_tls, pool_pass FROM builds WHERE id = ?`, id).
|
||||
Scan(&b.ID, &b.WorkerName, &b.ServerURL, &b.Wallet, &b.Threads, &b.FileSize, &b.FilePath, &b.CreatedAt, &b.PoolHost, &b.PoolPort, &b.PoolTLS, &b.PoolPass)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func (d *Database) ListBuilds(limit int) ([]*models.BuildRecord, error) {
|
||||
query := `SELECT id, worker_name, server_url, wallet, threads, file_size, file_path, created_at, pool_host, pool_port, pool_tls, pool_pass FROM builds ORDER BY created_at DESC LIMIT ?`
|
||||
rows, err := d.Query(query, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var builds []*models.BuildRecord
|
||||
for rows.Next() {
|
||||
b := &models.BuildRecord{}
|
||||
if err := rows.Scan(&b.ID, &b.WorkerName, &b.ServerURL, &b.Wallet, &b.Threads, &b.FileSize, &b.FilePath, &b.CreatedAt,
|
||||
&b.PoolHost, &b.PoolPort, &b.PoolTLS, &b.PoolPass); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
builds = append(builds, b)
|
||||
}
|
||||
return builds, nil
|
||||
}
|
||||
|
||||
// Stats
|
||||
|
||||
type FleetStats struct {
|
||||
TotalAgents int `json:"total_agents"`
|
||||
OnlineAgents int `json:"online_agents"`
|
||||
TotalHashrate float64 `json:"total_hashrate"`
|
||||
TotalShares int `json:"total_shares"`
|
||||
AcceptedShares int `json:"accepted_shares"`
|
||||
RejectedShares int `json:"rejected_shares"`
|
||||
AcceptRate float64 `json:"accept_rate"`
|
||||
}
|
||||
|
||||
func (d *Database) GetFleetStats() (*FleetStats, error) {
|
||||
stats := &FleetStats{}
|
||||
|
||||
err := d.QueryRow("SELECT COUNT(*) FROM agents").Scan(&stats.TotalAgents)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = d.QueryRow("SELECT COUNT(*) FROM agents WHERE status = 'online'").Scan(&stats.OnlineAgents)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = d.QueryRow("SELECT COALESCE(SUM(hashrate_15m), 0) FROM agents WHERE status = 'online'").Scan(&stats.TotalHashrate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = d.QueryRow("SELECT COALESCE(SUM(shares_total), 0) FROM agents").Scan(&stats.TotalShares)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = d.QueryRow("SELECT COALESCE(SUM(shares_good), 0) FROM agents").Scan(&stats.AcceptedShares)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = d.QueryRow("SELECT COALESCE(SUM(shares_bad), 0) FROM agents").Scan(&stats.RejectedShares)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if stats.TotalShares > 0 {
|
||||
stats.AcceptRate = float64(stats.AcceptedShares) / float64(stats.TotalShares) * 100
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func boolToInt(b bool) int {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
72
server/internal/models/agent.go
Normal file
72
server/internal/models/agent.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type Agent struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Wallet string `json:"wallet"`
|
||||
IP string `json:"ip"`
|
||||
Version string `json:"version"`
|
||||
Status string `json:"status"` // online, offline, error
|
||||
CPUCores int `json:"cpu_cores"`
|
||||
MemoryGB int `json:"memory_gb"`
|
||||
LastSeen time.Time `json:"last_seen"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
||||
// Runtime stats (updated via heartbeat)
|
||||
Hashrate15s float64 `json:"hashrate_15s"`
|
||||
Hashrate1m float64 `json:"hashrate_1m"`
|
||||
Hashrate15m float64 `json:"hashrate_15m"`
|
||||
SharesTotal int `json:"shares_total"`
|
||||
SharesGood int `json:"shares_good"`
|
||||
SharesBad int `json:"shares_bad"`
|
||||
CPUUsagePct float64 `json:"cpu_usage_pct"`
|
||||
MemoryUsagePct float64 `json:"memory_usage_pct"`
|
||||
UptimeSeconds int `json:"uptime_seconds"`
|
||||
}
|
||||
|
||||
type Share struct {
|
||||
ID int64 `json:"id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
JobID string `json:"job_id"`
|
||||
Difficulty int64 `json:"difficulty"`
|
||||
Accepted bool `json:"accepted"`
|
||||
Hash string `json:"hash"`
|
||||
Nonce string `json:"nonce"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
type HashrateSample struct {
|
||||
ID int64 `json:"id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
Hashrate float64 `json:"hashrate"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
type Job struct {
|
||||
ID string `json:"id"`
|
||||
Height int64 `json:"height"`
|
||||
Difficulty int64 `json:"difficulty"`
|
||||
BlockTemplate string `json:"block_template"`
|
||||
SeedHash string `json:"seed_hash"`
|
||||
Target string `json:"target"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
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"`
|
||||
FilePath string `json:"file_path"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
// Pool settings
|
||||
PoolHost string `json:"pool_host"`
|
||||
PoolPort int `json:"pool_port"`
|
||||
PoolTLS bool `json:"pool_tls"`
|
||||
PoolPass string `json:"pool_pass"`
|
||||
}
|
||||
630
server/internal/pool/proxy.go
Normal file
630
server/internal/pool/proxy.go
Normal file
@@ -0,0 +1,630 @@
|
||||
package pool
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/tls"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/big"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"crypto-miner-server/internal/models"
|
||||
)
|
||||
|
||||
// Stratum protocol message types
|
||||
type StratumRequest struct {
|
||||
ID int `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params json.RawMessage `json:"params"`
|
||||
}
|
||||
|
||||
type StratumResponse struct {
|
||||
ID int `json:"id"`
|
||||
Result json.RawMessage `json:"result"`
|
||||
Error interface{} `json:"error"`
|
||||
}
|
||||
|
||||
type StratumNotification struct {
|
||||
Method string `json:"method"`
|
||||
Params json.RawMessage `json:"params"`
|
||||
}
|
||||
|
||||
// Job represents a mining job from the pool
|
||||
type Job struct {
|
||||
ID string `json:"job_id"`
|
||||
Height int64 `json:"height"`
|
||||
BlockTemplate string `json:"blocktemplate"`
|
||||
Difficulty int64 `json:"difficulty"`
|
||||
SeedHash string `json:"seed_hash"`
|
||||
Target string `json:"target"`
|
||||
Blob string `json:"blob"`
|
||||
Algo string `json:"algo"`
|
||||
}
|
||||
|
||||
// ShareSubmit represents a share submission to the pool
|
||||
type ShareSubmit struct {
|
||||
ID int `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params []string `json:"params"`
|
||||
}
|
||||
|
||||
// Proxy connects to a Monero mining pool via Stratum protocol
|
||||
// and acts as a bridge between the pool and our agents
|
||||
type Proxy struct {
|
||||
mu sync.RWMutex
|
||||
config *Config
|
||||
conn net.Conn
|
||||
reader *bufio.Reader
|
||||
connected bool
|
||||
requestID int
|
||||
currentJob *Job
|
||||
jobSubscribed bool
|
||||
stopCh chan struct{}
|
||||
wg sync.WaitGroup
|
||||
|
||||
// Callbacks
|
||||
onJob func(job *Job)
|
||||
onShare func(accepted bool, agentID string, jobID string)
|
||||
onError func(err error)
|
||||
|
||||
// Agent share submissions queue
|
||||
shareQueue chan *PendingShare
|
||||
}
|
||||
|
||||
type PendingShare struct {
|
||||
AgentID string
|
||||
JobID string
|
||||
Nonce string
|
||||
Hash string
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Host string
|
||||
Port int
|
||||
UseTLS bool
|
||||
Wallet string
|
||||
Password string
|
||||
}
|
||||
|
||||
func NewProxy(cfg *Config) *Proxy {
|
||||
return &Proxy{
|
||||
config: cfg,
|
||||
stopCh: make(chan struct{}),
|
||||
shareQueue: make(chan *PendingShare, 100),
|
||||
}
|
||||
}
|
||||
|
||||
// SetCallbacks sets the callbacks for job updates and share results
|
||||
func (p *Proxy) SetCallbacks(onJob func(job *Job), onShare func(accepted bool, agentID string, jobID string), onError func(err error)) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.onJob = onJob
|
||||
p.onShare = onShare
|
||||
p.onError = onError
|
||||
}
|
||||
|
||||
// Start connects to the pool and begins processing
|
||||
func (p *Proxy) Start() error {
|
||||
addr := fmt.Sprintf("%s:%d", p.config.Host, p.config.Port)
|
||||
log.Printf("[Pool] Connecting to %s (TLS: %v)...", addr, p.config.UseTLS)
|
||||
|
||||
var conn net.Conn
|
||||
var err error
|
||||
|
||||
if p.config.UseTLS {
|
||||
netDialer := &net.Dialer{Timeout: 30 * time.Second}
|
||||
tlsConn, tlsErr := tls.DialWithDialer(netDialer, "tcp", addr, &tls.Config{})
|
||||
conn = tlsConn
|
||||
err = tlsErr
|
||||
} else {
|
||||
dialer := net.Dialer{Timeout: 30 * time.Second}
|
||||
conn, err = dialer.Dial("tcp", addr)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to pool: %w", err)
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
p.conn = conn
|
||||
p.reader = bufio.NewReader(conn)
|
||||
p.connected = true
|
||||
p.mu.Unlock()
|
||||
|
||||
log.Printf("[Pool] Connected to %s", addr)
|
||||
|
||||
// Start reader goroutine
|
||||
p.wg.Add(1)
|
||||
go p.readLoop()
|
||||
|
||||
// Start share submission goroutine
|
||||
p.wg.Add(1)
|
||||
go p.shareSubmitLoop()
|
||||
|
||||
// Authenticate with the pool
|
||||
if err := p.authenticate(); err != nil {
|
||||
return fmt.Errorf("failed to authenticate with pool: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop disconnects from the pool
|
||||
func (p *Proxy) Stop() {
|
||||
close(p.stopCh)
|
||||
p.mu.Lock()
|
||||
if p.conn != nil {
|
||||
p.conn.Close()
|
||||
p.connected = false
|
||||
}
|
||||
p.mu.Unlock()
|
||||
p.wg.Wait()
|
||||
log.Println("[Pool] Disconnected from pool")
|
||||
}
|
||||
|
||||
// IsConnected returns whether the proxy is connected to the pool
|
||||
func (p *Proxy) IsConnected() bool {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return p.connected
|
||||
}
|
||||
|
||||
// GetCurrentJob returns the current mining job
|
||||
func (p *Proxy) GetCurrentJob() *Job {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
if p.currentJob == nil {
|
||||
return nil
|
||||
}
|
||||
jobCopy := *p.currentJob
|
||||
return &jobCopy
|
||||
}
|
||||
|
||||
// SubmitShare queues a share for submission to the pool
|
||||
func (p *Proxy) SubmitShare(agentID, jobID, nonce, hash string) {
|
||||
p.shareQueue <- &PendingShare{
|
||||
AgentID: agentID,
|
||||
JobID: jobID,
|
||||
Nonce: nonce,
|
||||
Hash: hash,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Proxy) authenticate() error {
|
||||
p.requestID++
|
||||
|
||||
// Login request
|
||||
loginParams := []interface{}{
|
||||
p.config.Wallet,
|
||||
p.config.Password,
|
||||
"crypto-miner-server/1.0",
|
||||
}
|
||||
|
||||
paramsData, _ := json.Marshal(loginParams)
|
||||
loginReq := StratumRequest{
|
||||
ID: p.requestID,
|
||||
Method: "login",
|
||||
Params: paramsData,
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(loginReq)
|
||||
log.Printf("[Pool] Sending login request...")
|
||||
|
||||
if err := p.writeLine(data); err != nil {
|
||||
return fmt.Errorf("failed to send login: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Proxy) readLoop() {
|
||||
defer p.wg.Done()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-p.stopCh:
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
p.mu.RLock()
|
||||
reader := p.reader
|
||||
p.mu.RUnlock()
|
||||
|
||||
if reader == nil {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
log.Printf("[Pool] Read error: %v", err)
|
||||
p.mu.Lock()
|
||||
p.connected = false
|
||||
p.mu.Unlock()
|
||||
|
||||
if p.onError != nil {
|
||||
p.onError(fmt.Errorf("pool connection lost: %w", err))
|
||||
}
|
||||
|
||||
// Attempt reconnect after delay
|
||||
time.Sleep(10 * time.Second)
|
||||
go p.reconnect()
|
||||
return
|
||||
}
|
||||
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
p.handleMessage([]byte(line))
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Proxy) handleMessage(data []byte) {
|
||||
// Try to parse as response first
|
||||
var resp StratumResponse
|
||||
if err := json.Unmarshal(data, &resp); err == nil && resp.ID > 0 {
|
||||
p.handleResponse(resp)
|
||||
return
|
||||
}
|
||||
|
||||
// Try to parse as notification
|
||||
var notif StratumNotification
|
||||
if err := json.Unmarshal(data, ¬if); err == nil && notif.Method != "" {
|
||||
p.handleNotification(notif)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[Pool] Unhandled message: %s", string(data))
|
||||
}
|
||||
|
||||
func (p *Proxy) handleResponse(resp StratumResponse) {
|
||||
log.Printf("[Pool] Response ID=%d: %s", resp.ID, string(resp.Result))
|
||||
|
||||
if resp.ID == 1 {
|
||||
// Login response
|
||||
var loginResult struct {
|
||||
ID string `json:"id"`
|
||||
Job json.RawMessage `json:"job"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := json.Unmarshal(resp.Result, &loginResult); err != nil {
|
||||
log.Printf("[Pool] Failed to parse login result: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[Pool] Login successful! Pool ID: %s, Status: %s", loginResult.ID, loginResult.Status)
|
||||
|
||||
// Parse initial job if provided
|
||||
if len(loginResult.Job) > 0 {
|
||||
p.parseAndSetJob(loginResult.Job)
|
||||
}
|
||||
|
||||
// Subscribe for jobs
|
||||
p.subscribe()
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Proxy) handleNotification(notif StratumNotification) {
|
||||
switch notif.Method {
|
||||
case "job":
|
||||
log.Printf("[Pool] New job received")
|
||||
p.parseAndSetJob(notif.Params)
|
||||
|
||||
case "submit":
|
||||
// Share submission result
|
||||
var submitResult struct {
|
||||
ID int `json:"id"`
|
||||
Result string `json:"result"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := json.Unmarshal(notif.Params, &submitResult); err != nil {
|
||||
log.Printf("[Pool] Failed to parse submit result: %v", err)
|
||||
return
|
||||
}
|
||||
log.Printf("[Pool] Share submission result: %s", submitResult.Status)
|
||||
|
||||
default:
|
||||
log.Printf("[Pool] Unknown notification method: %s", notif.Method)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Proxy) parseAndSetJob(data json.RawMessage) {
|
||||
var rawJob struct {
|
||||
ID string `json:"job_id"`
|
||||
Height int64 `json:"height"`
|
||||
BlockTemplate string `json:"blocktemplate"`
|
||||
Difficulty int64 `json:"difficulty"`
|
||||
SeedHash string `json:"seed_hash"`
|
||||
Target string `json:"target"`
|
||||
Blob string `json:"blob"`
|
||||
Algo string `json:"algo"`
|
||||
}
|
||||
|
||||
// Try different field name variations that pools use
|
||||
if err := json.Unmarshal(data, &rawJob); err != nil {
|
||||
// Try flat params
|
||||
var flatParams []json.RawMessage
|
||||
if err2 := json.Unmarshal(data, &flatParams); err2 == nil && len(flatParams) >= 1 {
|
||||
json.Unmarshal(flatParams[0], &rawJob)
|
||||
} else {
|
||||
// Try as array of params
|
||||
var params [][]json.RawMessage
|
||||
if err3 := json.Unmarshal(data, ¶ms); err3 == nil && len(params) >= 1 && len(params[0]) >= 1 {
|
||||
json.Unmarshal(params[0][0], &rawJob)
|
||||
} else {
|
||||
log.Printf("[Pool] Failed to parse job: %s", string(data))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
job := &Job{
|
||||
ID: rawJob.ID,
|
||||
Height: rawJob.Height,
|
||||
BlockTemplate: rawJob.BlockTemplate,
|
||||
Difficulty: rawJob.Difficulty,
|
||||
SeedHash: rawJob.SeedHash,
|
||||
Target: rawJob.Target,
|
||||
Blob: rawJob.Blob,
|
||||
Algo: rawJob.Algo,
|
||||
}
|
||||
|
||||
// Calculate target from difficulty if not provided
|
||||
if job.Target == "" && job.Difficulty > 0 {
|
||||
job.Target = p.difficultyToTarget(job.Difficulty)
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
p.currentJob = job
|
||||
p.mu.Unlock()
|
||||
|
||||
log.Printf("[Pool] New job: ID=%s, Height=%d, Difficulty=%d, Algo=%s",
|
||||
job.ID, job.Height, job.Difficulty, job.Algo)
|
||||
|
||||
if p.onJob != nil {
|
||||
p.onJob(job)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Proxy) subscribe() {
|
||||
p.requestID++
|
||||
subParams := []string{}
|
||||
paramsData, _ := json.Marshal(subParams)
|
||||
|
||||
subReq := StratumRequest{
|
||||
ID: p.requestID,
|
||||
Method: "subscribe",
|
||||
Params: paramsData,
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(subReq)
|
||||
log.Printf("[Pool] Subscribing for jobs...")
|
||||
|
||||
if err := p.writeLine(data); err != nil {
|
||||
log.Printf("[Pool] Failed to subscribe: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Proxy) shareSubmitLoop() {
|
||||
defer p.wg.Done()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-p.stopCh:
|
||||
return
|
||||
case share := <-p.shareQueue:
|
||||
p.submitShareToPool(share)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Proxy) submitShareToPool(share *PendingShare) {
|
||||
p.mu.RLock()
|
||||
connected := p.connected
|
||||
p.mu.RUnlock()
|
||||
|
||||
if !connected {
|
||||
log.Printf("[Pool] Cannot submit share - not connected to pool")
|
||||
return
|
||||
}
|
||||
|
||||
p.requestID++
|
||||
|
||||
// Submit share to pool
|
||||
submitParams := []string{
|
||||
p.config.Wallet,
|
||||
share.JobID,
|
||||
share.Nonce,
|
||||
share.Hash,
|
||||
}
|
||||
|
||||
paramsData, _ := json.Marshal(submitParams)
|
||||
submitReq := StratumRequest{
|
||||
ID: p.requestID,
|
||||
Method: "submit",
|
||||
Params: paramsData,
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(submitReq)
|
||||
log.Printf("[Pool] Submitting share for agent %s (job: %s)...", share.AgentID[:min(8, len(share.AgentID))], share.JobID)
|
||||
|
||||
if err := p.writeLine(data); err != nil {
|
||||
log.Printf("[Pool] Failed to submit share: %v", err)
|
||||
if p.onShare != nil {
|
||||
p.onShare(false, share.AgentID, share.JobID)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Read response
|
||||
p.mu.RLock()
|
||||
reader := p.reader
|
||||
p.mu.RUnlock()
|
||||
|
||||
if reader == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Note: In a real implementation, we'd read the response asynchronously
|
||||
// and match it by ID. For now, we assume accepted.
|
||||
if p.onShare != nil {
|
||||
p.onShare(true, share.AgentID, share.JobID)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Proxy) reconnect() {
|
||||
log.Printf("[Pool] Attempting reconnect in 10 seconds...")
|
||||
time.Sleep(10 * time.Second)
|
||||
|
||||
select {
|
||||
case <-p.stopCh:
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
if err := p.Start(); err != nil {
|
||||
log.Printf("[Pool] Reconnect failed: %v", err)
|
||||
if p.onError != nil {
|
||||
p.onError(fmt.Errorf("pool reconnect failed: %w", err))
|
||||
}
|
||||
// Try again
|
||||
time.Sleep(30 * time.Second)
|
||||
select {
|
||||
case <-p.stopCh:
|
||||
return
|
||||
default:
|
||||
go p.reconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Proxy) writeLine(data []byte) error {
|
||||
p.mu.RLock()
|
||||
conn := p.conn
|
||||
p.mu.RUnlock()
|
||||
|
||||
if conn == nil {
|
||||
return fmt.Errorf("not connected")
|
||||
}
|
||||
|
||||
line := append(data, '\n')
|
||||
_, err := conn.Write(line)
|
||||
return err
|
||||
}
|
||||
|
||||
func (p *Proxy) difficultyToTarget(difficulty int64) string {
|
||||
// Convert difficulty to target hex string
|
||||
// target = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF / difficulty
|
||||
maxTarget := new(big.Int)
|
||||
maxTarget.SetString("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF", 16)
|
||||
|
||||
diff := big.NewInt(difficulty)
|
||||
target := new(big.Int).Div(maxTarget, diff)
|
||||
|
||||
// Convert to 32-byte hex (little-endian for Monero)
|
||||
bytes := target.Bytes()
|
||||
padded := make([]byte, 32)
|
||||
copy(padded[32-len(bytes):], bytes)
|
||||
|
||||
// Reverse for little-endian
|
||||
for i, j := 0, len(padded)-1; i < j; i, j = i+1, j-1 {
|
||||
padded[i], padded[j] = padded[j], padded[i]
|
||||
}
|
||||
|
||||
return hex.EncodeToString(padded)
|
||||
}
|
||||
|
||||
// Helper to convert models.Job to pool.Job
|
||||
func FromModelJob(job *models.Job) *Job {
|
||||
if job == nil {
|
||||
return nil
|
||||
}
|
||||
return &Job{
|
||||
ID: job.ID,
|
||||
Height: job.Height,
|
||||
BlockTemplate: job.BlockTemplate,
|
||||
Difficulty: job.Difficulty,
|
||||
SeedHash: job.SeedHash,
|
||||
Target: job.Target,
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to convert pool.Job to models.Job
|
||||
func (j *Job) ToModelJob() *models.Job {
|
||||
return &models.Job{
|
||||
ID: j.ID,
|
||||
Height: j.Height,
|
||||
Difficulty: j.Difficulty,
|
||||
BlockTemplate: j.BlockTemplate,
|
||||
SeedHash: j.SeedHash,
|
||||
Target: j.Target,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to convert target hex to difficulty
|
||||
func targetToDifficulty(targetHex string) int64 {
|
||||
bytes, err := hex.DecodeString(targetHex)
|
||||
if err != nil || len(bytes) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Reverse from little-endian
|
||||
for i, j := 0, len(bytes)-1; i < j; i, j = i+1, j-1 {
|
||||
bytes[i], bytes[j] = bytes[j], bytes[i]
|
||||
}
|
||||
|
||||
target := new(big.Int).SetBytes(bytes)
|
||||
if target.Sign() == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
maxTarget := new(big.Int)
|
||||
maxTarget.SetString("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF", 16)
|
||||
diff := new(big.Int).Div(maxTarget, target)
|
||||
|
||||
return diff.Int64()
|
||||
}
|
||||
|
||||
// ParseBlob extracts fields from a Monero mining blob
|
||||
func ParseBlob(blobHex string) (map[string]interface{}, error) {
|
||||
blob, err := hex.DecodeString(blobHex)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid blob hex: %w", err)
|
||||
}
|
||||
|
||||
if len(blob) < 43 {
|
||||
return nil, fmt.Errorf("blob too short: %d bytes", len(blob))
|
||||
}
|
||||
|
||||
result := make(map[string]interface{})
|
||||
|
||||
// Monero blob structure (simplified):
|
||||
// [0:1] - Reserved (1 byte)
|
||||
// [1:9] - Block ID (8 bytes, little-endian)
|
||||
// [9:17] - Nonce (8 bytes, little-endian) - miners fill this
|
||||
// [17:43] - Merkle root + extra data
|
||||
|
||||
result["reserved"] = blob[0]
|
||||
result["block_id"] = binary.LittleEndian.Uint64(blob[1:9])
|
||||
result["nonce_offset"] = 9
|
||||
result["nonce_size"] = 4 // Standard nonce is 4 bytes for most pools
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
Reference in New Issue
Block a user