diff --git a/Overlord-Server/package.json b/Overlord-Server/package.json index 46cf358..7d1d0ff 100644 --- a/Overlord-Server/package.json +++ b/Overlord-Server/package.json @@ -8,7 +8,7 @@ "test:e2e": "bun run test:e2e:prepare && playwright test", "test:e2e:prepare": "bun run build:css && bun run vendor", "test:e2e:install": "playwright install chromium", - "dev": "bun --watch src/index.ts", + "dev": "bun run scripts/dev.ts", "start": "bun run build && bun dist/index.js", "build": "bun run build:css && bun run vendor && bun run build:bundle", "build:public:prod": "bun run build:css && bun run vendor && bun run minify", @@ -17,7 +17,7 @@ "build:prod:win": "bun run build:public:prod && bun build --production src/index.ts --compile --target bun-windows-x64 --outfile dist/overlord-server.exe", "build:prod:linux": "bun run build:public:prod && bun build --production src/index.ts --compile --target bun-linux-x64 --outfile dist/overlord-server-linux-x64", "build:css": "tailwindcss -i ./src/styles.css -o ./public/assets/tailwind.css --minify", - "watch:css": "tailwindcss -i ./src/styles.css -o ./public/assets/tailwind.css --watch", + "watch:css": "tailwindcss -i ./src/styles.css -o ./public/assets/tailwind.css --watch=always", "minify": "bun run scripts/minify-public.ts", "bench:client-db": "bun run scripts/client-db-stress.ts", "typecheck": "tsc --noEmit" diff --git a/Overlord-Server/public/assets/access-denied.css b/Overlord-Server/public/assets/access-denied.css new file mode 100644 index 0000000..fd26eba --- /dev/null +++ b/Overlord-Server/public/assets/access-denied.css @@ -0,0 +1,201 @@ +:root { + color-scheme: dark; + font-family: "Inter", system-ui, sans-serif; + background: #05070f; + color: #eef2ff; +} + +* { + box-sizing: border-box; +} + +body { + min-width: 320px; + min-height: 100vh; + margin: 0; + background: + radial-gradient(circle at 18% 16%, rgba(37, 99, 235, 0.22), transparent 32rem), + radial-gradient(circle at 82% 84%, rgba(124, 58, 237, 0.16), transparent 30rem), + #05070f; +} + +body::before { + position: fixed; + inset: 0; + background-image: + linear-gradient(rgba(148, 163, 184, 0.025) 1px, transparent 1px), + linear-gradient(90deg, rgba(148, 163, 184, 0.025) 1px, transparent 1px); + background-size: 40px 40px; + content: ""; + pointer-events: none; + mask-image: radial-gradient(circle at center, black, transparent 80%); +} + +.access-shell { + position: relative; + display: grid; + min-height: 100vh; + place-content: center; + justify-items: center; + padding: 32px 20px; +} + +.access-card { + width: min(100%, 590px); + padding: clamp(28px, 6vw, 48px); + overflow: hidden; + text-align: center; + background: linear-gradient(145deg, rgba(24, 32, 52, 0.96), rgba(11, 15, 27, 0.98)); + border: 1px solid rgba(148, 163, 184, 0.16); + border-radius: 24px; + box-shadow: 0 28px 90px rgba(0, 0, 0, 0.48), 0 0 80px rgba(59, 130, 246, 0.08); +} + +.access-status { + display: inline-flex; + padding: 7px 11px; + margin-bottom: 28px; + color: #fbbf24; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + background: rgba(245, 158, 11, 0.09); + border: 1px solid rgba(245, 158, 11, 0.2); + border-radius: 999px; +} + +.access-icon { + display: grid; + width: 76px; + height: 76px; + margin: 0 auto 22px; + place-items: center; + color: #a5b4fc; + font-size: 1.8rem; + background: linear-gradient(145deg, rgba(79, 70, 229, 0.25), rgba(37, 99, 235, 0.12)); + border: 1px solid rgba(129, 140, 248, 0.24); + border-radius: 22px; + box-shadow: inset 0 1px rgba(255, 255, 255, 0.08), 0 14px 35px rgba(37, 99, 235, 0.14); +} + +h1 { + margin: 0; + font-size: clamp(1.7rem, 5vw, 2.35rem); + line-height: 1.12; + letter-spacing: -0.035em; +} + +.access-message { + max-width: 460px; + margin: 14px auto 0; + color: #aebbd0; + font-size: 1rem; + line-height: 1.65; +} + +.access-detail { + display: flex; + gap: 12px; + align-items: center; + justify-content: space-between; + margin-top: 26px; + padding: 14px 16px; + text-align: left; + background: rgba(2, 6, 23, 0.58); + border: 1px solid rgba(100, 116, 139, 0.2); + border-radius: 12px; +} + +.access-detail span { + color: #8593aa; + font-size: 0.78rem; + font-weight: 600; +} + +.access-detail code { + padding: 4px 7px; + color: #c7d2fe; + font-family: ui-monospace, "Cascadia Code", monospace; + font-size: 0.78rem; + background: rgba(99, 102, 241, 0.1); + border-radius: 6px; +} + +.access-help { + margin: 17px auto 0; + color: #708097; + font-size: 0.8rem; + line-height: 1.5; +} + +.access-actions { + display: flex; + gap: 10px; + justify-content: center; + margin-top: 30px; +} + +.access-actions a { + display: inline-flex; + gap: 9px; + align-items: center; + justify-content: center; + min-height: 44px; + padding: 0 17px; + color: #dbe7f7; + font-size: 0.86rem; + font-weight: 600; + text-decoration: none; + border-radius: 10px; + transition: transform 150ms ease, border-color 150ms ease, background 150ms ease; +} + +.access-actions a:hover { + transform: translateY(-1px); +} + +.access-primary { + background: linear-gradient(135deg, #4f46e5, #2563eb); + border: 1px solid rgba(165, 180, 252, 0.24); + box-shadow: 0 10px 24px rgba(37, 99, 235, 0.2); +} + +.access-primary:hover { + background: linear-gradient(135deg, #5b52ee, #3472ec); +} + +.access-secondary { + background: rgba(30, 41, 59, 0.6); + border: 1px solid rgba(148, 163, 184, 0.16); +} + +.access-secondary:hover { + background: rgba(51, 65, 85, 0.66); + border-color: rgba(148, 163, 184, 0.3); +} + +.access-footer { + margin: 22px 0 0; + color: #536077; + font-size: 0.76rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.access-footer i { + margin-right: 6px; + color: #6776a5; +} + +@media (max-width: 520px) { + .access-actions { + flex-direction: column; + } + + .access-detail { + align-items: flex-start; + flex-direction: column; + } +} diff --git a/Overlord-Server/scripts/dev.ts b/Overlord-Server/scripts/dev.ts new file mode 100644 index 0000000..8a54e4d --- /dev/null +++ b/Overlord-Server/scripts/dev.ts @@ -0,0 +1,70 @@ +/** + * Development supervisor. + * + * Keeps the backend and generated Tailwind stylesheet up to date while + * preserving a single foreground process for the repository launchers. + */ + +const cwd = import.meta.dir.replace(/[\\/]scripts$/, ""); +const env = { + ...process.env, + NODE_ENV: "development", +}; + +function runPreparation(command: string): void { + const result = Bun.spawnSync(["bun", "run", command], { + cwd, + env, + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + }); + + if (!result.success) { + process.exit(result.exitCode || 1); + } +} + +runPreparation("build:css"); +runPreparation("vendor"); + +const backend = Bun.spawn(["bun", "--watch", "src/index.ts"], { + cwd, + env, + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", +}); + +const styles = Bun.spawn(["bun", "run", "watch:css"], { + cwd, + env, + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", +}); + +let stopping = false; + +function stop(exitCode = 0): void { + if (stopping) return; + stopping = true; + backend.kill(); + styles.kill(); + process.exitCode = exitCode; +} + +process.on("SIGINT", () => stop(130)); +process.on("SIGTERM", () => stop(143)); + +const result = await Promise.race([ + backend.exited.then((exitCode) => ({ process: "backend", exitCode })), + styles.exited.then((exitCode) => ({ process: "Tailwind watcher", exitCode })), +]); + +if (!stopping) { + console.error(`[dev] ${result.process} exited with code ${result.exitCode}`); + stop(result.exitCode || 1); +} + +await Promise.allSettled([backend.exited, styles.exited]); diff --git a/Overlord-Server/src/main-server.ts b/Overlord-Server/src/main-server.ts index fde9182..23073dd 100644 --- a/Overlord-Server/src/main-server.ts +++ b/Overlord-Server/src/main-server.ts @@ -278,6 +278,7 @@ function parseMaxHttpBodyBytes(): number { } const MAX_HTTP_BODY_BYTES = parseMaxHttpBodyBytes(); +const IS_DEVELOPMENT = String(process.env.NODE_ENV || "").trim().toLowerCase() === "development"; const pluginLoadedByClient = new Map>(); const pendingPluginEvents = new Map>(); @@ -710,6 +711,13 @@ async function startServer() { port: PORT, hostname: HOST, ...(tls ? { tls: tls.tlsOptions } : {}), + development: IS_DEVELOPMENT + ? { + hmr: true, + console: true, + chromeDevToolsAutomaticWorkspaceFolders: true, + } + : false, idleTimeout: 255, maxRequestBodySize: MAX_HTTP_BODY_BYTES, fetch: createHttpFetchHandler({ diff --git a/Overlord-Server/src/server/routes/page-routes.ts b/Overlord-Server/src/server/routes/page-routes.ts index 08615f9..41e79eb 100644 --- a/Overlord-Server/src/server/routes/page-routes.ts +++ b/Overlord-Server/src/server/routes/page-routes.ts @@ -1,8 +1,10 @@ import { authenticateRequest } from "../../auth"; import { getConfig } from "../../config"; import { consumeLoginPageRateLimit } from "../../rateLimit"; -import { requirePermission, type Permission } from "../../rbac"; +import { getPermissionDescription, requirePermission, type Permission } from "../../rbac"; import { canUserAccessClient, canUserAccessFeature, getUserById, type FeatureName, type UserRole } from "../../users"; +import { renderAccessDeniedPage, type AccessDeniedKind } from "../ui/access-denied-view"; +import { htmlResponse } from "../ui/html"; type PageRouteDeps = { PUBLIC_ROOT: string; @@ -120,23 +122,60 @@ const PATH_CLIENT_PAGES: ClientPageDef[] = [ { pattern: /^\/(.+)\/keylogger$/, file: "keylogger.html", clientIdGroup: 1, feature: "keylogger" }, ]; -function checkAccess(role: UserRole, access: AccessLevel): Response | null { +function accessDeniedResponse( + deps: PageRouteDeps, + options: { + kind: AccessDeniedKind; + title?: string; + message: string; + detail?: string; + detailLabel?: string; + }, +): Response { + return htmlResponse(renderAccessDeniedPage(options), { + status: 403, + headers: deps.secureHeaders("text/html; charset=utf-8"), + }); +} + +function permissionDeniedResponse(deps: PageRouteDeps, permission: Permission): Response { + return accessDeniedResponse(deps, { + kind: "permission", + message: `This page requires permission to ${getPermissionDescription(permission).toLowerCase()}.`, + detail: permission, + detailLabel: "Missing permission", + }); +} + +function checkAccess(deps: PageRouteDeps, role: UserRole, access: AccessLevel): Response | null { switch (access) { case "any": return null; case "no-viewer": if (role === "viewer") { - return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 }); + return accessDeniedResponse(deps, { + kind: "role", + message: "Viewer accounts can inspect data, but cannot open interactive control features.", + detail: "Operator or administrator role", + }); } return null; case "admin": if (role !== "admin") { - return new Response("Forbidden: Admin access required", { status: 403 }); + return accessDeniedResponse(deps, { + kind: "role", + message: "This area is reserved for administrators.", + detail: "Administrator role", + }); } return null; case "admin-or-operator": if (role !== "admin" && role !== "operator") { - return new Response("Forbidden: Admin or operator access required", { status: 403 }); + return accessDeniedResponse(deps, { + kind: "role", + message: "This interactive area is available to operators and administrators.", + detail: "Operator or administrator role", + }); } return null; } @@ -195,7 +234,12 @@ export async function handlePageRoutes( if (!user) return serveLoginOrUnauthorized(req, deps); if (page.path === "/screenshots" && !getConfig().thumbnails.wallEnabled) { - return new Response("Screenshot wall is disabled by the administrator", { status: 403 }); + return accessDeniedResponse(deps, { + kind: "unavailable", + message: "The screenshot wall has been disabled in server settings.", + detail: "Screenshot wall disabled", + detailLabel: "Current status", + }); } if (page.checkPasswordChange) { @@ -203,14 +247,16 @@ export async function handlePageRoutes( if (maybeChange) return maybeChange; } - const accessDenied = checkAccess(user.role, page.access); + const accessDenied = checkAccess(deps, user.role, page.access); if (accessDenied) return accessDenied; if (page.permission) { try { requirePermission(user, page.permission); } catch (error) { - if (error instanceof Response) return error; + if (error instanceof Response && error.status === 403) { + return permissionDeniedResponse(deps, page.permission); + } return new Response("Forbidden", { status: 403 }); } } @@ -232,18 +278,34 @@ export async function handlePageRoutes( try { requirePermission(user, page.permission); } catch (error) { - if (error instanceof Response) return error; + if (error instanceof Response && error.status === 403) { + return permissionDeniedResponse(deps, page.permission); + } return new Response("Forbidden", { status: 403 }); } } if (page.feature && !canUserAccessFeature(user.userId, user.role, page.feature)) { - return new Response(`Forbidden: feature access denied for ${page.feature}`, { status: 403 }); + return accessDeniedResponse(deps, { + kind: "feature", + title: "This feature isn't enabled for you", + message: "Your account's feature access does not include this client tool.", + detail: page.feature, + detailLabel: "Restricted feature", + }); } const clientId = (url.searchParams.get("clientId") || "").trim(); if (!canAccessClientPage(user.userId, user.role, clientId)) { - return new Response("Forbidden: Client access denied", { status: 403 }); + return accessDeniedResponse(deps, { + kind: "client", + title: "Client access restricted", + message: clientId + ? "This client is outside the scope assigned to your account." + : "Choose a client from the dashboard before opening this tool.", + detail: clientId || "No client selected", + detailLabel: "Client", + }); } return serveFile(deps, page.file); @@ -261,18 +323,32 @@ export async function handlePageRoutes( try { requirePermission(user, page.permission); } catch (error) { - if (error instanceof Response) return error; + if (error instanceof Response && error.status === 403) { + return permissionDeniedResponse(deps, page.permission); + } return new Response("Forbidden", { status: 403 }); } } if (page.feature && !canUserAccessFeature(user.userId, user.role, page.feature)) { - return new Response(`Forbidden: feature access denied for ${page.feature}`, { status: 403 }); + return accessDeniedResponse(deps, { + kind: "feature", + title: "This feature isn't enabled for you", + message: "Your account's feature access does not include this client tool.", + detail: page.feature, + detailLabel: "Restricted feature", + }); } const clientId = match[page.clientIdGroup]; if (!canAccessClientPage(user.userId, user.role, clientId)) { - return new Response("Forbidden: Client access denied", { status: 403 }); + return accessDeniedResponse(deps, { + kind: "client", + title: "Client access restricted", + message: "This client is outside the scope assigned to your account.", + detail: clientId, + detailLabel: "Client", + }); } return serveFile(deps, page.file); diff --git a/Overlord-Server/src/server/ui/access-denied-view.test.ts b/Overlord-Server/src/server/ui/access-denied-view.test.ts new file mode 100644 index 0000000..d966593 --- /dev/null +++ b/Overlord-Server/src/server/ui/access-denied-view.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from "bun:test"; +import { renderAccessDeniedPage } from "./access-denied-view"; + +describe("renderAccessDeniedPage", () => { + test("renders a complete permission screen with recovery actions", () => { + const html = renderAccessDeniedPage({ + kind: "permission", + message: "This page requires additional access.", + detail: "users:manage", + detailLabel: "Missing permission", + }); + + expect(html).toContain(""); + expect(html).toContain("You don't have access"); + expect(html).toContain("users:manage"); + expect(html).toContain('href="/"'); + expect(html).toContain('href="/settings"'); + }); + + test("escapes dynamic denial details", () => { + const html = renderAccessDeniedPage({ + kind: "client", + title: "", + message: "Client ", + detail: '">', + }); + + expect(html).not.toContain(""); + expect(html).not.toContain(" = { + permission: "fa-user-lock", + role: "fa-shield-halved", + feature: "fa-toggle-off", + client: "fa-desktop", + unavailable: "fa-circle-pause", +}; + +export function renderAccessDeniedPage(options: AccessDeniedViewOptions): string { + const title = options.title || ( + options.kind === "unavailable" ? "This feature is unavailable" : "You don't have access" + ); + const detail = options.detail + ? ` +
+ ${escapeHtml(options.detailLabel || "Required access")} + ${escapeHtml(options.detail)} +
` + : ""; + + return ` + + + + + + ${escapeHtml(title)} · Overlord + + + + + + + + +
+
+
403 · Access restricted
+ +

${escapeHtml(title)}

+

${escapeHtml(options.message)}

+ ${detail} +

If you believe you should have access, ask an administrator to update your account.

+ +
+ +
+ +`; +} diff --git a/Overlord-Server/test/e2e/access.e2e.ts b/Overlord-Server/test/e2e/access.e2e.ts index 880e5d4..b960e21 100644 --- a/Overlord-Server/test/e2e/access.e2e.ts +++ b/Overlord-Server/test/e2e/access.e2e.ts @@ -56,7 +56,9 @@ test.describe("authentication and authorization", () => { const denied = await page.goto("/users"); expect(denied?.status()).toBe(403); - await expect(page.locator("body")).toContainText("missing permission users:manage"); + await expect(page.getByRole("heading", { name: "You don't have access" })).toBeVisible(); + await expect(page.locator(".access-detail")).toContainText("users:manage"); + await expect(page.getByRole("link", { name: "Return to dashboard" })).toBeVisible(); const allowed = await page.goto("/metrics"); expect(allowed?.status()).toBe(200); diff --git a/scripts/start-dev-server.sh b/scripts/start-dev-server.sh index b599ff0..400139a 100644 --- a/scripts/start-dev-server.sh +++ b/scripts/start-dev-server.sh @@ -23,12 +23,9 @@ fi echo "[server] using bun at: $(command -v $BUN_BIN)" echo "[server] bun install..." "$BUN_BIN" install -echo "[server] building Tailwind CSS..." -"$BUN_BIN" run build:css -echo "[server] building vendor assets..." -"$BUN_BIN" run vendor echo "[server] starting bun dev (foreground)" # Use exec so the script process is replaced by the bun process. This keeps the # bun process as a child of the dispatcher so the dispatcher can kill it. export OVERLORD_AGENT_TOKEN="dev-token-insecure-local-only" +export NODE_ENV="development" exec "$BUN_BIN" run dev diff --git a/scripts/start-dev.bat b/scripts/start-dev.bat index 2e2abea..faf3169 100644 --- a/scripts/start-dev.bat +++ b/scripts/start-dev.bat @@ -37,7 +37,7 @@ set OVERLORD_DISABLE_AGENT_AUTH=true set LOG_LEVEL=debug set CGO_ENABLED=1 -start "Overlord-Server" cmd /k "cd /d ""%ROOT%Overlord-Server"" && set OVERLORD_DISABLE_AGENT_AUTH=true && set OVERLORD_AGENT_TOKEN=dev-token-insecure-local-only && set LOG_LEVEL=debug && set NODE_ENV=development && bun install && bun run start" +start "Overlord-Server" cmd /k "cd /d ""%ROOT%Overlord-Server"" && set OVERLORD_DISABLE_AGENT_AUTH=true && set OVERLORD_AGENT_TOKEN=dev-token-insecure-local-only && set LOG_LEVEL=debug && set NODE_ENV=development && bun run dev" timeout /t 3 /nobreak >nul start "Overlord-Client" cmd /k "cd /d ""%ROOT%Overlord-Client"" && set CGO_ENABLED=1 && set OVERLORD_SERVER=wss://localhost:5173 && set OVERLORD_AGENT_TOKEN=dev-token-insecure-local-only && set OVERLORD_TLS_INSECURE_SKIP_VERIFY=true && set OVERLORD_MODE=dev && set OVERLORD_CAPTURE_METRICS=true && set GOINSECURE=* && set GOSUMDB=off && set GOPROXY=https://proxy.golang.org,direct && go mod tidy && go run ./cmd/agent"