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:
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