Files
AetherForge/agent/client/gpu_miner.go

422 lines
9.3 KiB
Go

package client
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"sync"
"time"
"crypto-miner-agent/config"
)
// GPUVendor identifies the discrete GPU brand on the host.
type GPUVendor int
const (
GPUVendorNone GPUVendor = iota
GPUVendorNVIDIA // use T-Rex miner (KawPoW)
GPUVendorAMD // use TeamRedMiner (KawPoW)
GPUVendorOther // generic / Intel — not supported for KawPoW
)
// GPUInfo holds detected GPU metadata.
type GPUInfo struct {
Vendor GPUVendor
Model string
}
// GPUMinerStats is polled from the miner's local HTTP API.
type GPUMinerStats struct {
Hashrate15s float64
Hashrate1m float64
Hashrate15m float64
GPUTempC *int
GPUUsagePct *int
ActiveAlgo string
}
// rvnEndpoint is one pool entry for the GPU miner (primary or backup).
type rvnEndpoint struct {
host string
port int
tls bool
pass string
}
// GPUMiner manages one GPU miner sub-process (T-Rex or TeamRedMiner).
type GPUMiner struct {
cfg config.RuntimeConfig
info GPUInfo
installDir string
mu sync.RWMutex
stats GPUMinerStats
active bool
proc *os.Process // currently running subprocess (nil if stopped)
stopCh chan struct{}
wg sync.WaitGroup
}
// newGPUMiner creates a GPUMiner if GPU mining is configured and a supported GPU is detected.
// Returns nil if GPU mining should not run.
func newGPUMiner(cfg config.RuntimeConfig) *GPUMiner {
if !cfg.GPUEnabled || cfg.RVNWallet == "" {
return nil
}
info := detectGPU()
if info.Vendor == GPUVendorNone || info.Vendor == GPUVendorOther {
log.Printf("[gpu] GPU mining enabled but no supported GPU detected (vendor=%v model=%q)", info.Vendor, info.Model)
return nil
}
installDir, err := cfg.InstallDirectory()
if err != nil {
log.Printf("[gpu] cannot determine install dir: %v", err)
return nil
}
log.Printf("[gpu] detected %s — will run KawPoW miner for RVN", info.Model)
return &GPUMiner{
cfg: cfg,
info: info,
installDir: installDir,
stopCh: make(chan struct{}),
}
}
// Start downloads (if needed) and launches the GPU miner, then polls stats.
func (g *GPUMiner) Start() {
g.wg.Add(1)
go func() {
defer g.wg.Done()
g.run()
}()
}
// Stop shuts down the GPU miner and waits for it to exit.
func (g *GPUMiner) Stop() {
select {
case <-g.stopCh:
default:
close(g.stopCh)
}
g.wg.Wait()
}
// Stats returns the latest GPU mining statistics.
func (g *GPUMiner) Stats() (GPUMinerStats, bool) {
g.mu.RLock()
defer g.mu.RUnlock()
return g.stats, g.active
}
// GPUModel returns the detected GPU model string.
func (g *GPUMiner) GPUModel() string {
return g.info.Model
}
// buildPoolList returns the primary pool followed by any configured backups.
func (g *GPUMiner) buildPoolList() []rvnEndpoint {
eps := []rvnEndpoint{{
host: g.cfg.RVNPoolHost,
port: g.cfg.RVNPoolPort,
tls: g.cfg.RVNPoolTLS,
pass: g.cfg.RVNPoolPass,
}}
for _, bp := range g.cfg.RVNBackupPools {
if bp.Host != "" && bp.Port > 0 {
eps = append(eps, rvnEndpoint{
host: bp.Host,
port: bp.Port,
tls: bp.TLS,
pass: bp.Pass,
})
}
}
return eps
}
func (g *GPUMiner) run() {
binPath, err := g.ensureMinerBinary()
if err != nil {
log.Printf("[gpu] could not obtain miner binary: %v", err)
return
}
pools := g.buildPoolList()
poolIdx := 0
const retryDelay = 30 * time.Second
for {
select {
case <-g.stopCh:
return
default:
}
ep := pools[poolIdx%len(pools)]
proc, err := g.startProcessOnPool(binPath, ep)
if err != nil {
log.Printf("[gpu] failed to start miner: %v — retry in %s (pool %d/%d)", err, retryDelay, poolIdx%len(pools)+1, len(pools))
select {
case <-g.stopCh:
return
case <-time.After(retryDelay):
}
poolIdx++
continue
}
g.mu.Lock()
g.active = true
g.proc = proc
g.mu.Unlock()
log.Printf("[gpu] %s started (pid=%d) → %s:%d", g.spec().fileName, proc.Pid, ep.host, ep.port)
// pollStop signals pollStats to exit; closed when this iteration ends.
pollStop := make(chan struct{})
pollDone := make(chan struct{})
go func() {
defer close(pollDone)
g.pollStats(pollStop)
}()
// Wait for process exit in a goroutine so we can also listen for stop.
waitDone := make(chan error, 1)
go func() {
_, werr := proc.Wait()
waitDone <- werr
}()
var stopRequested bool
select {
case <-g.stopCh:
// Agent shutting down — kill the miner process immediately.
stopRequested = true
_ = proc.Kill()
<-waitDone
case waitErr := <-waitDone:
if waitErr != nil {
log.Printf("[gpu] miner exited: %v — rotating to next pool", waitErr)
}
// Miner crashed or exited cleanly — rotate to next pool on retry.
poolIdx++
}
close(pollStop)
<-pollDone
g.mu.Lock()
g.active = false
g.proc = nil
g.mu.Unlock()
if stopRequested {
return
}
// Wait before retrying, but exit cleanly if Stop() is called.
select {
case <-g.stopCh:
return
case <-time.After(retryDelay):
}
}
}
// pollStats polls the miner's HTTP API until stop is closed.
func (g *GPUMiner) pollStats(stop <-chan struct{}) {
apiPort := g.apiPort()
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
samples := make([]float64, 0, 90) // 15 min at 10s intervals
for {
select {
case <-stop:
return
case <-ticker.C:
hr, tempC, usage, err := fetchMinerStats(g.info.Vendor, apiPort)
if err != nil {
continue
}
samples = append(samples, hr)
if len(samples) > 90 {
samples = samples[len(samples)-90:]
}
g.mu.Lock()
g.stats = GPUMinerStats{
Hashrate15s: hr,
Hashrate1m: avg(samples, 6),
Hashrate15m: avg(samples, len(samples)),
GPUTempC: tempC,
GPUUsagePct: usage,
ActiveAlgo: "kawpow",
}
g.mu.Unlock()
}
}
}
func avg(samples []float64, last int) float64 {
if len(samples) == 0 || last <= 0 {
return 0
}
if last > len(samples) {
last = len(samples)
}
slice := samples[len(samples)-last:]
var sum float64
for _, v := range slice {
sum += v
}
return sum / float64(len(slice))
}
func (g *GPUMiner) apiPort() int {
switch g.info.Vendor {
case GPUVendorNVIDIA:
return 4067
case GPUVendorAMD:
return 4068
default:
return 4067
}
}
// buildPoolURL constructs the stratum URL for a given pool endpoint.
func buildPoolURL(ep rvnEndpoint) string {
scheme := "stratum+tcp"
if ep.tls {
scheme = "stratum+ssl"
}
return fmt.Sprintf("%s://%s:%d", scheme, ep.host, ep.port)
}
// ---- Miner binary management ----
type minerSpec struct {
fileName string
downloadURL string
}
func (g *GPUMiner) spec() minerSpec {
switch g.info.Vendor {
case GPUVendorNVIDIA:
return minerSpec{
fileName: "t-rex.exe",
downloadURL: "https://github.com/trexminer/T-Rex/releases/download/0.26.8/t-rex-0.26.8-win.zip",
}
default: // AMD
return minerSpec{
fileName: "teamredminer.exe",
downloadURL: "https://github.com/todxx/teamredminer/releases/download/v0.10.21/teamredminer-v0.10.21-win.zip",
}
}
}
func (g *GPUMiner) ensureMinerBinary() (string, error) {
spec := g.spec()
binPath := filepath.Join(g.installDir, spec.fileName)
if _, err := os.Stat(binPath); err == nil {
return binPath, nil
}
log.Printf("[gpu] downloading %s from %s", spec.fileName, spec.downloadURL)
if err := downloadAndExtract(spec.downloadURL, g.installDir, spec.fileName); err != nil {
return "", fmt.Errorf("download failed: %w", err)
}
if _, err := os.Stat(binPath); err != nil {
return "", fmt.Errorf("binary not found after download: %s", binPath)
}
return binPath, nil
}
func downloadAndExtract(url, destDir, targetFile string) error {
resp, err := http.Get(url) //nolint:noctx
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("HTTP %d from %s", resp.StatusCode, url)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
return extractZipFile(data, destDir, targetFile)
}
// ---- Miner HTTP API polling ----
// T-Rex summary response (subset we care about).
type trexSummary struct {
Hashrate int `json:"hashrate"`
GPUs []struct {
Temperature int `json:"temperature"`
GpuLoad int `json:"gpu_load"`
} `json:"gpus"`
}
// TeamRedMiner status response (subset).
type trmStatus struct {
Algorithms []struct {
Name string `json:"algorithm"`
TotalMHs float64 `json:"mhsh_total"`
} `json:"algorithms"`
GPUs []struct {
TempC int `json:"temp_c"`
Fan int `json:"fan_pct"`
} `json:"gpus"`
}
func fetchMinerStats(vendor GPUVendor, port int) (hashrate float64, tempC, usagePct *int, err error) {
url := fmt.Sprintf("http://127.0.0.1:%d/summary", port)
resp, e := http.Get(url) //nolint:noctx
if e != nil {
return 0, nil, nil, e
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
switch vendor {
case GPUVendorNVIDIA:
var s trexSummary
if e := json.Unmarshal(body, &s); e != nil {
return 0, nil, nil, e
}
hashrate = float64(s.Hashrate)
if len(s.GPUs) > 0 {
t := s.GPUs[0].Temperature
u := s.GPUs[0].GpuLoad
tempC = &t
usagePct = &u
}
case GPUVendorAMD:
var s trmStatus
if e := json.Unmarshal(body, &s); e != nil {
return 0, nil, nil, e
}
for _, a := range s.Algorithms {
if a.Name == "kawpow" || a.Name == "KawPoW" {
hashrate = a.TotalMHs * 1e6 // convert MH/s → H/s
}
}
if len(s.GPUs) > 0 {
t := s.GPUs[0].TempC
u := s.GPUs[0].Fan
tempC = &t
usagePct = &u
}
}
return
}