This commit is contained in:
KingKDot
2026-04-29 00:22:37 -05:00
parent 4808a0fbd3
commit 6a363a62f2
16 changed files with 1182 additions and 339 deletions
+10 -4
View File
@@ -46,10 +46,16 @@ function resolveClientBuildCacheRoot(): string {
}
function resolveAndroidNdkToolchainBin(): string | null {
const ndkHome = (process.env.ANDROID_NDK_HOME || "/opt/android-ndk").trim();
const explicit = process.env.ANDROID_NDK_HOME?.trim();
const ndkHome = explicit || (process.platform === "win32" ? "" : "/opt/android-ndk");
if (!ndkHome) return null;
const hostArch = process.arch === "arm64" ? "linux-aarch64" : "linux-x86_64";
const toolchainBin = path.join(ndkHome, "toolchains", "llvm", "prebuilt", hostArch, "bin");
return fs.existsSync(toolchainBin) ? toolchainBin : null;
try {
return fs.existsSync(toolchainBin) ? toolchainBin : null;
} catch {
return null;
}
}
type BoundFile = {
@@ -291,8 +297,8 @@ export async function startBuildProcess(
});
}
const ndkBin = resolveAndroidNdkToolchainBin();
if (!ndkBin && platformsToBuild.some((p) => p.startsWith("android-"))) {
const ndkBin = hasAndroidTargets ? resolveAndroidNdkToolchainBin() : null;
if (hasAndroidTargets && !ndkBin) {
sendToStream({
type: "output",
text: "Warning: Android NDK not found. Android builds require the NDK. Install it to /opt/android-ndk or set the ANDROID_NDK_HOME environment variable.\n",
@@ -77,6 +77,21 @@ export async function ensurePluginExtracted(
const zip = new AdmZip(zipPath);
const entries = zip.getEntries();
const MAX_PLUGIN_UNCOMPRESSED_BYTES = 200 * 1024 * 1024;
let totalUncompressed = 0;
for (const entry of entries as any[]) {
if (entry?.isDirectory) continue;
const sz = Number(entry?.header?.size ?? 0);
if (!Number.isFinite(sz) || sz < 0) {
throw new Error(`Invalid plugin bundle: ${safeId} (malformed zip)`);
}
totalUncompressed += sz;
if (totalUncompressed > MAX_PLUGIN_UNCOMPRESSED_BYTES) {
throw new Error(`Plugin bundle too large: ${safeId} (uncompressed > 200 MB)`);
}
}
let htmlEntry: Buffer | null = null;
let cssEntry: Buffer | null = null;
let jsEntry: Buffer | null = null;
@@ -40,11 +40,8 @@ export async function handleAutoScriptsRoutes(
headers: { "Content-Type": "application/json" },
});
}
try {
requirePermission(user, "clients:control");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
if (user.role !== "admin") {
return new Response("Forbidden: Admin access required", { status: 403 });
}
let body: any = {};
@@ -98,11 +95,8 @@ export async function handleAutoScriptsRoutes(
headers: { "Content-Type": "application/json" },
});
}
try {
requirePermission(user, "clients:control");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
if (user.role !== "admin") {
return new Response("Forbidden: Admin access required", { status: 403 });
}
let body: any = {};
@@ -153,11 +147,8 @@ export async function handleAutoScriptsRoutes(
headers: { "Content-Type": "application/json" },
});
}
try {
requirePermission(user, "clients:control");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
if (user.role !== "admin") {
return new Response("Forbidden: Admin access required", { status: 403 });
}
deleteAutoScript(autoScriptMatch[1]);
@@ -13,6 +13,9 @@ import {
listBuildProfilesForUser,
saveBuildProfileForUser,
deleteBuildProfileForUser,
setBuildBlocked,
listClientIdsByBuildTag,
countBuildClaimsBatch,
} from "../../db";
import { encodeMessage } from "../../protocol";
import { metrics } from "../../metrics";
@@ -386,7 +389,13 @@ export async function handleBuildRoutes(
const builds = showAll
? getAllBuilds(undefined, "admin")
: getAllBuilds(user.userId, user.role === "admin" ? "operator" : user.role);
return Response.json({ builds });
const claimCounts = countBuildClaimsBatch(builds.map((b) => b.id));
const enriched = builds.map((b) => ({
...b,
claimCount: claimCounts.get(b.id) ?? 0,
}));
return Response.json({ builds: enriched });
}
if (req.method === "GET" && url.pathname === "/api/build/profiles") {
@@ -518,6 +527,66 @@ export async function handleBuildRoutes(
return Response.json({ success: true });
}
if (req.method === "POST" && url.pathname.match(/^\/api\/build\/(.+)\/block$/)) {
requirePermission(user, "clients:build");
const buildId = decodeURIComponent(url.pathname.split("/")[3]);
const build = getBuild(buildId);
if (!build) {
return new Response("Not Found", { status: 404 });
}
if (build.builtByUserId && build.builtByUserId !== user.userId && user.role !== "admin") {
return new Response("Forbidden", { status: 403 });
}
let body: any = {};
try {
body = await req.json();
} catch {
// empty body = default to block (true)
}
const blocked = body && typeof body.blocked === "boolean" ? body.blocked : true;
const ok = setBuildBlocked(buildId, blocked);
if (!ok) {
return Response.json({ error: "Build not found" }, { status: 404 });
}
let disconnected = 0;
if (blocked && build.buildTag) {
const clientIds = listClientIdsByBuildTag(build.buildTag);
for (const cid of clientIds) {
const target = clientManager.getClient(cid);
if (!target?.ws) continue;
try {
target.ws.send(
encodeMessage({
type: "command",
commandType: "disconnect",
id: uuidv4(),
payload: { reason: "build_blocked" },
}),
);
} catch {}
try { target.ws.close(4007, "build_blocked"); } catch {}
disconnected++;
}
}
const ip = server.requestIP(req)?.address || "unknown";
logAudit({
timestamp: Date.now(),
username: user.username,
ip,
action: AuditAction.COMMAND,
details: `${blocked ? "Blocked" : "Unblocked"} build ${buildId}${blocked && disconnected > 0 ? ` (kicked ${disconnected} live agents)` : ""}`,
success: true,
});
logger.info(`[build:block] Build ${buildId.substring(0, 8)} ${blocked ? "blocked" : "unblocked"} by ${user.username}${blocked && disconnected > 0 ? ` (${disconnected} live agents kicked)` : ""}`);
return Response.json({ success: true, blocked, disconnected });
}
if (req.method === "GET" && url.pathname.match(/^\/api\/build\/(.+)\/stream$/)) {
requirePermission(user, "clients:build");
@@ -625,11 +694,30 @@ export async function handleBuildRoutes(
!fileName ||
fileName.includes("\u0000") ||
fileName.includes("/") ||
fileName.includes("\\")
fileName.includes("\\") ||
fileName.includes("\"") ||
fileName.includes("\r") ||
fileName.includes("\n") ||
fileName.includes(";")
) {
return Response.json({ error: "File not found" }, { status: 404 });
}
const allBuilds = getAllBuilds(undefined, "admin");
const owningBuild = allBuilds.find((b) =>
Array.isArray(b.files) && b.files.some((f: any) => f && f.filename === fileName),
);
if (!owningBuild) {
return Response.json({ error: "File not found" }, { status: 404 });
}
if (
owningBuild.builtByUserId &&
owningBuild.builtByUserId !== user.userId &&
user.role !== "admin"
) {
return new Response("Forbidden", { status: 403 });
}
const rootDir = resolveRuntimeRoot();
const distRoot = path.resolve(rootDir, "dist-clients");
const filePath = path.resolve(distRoot, fileName);
@@ -4,7 +4,7 @@ import { getConfig, updateSecurityConfig, updateTlsConfig, updateAppearanceConfi
import { getClientMetricsSummary, getClientMetricsSummaryForUser } from "../../db";
import { metrics } from "../../metrics";
import { requirePermission } from "../../rbac";
import { getUserTelegramChatId, setUserTelegramChatId, getUserClientAccessScope, listUserClientRuleIdsByAccess } from "../../users";
import { getUserTelegramChatId, setUserTelegramChatId, getUserClientAccessScope, listUserClientRuleIdsByAccess, canUserAccessClient } from "../../users";
import { runCertbotSetup } from "../certbot-setup";
import {
getActiveProxies,
@@ -351,7 +351,10 @@ export async function handleMiscRoutes(
if (user.role === "viewer") {
return new Response("Forbidden", { status: 403 });
}
return Response.json({ proxies: getActiveProxies() }, { headers: deps.CORS_HEADERS });
const proxies = getActiveProxies().filter((p) =>
canUserAccessClient(user.userId, user.role, p.clientId),
);
return Response.json({ proxies }, { headers: deps.CORS_HEADERS });
}
if (req.method === "POST" && url.pathname === "/api/proxy/start") {
@@ -373,10 +376,22 @@ export async function handleMiscRoutes(
if (!clientId) {
return Response.json({ ok: false, message: "clientId is required" }, { status: 400 });
}
if (!canUserAccessClient(user.userId, user.role, clientId)) {
return Response.json({ ok: false, message: "Forbidden: Client access denied" }, { status: 403 });
}
if (port < 1 || port > 65535) {
return Response.json({ ok: false, message: "port must be 1-65535" }, { status: 400 });
}
const result = startProxy(clientId, port);
logAudit({
timestamp: Date.now(),
username: user.username,
ip: deps.requestIP?.(req)?.address || "unknown",
action: AuditAction.COMMAND,
details: `Started SOCKS5 proxy on port ${port} for client ${clientId}`,
success: result.ok,
errorMessage: result.ok ? undefined : result.message,
});
return Response.json(result, {
status: result.ok ? 200 : 400,
headers: deps.CORS_HEADERS,
@@ -401,7 +416,20 @@ export async function handleMiscRoutes(
if (port < 1 || port > 65535) {
return Response.json({ ok: false, message: "port must be 1-65535" }, { status: 400 });
}
const owner = getActiveProxies().find((p) => p.port === port);
if (owner && !canUserAccessClient(user.userId, user.role, owner.clientId)) {
return Response.json({ ok: false, message: "Forbidden: Client access denied" }, { status: 403 });
}
const result = stopProxy(port);
logAudit({
timestamp: Date.now(),
username: user.username,
ip: deps.requestIP?.(req)?.address || "unknown",
action: AuditAction.COMMAND,
details: `Stopped SOCKS5 proxy on port ${port}${owner ? ` (client ${owner.clientId})` : ""}`,
success: result.ok,
errorMessage: result.ok ? undefined : result.message,
});
return Response.json(result, {
status: result.ok ? 200 : 400,
headers: deps.CORS_HEADERS,
@@ -577,6 +605,10 @@ export async function handleMiscRoutes(
}
if (req.method === "GET" && url.pathname === "/api/cert/download" && deps.tlsCertPath) {
const user = await authenticateRequest(req);
if (!user) {
return new Response("Unauthorized", { status: 401 });
}
try {
const file = Bun.file(deps.tlsCertPath);
if (await file.exists()) {
@@ -81,8 +81,8 @@ export async function handleNotificationsConfigRoutes(
headers: { "Content-Type": "application/json" },
});
}
if (user.role !== "admin" && user.role !== "operator") {
return new Response("Forbidden: Admin or operator access required", {
if (user.role !== "admin") {
return new Response("Forbidden: Admin access required", {
status: 403,
});
}
@@ -1,5 +1,7 @@
import fs from "fs/promises";
import { constants as fsConstants } from "fs";
import path from "path";
import AdmZip from "adm-zip";
import { v4 as uuidv4 } from "uuid";
import { authenticateRequest } from "../../auth";
import { requirePermission } from "../../rbac";
@@ -189,6 +191,12 @@ export async function handlePluginRoutes(
return new Response("Only .zip files are supported", { status: 400 });
}
const MAX_PLUGIN_ZIP_BYTES = 50 * 1024 * 1024; // 50 MB compressed
const MAX_PLUGIN_UNCOMPRESSED_BYTES = 200 * 1024 * 1024; // 200 MB total uncompressed
if (typeof file.size === "number" && file.size > MAX_PLUGIN_ZIP_BYTES) {
return new Response("Plugin zip exceeds size limit", { status: 413 });
}
const base = path.basename(filename, path.extname(filename));
let pluginId = "";
try {
@@ -197,9 +205,30 @@ export async function handlePluginRoutes(
return new Response("Invalid plugin name", { status: 400 });
}
const data = new Uint8Array(await file.arrayBuffer());
if (data.byteLength > MAX_PLUGIN_ZIP_BYTES) {
return new Response("Plugin zip exceeds size limit", { status: 413 });
}
try {
const probe = new AdmZip(Buffer.from(data));
let totalUncompressed = 0;
for (const entry of probe.getEntries() as any[]) {
const sz = Number(entry?.header?.size ?? 0);
if (!Number.isFinite(sz) || sz < 0) {
return new Response("Invalid plugin zip", { status: 400 });
}
totalUncompressed += sz;
if (totalUncompressed > MAX_PLUGIN_UNCOMPRESSED_BYTES) {
return new Response("Plugin uncompressed size exceeds limit", { status: 413 });
}
}
} catch {
return new Response("Invalid plugin zip", { status: 400 });
}
await fs.mkdir(deps.PLUGIN_ROOT, { recursive: true });
const zipPath = path.join(deps.PLUGIN_ROOT, `${pluginId}.zip`);
const data = new Uint8Array(await file.arrayBuffer());
await fs.writeFile(zipPath, data);
try {
@@ -262,8 +291,8 @@ export async function handlePluginRoutes(
if (!user) {
return new Response("Unauthorized", { status: 401 });
}
if (user.role !== "admin" && user.role !== "operator") {
return new Response("Forbidden: Admin or operator access required", { status: 403 });
if (user.role !== "admin") {
return new Response("Forbidden: Admin access required", { status: 403 });
}
let pluginId = "";
try {
@@ -325,6 +354,28 @@ export async function handlePluginRoutes(
return target;
}
async function isPathSafe(target: string, dataDir: string): Promise<boolean> {
const root = path.resolve(dataDir);
let current = path.resolve(target);
while (current !== root) {
try {
const st = await fs.lstat(current);
if (st.isSymbolicLink()) return false;
} catch {
}
const parent = path.dirname(current);
if (parent === current) return false;
current = parent;
}
try {
const st = await fs.lstat(root);
if (st.isSymbolicLink() || !st.isDirectory()) return false;
} catch {
return false;
}
return true;
}
const pluginDataListMatch = url.pathname.match(/^\/api\/plugins\/([^/]+)\/data$/);
if (req.method === "GET" && pluginDataListMatch) {
const user = await authenticateRequest(req);
@@ -366,10 +417,13 @@ export async function handlePluginRoutes(
if (relPath.includes("\u0000")) return new Response("Bad request", { status: 400 });
const target = resolveDataPath(pluginId, relPath);
if (!target) return new Response("Forbidden", { status: 403 });
const dataDir = path.join(deps.PLUGIN_ROOT, pluginId, "data");
if (!(await isPathSafe(target, dataDir))) return new Response("Forbidden", { status: 403 });
const file = Bun.file(target);
if (!(await file.exists())) return new Response("Not found", { status: 404 });
const st = await fs.stat(target);
const st = await fs.lstat(target);
if (st.isDirectory()) return new Response("Is a directory", { status: 400 });
if (st.isSymbolicLink()) return new Response("Forbidden", { status: 403 });
return new Response(file, { headers: deps.secureHeaders(deps.mimeType(relPath)) });
}
@@ -387,9 +441,27 @@ export async function handlePluginRoutes(
}
const target = resolveDataPath(pluginId, relPath);
if (!target) return new Response("Forbidden", { status: 403 });
const dataDir = path.join(deps.PLUGIN_ROOT, pluginId, "data");
await fs.mkdir(dataDir, { recursive: true });
await fs.mkdir(path.dirname(target), { recursive: true });
if (!(await isPathSafe(target, dataDir))) return new Response("Forbidden", { status: 403 });
const existingSt = await fs.lstat(target).catch(() => null);
if (existingSt && existingSt.isSymbolicLink()) {
return new Response("Forbidden", { status: 403 });
}
const body = await req.arrayBuffer();
await fs.writeFile(target, new Uint8Array(body));
let fh: import("fs/promises").FileHandle | null = null;
try {
fh = await fs.open(target, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_TRUNC | (fsConstants.O_NOFOLLOW || 0));
await fh.writeFile(new Uint8Array(body));
} catch (err: any) {
if (err && (err.code === "ELOOP" || err.code === "EISDIR")) {
return new Response("Forbidden", { status: 403 });
}
throw err;
} finally {
if (fh) await fh.close().catch(() => {});
}
return Response.json({ ok: true, path: relPath, size: body.byteLength });
}
@@ -405,9 +477,14 @@ export async function handlePluginRoutes(
if (relPath.includes("\u0000")) return new Response("Bad request", { status: 400 });
const target = resolveDataPath(pluginId, relPath);
if (!target) return new Response("Forbidden", { status: 403 });
const dataDir = path.join(deps.PLUGIN_ROOT, pluginId, "data");
if (!(await isPathSafe(target, dataDir))) return new Response("Forbidden", { status: 403 });
try {
const st = await fs.stat(target);
if (st.isDirectory()) {
const st = await fs.lstat(target);
if (st.isSymbolicLink()) {
// Just unlink the link itself; never traverse it.
await fs.unlink(target);
} else if (st.isDirectory()) {
await fs.rm(target, { recursive: true, force: true });
} else {
await fs.unlink(target);
@@ -437,19 +514,35 @@ export async function handlePluginRoutes(
if (decodedFile.includes("\u0000")) return new Response("Bad request", { status: 400 });
const target = resolveDataPath(pluginId, decodedFile);
if (!target) return new Response("Forbidden", { status: 403 });
const dataDir = path.join(deps.PLUGIN_ROOT, pluginId, "data");
if (!(await isPathSafe(target, dataDir))) return new Response("Forbidden", { status: 403 });
try {
const st = await fs.stat(target);
const st = await fs.lstat(target);
if (st.isSymbolicLink()) return new Response("Forbidden", { status: 403 });
if (st.isDirectory()) return new Response("Is a directory", { status: 400 });
} catch {
return new Response("Not found", { status: 404 });
}
// Resolve via realpath to defeat any symlink raced into place after the lstat
// above; re-verify the resolved path is still under the data dir.
let realTarget: string;
try {
realTarget = await fs.realpath(target);
} catch {
return new Response("Forbidden", { status: 403 });
}
const dataDirPrefix = dataDir.endsWith(path.sep) ? dataDir : `${dataDir}${path.sep}`;
if (realTarget !== dataDir && !realTarget.startsWith(dataDirPrefix)) {
return new Response("Forbidden", { status: 403 });
}
const args: string[] = Array.isArray(body.args) ? body.args.filter((a: any) => typeof a === "string") : [];
const stdinData: string = typeof body.stdin === "string" ? body.stdin : "";
const timeoutMs: number = typeof body.timeoutMs === "number" && body.timeoutMs > 0 ? Math.min(body.timeoutMs, 60_000) : 30_000;
// Ensure the binary is executable
try { await fs.chmod(target, 0o755); } catch {}
const proc = Bun.spawn([target, ...args], {
cwd: path.dirname(target),
// Ensure the binary is executable. chmod follows symlinks, but we've verified
// the realpath stays inside dataDir, so this is safe.
try { await fs.chmod(realTarget, 0o755); } catch {}
const proc = Bun.spawn([realTarget, ...args], {
cwd: path.dirname(realTarget),
stdin: stdinData ? Buffer.from(stdinData) : "ignore",
stdout: "pipe",
stderr: "pipe",
@@ -65,11 +65,7 @@ type RegistrationRouteDeps = {
};
function getClientIp(req: Request, deps: RegistrationRouteDeps): string {
const fromDeps = deps.requestIP?.(req)?.address;
if (fromDeps) return fromDeps;
return req.headers.get("x-forwarded-for")?.split(",")[0]?.trim()
|| req.headers.get("x-real-ip")
|| "unknown";
return deps.requestIP?.(req)?.address || "unknown";
}
export async function handleRegistrationRoutes(
@@ -2,6 +2,11 @@ import { generateToken, authenticateRequest, getSessionTtlSeconds } from "../../
import { AuditAction, logAudit } from "../../auditLog";
import { logger } from "../../logger";
import { requirePermission } from "../../rbac";
import {
listUserSessions,
persistRevokedTokenHash,
revokeAllUserSessions,
} from "../../db";
import {
type ClientAccessRuleKind,
type ClientAccessScope,
@@ -80,6 +85,9 @@ export async function handleUsersRoutes(
}
if (req.method === "PUT" && url.pathname.match(/^\/api\/users\/\d+\/password$/)) {
if (!user) {
return Response.json({ error: "Not authenticated" }, { status: 401 });
}
const userId = parseInt(url.pathname.split("/")[3]);
const body = await req.json();
const { password, newPassword, currentPassword } = body;
@@ -90,12 +98,19 @@ export async function handleUsersRoutes(
return Response.json({ error: "Permission denied" }, { status: 403 });
}
if (user.userId === userId && currentPassword) {
if (user.userId === userId) {
const targetUser = getUserById(userId);
if (!targetUser) {
return Response.json({ error: "User not found" }, { status: 404 });
}
if (typeof currentPassword !== "string" || currentPassword.length === 0) {
return Response.json(
{ error: "Current password is required" },
{ status: 400 },
);
}
const isValid = await Bun.password.verify(
currentPassword,
targetUser.password_hash,
@@ -117,6 +132,13 @@ export async function handleUsersRoutes(
if (result.success) {
const targetUser = getUserById(userId);
const existingSessions = listUserSessions(userId).filter((s) => !s.revoked);
for (const s of existingSessions) {
persistRevokedTokenHash(s.tokenHash, s.expiresAt);
}
revokeAllUserSessions(userId);
const ip = server.requestIP(req)?.address || "unknown";
logAudit({
timestamp: Date.now(),
@@ -168,6 +190,13 @@ export async function handleUsersRoutes(
if (result.success) {
const targetUser = getUserById(userId);
const existingSessions = listUserSessions(userId).filter((s) => !s.revoked);
for (const s of existingSessions) {
persistRevokedTokenHash(s.tokenHash, s.expiresAt);
}
revokeAllUserSessions(userId);
const ip = server.requestIP(req)?.address || "unknown";
logAudit({
timestamp: Date.now(),
@@ -103,7 +103,7 @@ type WsLifecycleDeps = {
clearPendingNotificationScreenshots: (clientId: string) => void;
clearClientPluginState: (clientId: string) => void;
notifyRemoteDesktopStatus: (clientId: string, status: string, reason?: string) => void;
handleBuildTagConnection: (clientId: string, buildTag: string) => void;
handleBuildTagConnection: (clientId: string, buildTag: string, keyFingerprint: string) => void;
notifyDashboard: () => void;
notifyDashboardClientEvent: (
event: "client_online" | "client_offline" | "client_purgatory",
@@ -302,6 +302,22 @@ export async function handleWebSocketMessage(
const build = buildTag ? getBuildByTag(buildTag) : null;
const builtByUserId = build?.builtByUserId;
if (buildTag && build?.blocked) {
logger.info(`[build-block] rejecting agent ${resolvedId} from blocked build ${build.id.substring(0, 8)}`);
try {
ws.send(
encodeMessage({
type: "command",
commandType: "disconnect",
id: crypto.randomUUID(),
payload: { reason: "build_blocked" },
}),
);
} catch {}
try { ws.close(4007, "build_blocked"); } catch {}
return;
}
if (enrollmentStatus === "denied") {
logger.info(`[purgatory] denied client ${resolvedId} tried to connect`);
ws.send(encodeMessage({ type: "enrollment_status", status: "denied" }));
@@ -478,7 +494,7 @@ export async function handleWebSocketMessage(
(ws as any).data.wasKnown = true;
if (buildTag) {
deps.handleBuildTagConnection(infoObj.id, buildTag);
deps.handleBuildTagConnection(infoObj.id, buildTag, keyFingerprint);
}
}
break;