1.9.0
This commit is contained in:
+89
-31
@@ -191,8 +191,19 @@ db.run(`
|
||||
|
||||
try { db.run(`ALTER TABLE builds ADD COLUMN build_tag TEXT`); } catch {}
|
||||
try { db.run(`ALTER TABLE builds ADD COLUMN built_by_user_id INTEGER`); } catch {}
|
||||
try { db.run(`ALTER TABLE builds ADD COLUMN blocked INTEGER NOT NULL DEFAULT 0`); } catch {}
|
||||
db.run(`CREATE INDEX IF NOT EXISTS idx_builds_build_tag ON builds(build_tag);`);
|
||||
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS build_claims (
|
||||
build_id TEXT NOT NULL,
|
||||
key_fingerprint TEXT NOT NULL,
|
||||
claimed_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (build_id, key_fingerprint)
|
||||
);
|
||||
`);
|
||||
db.run(`CREATE INDEX IF NOT EXISTS idx_build_claims_build ON build_claims(build_id);`);
|
||||
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS build_profiles (
|
||||
user_id INTEGER NOT NULL,
|
||||
@@ -1537,6 +1548,7 @@ export interface BuildRecord {
|
||||
}>;
|
||||
buildTag?: string;
|
||||
builtByUserId?: number;
|
||||
blocked?: boolean;
|
||||
}
|
||||
|
||||
export function saveBuild(build: BuildRecord) {
|
||||
@@ -1553,10 +1565,7 @@ export function saveBuild(build: BuildRecord) {
|
||||
);
|
||||
}
|
||||
|
||||
export function getBuild(id: string): BuildRecord | null {
|
||||
const row = db.query<any>(`SELECT * FROM builds WHERE id = ?`).get(id);
|
||||
if (!row) return null;
|
||||
|
||||
function mapBuildRow(row: any): BuildRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
status: row.status,
|
||||
@@ -1565,22 +1574,87 @@ export function getBuild(id: string): BuildRecord | null {
|
||||
files: JSON.parse(row.files),
|
||||
buildTag: row.build_tag || undefined,
|
||||
builtByUserId: row.built_by_user_id || undefined,
|
||||
blocked: row.blocked === 1,
|
||||
};
|
||||
}
|
||||
|
||||
export function getBuild(id: string): BuildRecord | null {
|
||||
const row = db.query<any>(`SELECT * FROM builds WHERE id = ?`).get(id);
|
||||
if (!row) return null;
|
||||
return mapBuildRow(row);
|
||||
}
|
||||
|
||||
export function getBuildByTag(buildTag: string): BuildRecord | null {
|
||||
const row = db.query<any>(`SELECT * FROM builds WHERE build_tag = ?`).get(buildTag);
|
||||
if (!row) return null;
|
||||
return mapBuildRow(row);
|
||||
}
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
status: row.status,
|
||||
startTime: row.start_time,
|
||||
expiresAt: row.expires_at,
|
||||
files: JSON.parse(row.files),
|
||||
buildTag: row.build_tag || undefined,
|
||||
builtByUserId: row.built_by_user_id || undefined,
|
||||
};
|
||||
export function isBuildTagBlocked(buildTag: string): boolean {
|
||||
if (!buildTag) return false;
|
||||
const row = db.query<{ blocked: number }>(
|
||||
`SELECT blocked FROM builds WHERE build_tag = ?`,
|
||||
).get(buildTag);
|
||||
return !!row && row.blocked === 1;
|
||||
}
|
||||
|
||||
export function setBuildBlocked(buildId: string, blocked: boolean): boolean {
|
||||
const result = db.run(
|
||||
`UPDATE builds SET blocked = ? WHERE id = ?`,
|
||||
blocked ? 1 : 0,
|
||||
buildId,
|
||||
);
|
||||
return ((result as any)?.changes || 0) > 0;
|
||||
}
|
||||
|
||||
export function listClientIdsByBuildTag(buildTag: string): string[] {
|
||||
if (!buildTag) return [];
|
||||
const rows = db.query<{ id: string }>(
|
||||
`SELECT id FROM clients WHERE build_tag = ?`,
|
||||
).all(buildTag);
|
||||
return rows.map((r) => r.id);
|
||||
}
|
||||
|
||||
export function recordBuildClaim(buildId: string, keyFingerprint: string): boolean {
|
||||
if (!buildId || !keyFingerprint) return false;
|
||||
try {
|
||||
const result = db.run(
|
||||
`INSERT OR IGNORE INTO build_claims (build_id, key_fingerprint, claimed_at) VALUES (?, ?, ?)`,
|
||||
buildId,
|
||||
keyFingerprint,
|
||||
Date.now(),
|
||||
);
|
||||
return ((result as any)?.changes || 0) > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Number of distinct agent fingerprints that have ever claimed this build's tag. */
|
||||
export function countBuildClaims(buildId: string): number {
|
||||
if (!buildId) return 0;
|
||||
const row = db.query<{ count: number }>(
|
||||
`SELECT COUNT(*) as count FROM build_claims WHERE build_id = ?`,
|
||||
).get(buildId);
|
||||
return row?.count ?? 0;
|
||||
}
|
||||
|
||||
/** Batch helper — returns Map<buildId, claimCount> for the given build IDs. */
|
||||
export function countBuildClaimsBatch(buildIds: string[]): Map<string, number> {
|
||||
const out = new Map<string, number>();
|
||||
if (buildIds.length === 0) return out;
|
||||
// SQLite has a host-parameter limit (~999 by default); chunk to be safe.
|
||||
for (let i = 0; i < buildIds.length; i += 500) {
|
||||
const chunk = buildIds.slice(i, i + 500);
|
||||
const placeholders = chunk.map(() => "?").join(",");
|
||||
const rows = db.query<{ build_id: string; count: number }>(
|
||||
`SELECT build_id, COUNT(*) as count FROM build_claims WHERE build_id IN (${placeholders}) GROUP BY build_id`,
|
||||
).all(...chunk);
|
||||
for (const r of rows) out.set(r.build_id, r.count);
|
||||
}
|
||||
// Ensure every requested ID is in the map (default 0).
|
||||
for (const id of buildIds) if (!out.has(id)) out.set(id, 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function getAllBuilds(userId?: number, role?: string): BuildRecord[] {
|
||||
@@ -1591,28 +1665,12 @@ export function getAllBuilds(userId?: number, role?: string): BuildRecord[] {
|
||||
const rows = db
|
||||
.query<any>(`SELECT * FROM builds WHERE built_by_user_id = ? ORDER BY start_time DESC`)
|
||||
.all(userId);
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
status: row.status,
|
||||
startTime: row.start_time,
|
||||
expiresAt: row.expires_at,
|
||||
files: JSON.parse(row.files),
|
||||
buildTag: row.build_tag || undefined,
|
||||
builtByUserId: row.built_by_user_id || undefined,
|
||||
}));
|
||||
return rows.map(mapBuildRow);
|
||||
}
|
||||
const rows = db
|
||||
.query<any>("SELECT * FROM builds ORDER BY start_time DESC")
|
||||
.all();
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
status: row.status,
|
||||
startTime: row.start_time,
|
||||
expiresAt: row.expires_at,
|
||||
files: JSON.parse(row.files),
|
||||
buildTag: row.build_tag || undefined,
|
||||
builtByUserId: row.built_by_user_id || undefined,
|
||||
}));
|
||||
return rows.map(mapBuildRow);
|
||||
}
|
||||
|
||||
export function deleteExpiredBuilds() {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { fileURLToPath } from "url";
|
||||
import path from "path";
|
||||
import fs from "fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { upsertClientRow, setOnlineState, listClients, markAllClientsOffline, getBuild, getBuildByTag, getAllBuilds, deleteExpiredBuilds, deleteBuild, getNotificationScreenshot, clearNotificationScreenshots, deleteClientRow, getClientIp, banIp, isIpBanned, clientExists, deleteExpiredSharedFiles, getChatHistory, insertChatMessage, getOnlineClientCountForUser, deleteExpiredChatMessages } from "./db";
|
||||
import { upsertClientRow, setOnlineState, listClients, markAllClientsOffline, getBuild, getBuildByTag, getAllBuilds, deleteExpiredBuilds, deleteBuild, getNotificationScreenshot, clearNotificationScreenshots, deleteClientRow, getClientIp, banIp, isIpBanned, clientExists, deleteExpiredSharedFiles, getChatHistory, insertChatMessage, getOnlineClientCountForUser, deleteExpiredChatMessages, recordBuildClaim } from "./db";
|
||||
import { handleFrame, handleHello, handlePing, handlePong } from "./wsHandlers";
|
||||
import { getMessageByteLength, getMaxPayloadLimit, isAllowedClientMessageType } from "./wsValidation";
|
||||
import { ClientInfo, ClientRole } from "./types";
|
||||
@@ -482,11 +482,16 @@ async function startServer() {
|
||||
registration: {},
|
||||
};
|
||||
|
||||
function handleBuildTagConnection(clientId: string, buildTagValue: string) {
|
||||
function handleBuildTagConnection(clientId: string, buildTagValue: string, keyFingerprint: string) {
|
||||
if (!buildTagValue) return;
|
||||
const build = getBuildByTag(buildTagValue);
|
||||
if (!build || !build.builtByUserId) return;
|
||||
|
||||
if (build.blocked) return;
|
||||
|
||||
const isFirstClaim = recordBuildClaim(build.id, keyFingerprint);
|
||||
if (!isFirstClaim) return;
|
||||
|
||||
const userId = build.builtByUserId;
|
||||
const user = getUserById(userId);
|
||||
if (!user) return;
|
||||
@@ -500,7 +505,7 @@ async function startServer() {
|
||||
|
||||
if (currentScope === "none" || currentScope === "allowlist") {
|
||||
setUserClientAccessRule(userId, clientId, "allow");
|
||||
logger.info(`[build-tag] Auto-added client ${clientId} to user ${user.username}'s allowlist (build: ${build.id.substring(0, 8)})`);
|
||||
logger.info(`[build-tag] Auto-added client ${clientId} to user ${user.username}'s allowlist (build: ${build.id.substring(0, 8)}, fp: ${keyFingerprint.substring(0, 12)}...)`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -280,10 +280,7 @@ if (userCount.count === 0) {
|
||||
|
||||
logger.info(`[users] Initial admin account created (username: ${initialUsername})`);
|
||||
logger.warn(
|
||||
`[users] Bootstrap login credentials -> username: ${initialUsername} | password: ${initialPassword}`,
|
||||
);
|
||||
logger.warn(
|
||||
"[users] SECURITY WARNING: Rotate the bootstrap password after first login. Default bootstrap credentials are admin/admin unless overridden by configuration.",
|
||||
"[users] SECURITY WARNING: A default admin account has been created. Sign in and rotate the password immediately. Bootstrap credentials default to admin/admin unless overridden by configuration; the password is not logged.",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -742,12 +739,30 @@ export function updateLastLogin(userId: number): void {
|
||||
);
|
||||
}
|
||||
|
||||
let _dummyHashPromise: Promise<string> | null = null;
|
||||
function getDummyPasswordHash(): Promise<string> {
|
||||
if (!_dummyHashPromise) {
|
||||
_dummyHashPromise = Bun.password.hash("__overlord_dummy_hash_for_timing__", {
|
||||
algorithm: "bcrypt",
|
||||
cost: 10,
|
||||
});
|
||||
}
|
||||
return _dummyHashPromise;
|
||||
}
|
||||
|
||||
export async function verifyPassword(
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<User | null> {
|
||||
const user = getUserByUsername(username);
|
||||
if (!user) return null;
|
||||
if (!user) {
|
||||
try {
|
||||
const dummy = await getDummyPasswordHash();
|
||||
await Bun.password.verify(password, dummy);
|
||||
} catch {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const isValid = await Bun.password.verify(password, user.password_hash);
|
||||
if (!isValid) return null;
|
||||
|
||||
@@ -24,6 +24,37 @@ function sanitizeInfoString(val: unknown, maxLen = 256): string | undefined {
|
||||
return val.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "").slice(0, maxLen);
|
||||
}
|
||||
|
||||
function sanitizeMonitorCount(val: unknown): number | undefined {
|
||||
if (typeof val !== "number" || !Number.isFinite(val)) return undefined;
|
||||
const n = Math.floor(val);
|
||||
if (n < 0) return 0;
|
||||
if (n > 32) return 32;
|
||||
return n;
|
||||
}
|
||||
|
||||
function sanitizeJsonField(
|
||||
val: unknown,
|
||||
opts: { maxJsonBytes: number; maxArrayLen?: number; maxKeys?: number },
|
||||
): unknown | undefined {
|
||||
if (val === null || val === undefined) return undefined;
|
||||
if (typeof val !== "object") return undefined;
|
||||
if (Array.isArray(val) && opts.maxArrayLen !== undefined && val.length > opts.maxArrayLen) {
|
||||
return undefined;
|
||||
}
|
||||
if (!Array.isArray(val) && opts.maxKeys !== undefined) {
|
||||
const keys = Object.keys(val as Record<string, unknown>);
|
||||
if (keys.length > opts.maxKeys) return undefined;
|
||||
}
|
||||
let serialized: string;
|
||||
try {
|
||||
serialized = JSON.stringify(val);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (serialized.length > opts.maxJsonBytes) return undefined;
|
||||
return val;
|
||||
}
|
||||
|
||||
const MAX_PING_RTT_MS = 15_000;
|
||||
const CLIENT_DB_SYNC_INTERVAL_MS = Number(process.env.OVERLORD_CLIENT_DB_SYNC_MS || 5000);
|
||||
const lastClientDbSync = new Map<string, number>();
|
||||
@@ -99,13 +130,23 @@ export async function handleHello(
|
||||
info.arch = sanitizeInfoString(payload.arch, 32);
|
||||
info.version = sanitizeInfoString(payload.version, 64);
|
||||
info.user = sanitizeInfoString(payload.user);
|
||||
info.monitors = payload.monitors;
|
||||
info.monitorInfo = (payload as any).monitorInfo || info.monitorInfo;
|
||||
info.monitors = sanitizeMonitorCount(payload.monitors) ?? info.monitors;
|
||||
const cleanMonitorInfo = sanitizeJsonField((payload as any).monitorInfo, {
|
||||
maxJsonBytes: 8 * 1024,
|
||||
maxArrayLen: 32,
|
||||
});
|
||||
if (cleanMonitorInfo !== undefined) {
|
||||
info.monitorInfo = cleanMonitorInfo as any;
|
||||
}
|
||||
info.inMemory = !!(payload as any).inMemory;
|
||||
info.isAdmin = !!(payload as any).isAdmin;
|
||||
info.elevation = sanitizeInfoString((payload as any).elevation, 32) ?? info.elevation;
|
||||
if ((payload as any).permissions && typeof (payload as any).permissions === "object") {
|
||||
info.permissions = (payload as any).permissions;
|
||||
const cleanPermissions = sanitizeJsonField((payload as any).permissions, {
|
||||
maxJsonBytes: 4 * 1024,
|
||||
maxKeys: 64,
|
||||
});
|
||||
if (cleanPermissions !== undefined && !Array.isArray(cleanPermissions)) {
|
||||
info.permissions = cleanPermissions as any;
|
||||
}
|
||||
info.cpu = sanitizeInfoString((payload as any).cpu) || info.cpu;
|
||||
info.gpu = sanitizeInfoString((payload as any).gpu) || info.gpu;
|
||||
|
||||
Reference in New Issue
Block a user