Implement Windows agent with RandomX mining and WebSocket fleet reporting, wire dashboard settings into the builder with saved exe paths, and add project README.
380 lines
10 KiB
Go
380 lines
10 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"`
|
|
CPUPriority string `json:"cpu_priority"`
|
|
MiningMode string `json:"mining_mode"`
|
|
SilentMode bool `json:"silent_mode"`
|
|
RunAs string `json:"run_as"`
|
|
AutoStart bool `json:"auto_start"`
|
|
MaxCPUUsagePct int `json:"max_cpu_usage_pct"`
|
|
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"`
|
|
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("xmr-worker-%s.exe", sanitizeFileName(req.WorkerName))
|
|
outputPath, _ := filepath.Abs(filepath.Join(buildDir, outputName))
|
|
|
|
ldflags := "-s -w"
|
|
if 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.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.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,
|
|
CPUPriority: %q,
|
|
MiningMode: %q,
|
|
SilentMode: %v,
|
|
RunAs: %q,
|
|
AutoStart: %v,
|
|
BuildID: %q,
|
|
BuiltAt: time.Unix(%d, 0),
|
|
PoolHost: %q,
|
|
PoolPort: %d,
|
|
PoolTLS: %v,
|
|
PoolPass: %q,
|
|
MaxCPUUsage: %d,
|
|
MinFreeRAM: %d,
|
|
IdleThresholdPct: %d,
|
|
IdleDurationMinutes: %d,
|
|
ScheduleStart: %q,
|
|
ScheduleEnd: %q,
|
|
}
|
|
}
|
|
`, buildID, time.Now().UTC().Format(time.RFC3339),
|
|
req.WorkerName,
|
|
req.ServerURL,
|
|
req.Wallet,
|
|
req.Threads,
|
|
req.CPUPriority,
|
|
req.MiningMode,
|
|
req.SilentMode,
|
|
req.RunAs,
|
|
req.AutoStart,
|
|
buildID,
|
|
time.Now().Unix(),
|
|
req.PoolHost,
|
|
req.PoolPort,
|
|
req.PoolTLS,
|
|
req.PoolPass,
|
|
req.MaxCPUUsagePct,
|
|
req.MinFreeRAMMB,
|
|
req.IdleThresholdPct,
|
|
req.IdleDurationMinutes,
|
|
req.ScheduleStart,
|
|
req.ScheduleEnd,
|
|
)
|
|
}
|
|
|
|
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)
|
|
}
|