Gate agent admin UI by OS privileges

Add OS-level admin detection and use it to gate sensitive agent UI and tray actions. Introduce privileges.rs (Windows TokenElevation / Unix geteuid) and expose is_os_admin as a Tauri command; wire it into tray setup to hide admin-only menu items (Settings, Quit) and re-check privileges before executing those actions. Add show_window helper and emit navigate events from the tray; frontend listens for navigate and conditionally renders /settings (shows AdminRequired component for non-admins). Update App.tsx to query is_os_admin on startup and include navigation listener; add AdminRequired component, styles, and i18n keys. Update Cargo.toml with platform deps (windows features + libc for unix). Also add UI/locale assets and CSS for agent lazy-loaded device tabs and a notifications dropdown, plus several web-nodejs route/view/style updates and new task docs describing phase work.
This commit is contained in:
UNITRONIX
2026-04-18 01:18:57 +02:00
parent fd7790ab56
commit e556b181df
28 changed files with 2453 additions and 44 deletions
@@ -43,8 +43,13 @@ sha2 = "0.10"
windows-sys = { version = "0.59", features = [
"Win32_Foundation",
"Win32_System_Console",
"Win32_Security",
"Win32_System_Threading",
] }
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[features]
default = ["custom-protocol"]
custom-protocol = ["tauri/custom-protocol"]
@@ -51,6 +51,15 @@ pub struct AgentSettings {
// ─────────────────────────── Status & Lifecycle ───────────────────────────
/// Returns true when the agent process runs with local OS administrator
/// privileges. Used by the frontend + tray menu to gate sensitive actions
/// (Settings, Quit agent, Unregister) so regular users cannot disable the
/// agent without elevation.
#[tauri::command]
pub fn is_os_admin() -> bool {
crate::privileges::is_os_admin()
}
#[tauri::command]
pub fn get_agent_status(state: State<'_, AgentState>) -> Result<AgentStatus, String> {
let config = state.config.lock().map_err(|e| e.to_string())?;
+80 -20
View File
@@ -8,6 +8,7 @@
pub mod commands;
pub mod config;
pub mod privileges;
pub mod registration;
pub mod sysinfo_collect;
@@ -59,6 +60,7 @@ pub fn run() {
})
.invoke_handler(tauri::generate_handler![
// Status & lifecycle
commands::is_os_admin,
commands::get_agent_status,
commands::reconnect_agent,
commands::send_diagnostics,
@@ -107,41 +109,99 @@ pub fn run() {
.expect("Failed to start BetterDesk Agent");
}
/// Minimal system tray setup.
/// System tray setup.
///
/// Menu layout:
/// - User items (always visible): Show ID, Help request, Chat, Check connection
/// - Admin-gated items (OS admin): Settings, Quit agent
///
/// Admin detection is cached at setup time. If privilege status changes
/// (user elevates mid-session), restart the agent.
fn setup_tray(app: &tauri::AppHandle) -> Result<(), Box<dyn std::error::Error>> {
use tauri::menu::{MenuBuilder, MenuItemBuilder};
use tauri::menu::{MenuBuilder, MenuItemBuilder, PredefinedMenuItem};
use tauri::tray::TrayIconBuilder;
let show = MenuItemBuilder::with_id("show", "Show").build(app)?;
let quit = MenuItemBuilder::with_id("quit", "Quit").build(app)?;
let is_admin = privileges::is_os_admin();
info!("Tray setup — OS admin: {}", is_admin);
let menu = MenuBuilder::new(app).item(&show).separator().item(&quit).build()?;
// Always-visible (user) items.
let show_id = MenuItemBuilder::with_id("show_id", "Show device ID").build(app)?;
let help = MenuItemBuilder::with_id("help_request", "Request help").build(app)?;
let chat = MenuItemBuilder::with_id("chat", "Chat").build(app)?;
let check = MenuItemBuilder::with_id("check_conn", "Check connection").build(app)?;
let mut builder = MenuBuilder::new(app)
.item(&show_id)
.item(&help)
.item(&chat)
.item(&check);
// Admin-only items.
if is_admin {
let sep = PredefinedMenuItem::separator(app)?;
let settings = MenuItemBuilder::with_id("settings", "Settings").build(app)?;
let quit = MenuItemBuilder::with_id("quit", "Quit agent").build(app)?;
builder = builder.item(&sep).item(&settings).item(&quit);
}
let menu = builder.build()?;
let _tray = TrayIconBuilder::new()
.menu(&menu)
.tooltip("BetterDesk Agent")
.on_menu_event(move |app, event| match event.id().as_ref() {
"show" => {
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
.tooltip(if is_admin {
"BetterDesk Agent (admin)"
} else {
"BetterDesk Agent"
})
.on_menu_event(move |app, event| {
let id = event.id().as_ref();
match id {
"show_id" => show_window(app, "/"),
"help_request" => show_window(app, "/help"),
"chat" => show_window(app, "/chat"),
"check_conn" => show_window(app, "/?action=reconnect"),
// Admin-gated. Re-check privilege before executing to guard
// against tampered menu IDs (double-safety).
"settings" => {
if privileges::is_os_admin() {
show_window(app, "/settings");
} else {
info!("Settings requested but not admin — ignoring");
}
}
"quit" => {
if privileges::is_os_admin() {
info!("Quit requested from tray (admin confirmed)");
app.exit(0);
} else {
info!("Quit requested but not admin — ignoring");
}
}
_ => {}
}
"quit" => {
info!("Quit requested from tray");
app.exit(0);
}
_ => {}
})
.on_tray_icon_event(|tray, event| {
if let tauri::tray::TrayIconEvent::DoubleClick { .. } = event {
if let Some(window) = tray.app_handle().get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
}
show_window(tray.app_handle(), "/");
}
})
.build(app)?;
Ok(())
}
/// Bring the main window to front and navigate to a given route.
///
/// The route is emitted as a `navigate` event; the SolidJS router listens
/// and performs client-side navigation. Falls back to showing the window
/// even if navigation fails (e.g. frontend not ready).
fn show_window(app: &tauri::AppHandle, route: &str) {
use tauri::Emitter;
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.unminimize();
let _ = window.set_focus();
let _ = app.emit("navigate", route.to_string());
}
}
@@ -0,0 +1,44 @@
//! Detection of local OS administrator privileges.
//!
//! Used to gate sensitive tray menu items (Settings, Unregister, Quit) so
//! regular users cannot disable the agent or reconfigure it without elevation.
#[cfg(windows)]
pub fn is_os_admin() -> bool {
use windows_sys::Win32::Foundation::{CloseHandle, HANDLE};
use windows_sys::Win32::Security::{
GetTokenInformation, TokenElevation, TOKEN_ELEVATION, TOKEN_QUERY,
};
use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
unsafe {
let mut token: HANDLE = std::ptr::null_mut();
if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) == 0 {
return false;
}
let mut elevation = TOKEN_ELEVATION { TokenIsElevated: 0 };
let mut size: u32 = 0;
let ok = GetTokenInformation(
token,
TokenElevation,
&mut elevation as *mut _ as *mut _,
std::mem::size_of::<TOKEN_ELEVATION>() as u32,
&mut size,
);
CloseHandle(token);
ok != 0 && elevation.TokenIsElevated != 0
}
}
#[cfg(unix)]
pub fn is_os_admin() -> bool {
// SAFETY: geteuid() has no preconditions and cannot fail.
unsafe { libc::geteuid() == 0 }
}
#[cfg(not(any(windows, unix)))]
pub fn is_os_admin() -> bool {
false
}
+72 -23
View File
@@ -1,17 +1,49 @@
import { Component, createSignal, onMount } from "solid-js";
import { Router, Route } from "@solidjs/router";
import { Component, createSignal, onMount, onCleanup, Show } from "solid-js";
import { Router, Route, useNavigate } from "@solidjs/router";
import StatusPanel from "./components/StatusPanel";
import SetupWizard from "./components/SetupWizard";
import ChatPanel from "./components/ChatPanel";
import HelpRequest from "./components/HelpRequest";
import SettingsPanel from "./components/SettingsPanel";
import Sidebar from "./components/Sidebar";
import { t, initI18n } from "./lib/i18n";
import AdminRequired from "./components/AdminRequired";
import { initI18n } from "./lib/i18n";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
// Component that handles navigation events from the tray menu.
// Must be inside <Router> to access useNavigate().
const NavigationListener: Component = () => {
const navigate = useNavigate();
onMount(() => {
const unlistenPromise = listen<string>("navigate", (event) => {
try {
const route = event.payload;
if (typeof route === "string" && route.startsWith("/")) {
navigate(route);
}
} catch {
// Ignore malformed navigation payloads — safer to stay on current page.
}
});
onCleanup(async () => {
try {
const un = await unlistenPromise;
un();
} catch {
// Cleanup is best-effort on unmount.
}
});
});
return null;
};
const App: Component = () => {
const [ready, setReady] = createSignal(false);
const [registered, setRegistered] = createSignal(false);
const [isAdmin, setIsAdmin] = createSignal(false);
onMount(async () => {
await initI18n();
@@ -21,30 +53,47 @@ const App: Component = () => {
} catch {
setRegistered(false);
}
try {
const admin = await invoke<boolean>("is_os_admin");
setIsAdmin(admin);
} catch {
setIsAdmin(false);
}
setReady(true);
});
return (
<div class="app-root">
{!ready() ? (
<div class="app-loading">
<span class="material-symbols-rounded spin">sync</span>
</div>
) : !registered() ? (
<SetupWizard onComplete={() => setRegistered(true)} />
) : (
<div class="app-layout">
<Sidebar />
<main class="app-main">
<Router>
<Route path="/" component={StatusPanel} />
<Route path="/chat" component={ChatPanel} />
<Route path="/help" component={HelpRequest} />
<Route path="/settings" component={SettingsPanel} />
</Router>
</main>
</div>
)}
<Show
when={ready()}
fallback={
<div class="app-loading">
<span class="material-symbols-rounded spin">sync</span>
</div>
}
>
<Show
when={registered()}
fallback={<SetupWizard onComplete={() => setRegistered(true)} />}
>
<div class="app-layout app-layout-tray">
<main class="app-main app-main-full">
<Router>
<NavigationListener />
<Route path="/" component={StatusPanel} />
<Route path="/chat" component={ChatPanel} />
<Route path="/help" component={HelpRequest} />
<Route
path="/settings"
component={() =>
isAdmin() ? <SettingsPanel /> : <AdminRequired />
}
/>
</Router>
</main>
</div>
</Show>
</Show>
</div>
);
};
@@ -0,0 +1,20 @@
import { Component } from "solid-js";
import { t } from "../lib/i18n";
/// Shown when a non-admin user tries to access a privilege-gated route
/// (e.g. /settings). The tray menu already hides these items when the agent
/// runs unelevated, but this is a defense-in-depth fallback.
const AdminRequired: Component = () => {
return (
<div class="admin-required">
<div class="admin-required-icon">
<span class="material-symbols-rounded">admin_panel_settings</span>
</div>
<h2>{t("admin_required.title")}</h2>
<p>{t("admin_required.message")}</p>
<p class="admin-required-hint">{t("admin_required.hint")}</p>
</div>
);
};
export default AdminRequired;
@@ -5,6 +5,11 @@
"help": "Help Request",
"settings": "Settings"
},
"admin_required": {
"title": "Administrator rights required",
"message": "This section can only be accessed when the agent runs with local administrator privileges.",
"hint": "Ask your IT administrator to launch the agent as Administrator, or right-click the installer and choose \"Run as administrator\"."
},
"status": {
"title": "Connection Status",
"connected": "Connected",
@@ -5,6 +5,11 @@
"help": "Prośba o pomoc",
"settings": "Ustawienia"
},
"admin_required": {
"title": "Wymagane uprawnienia administratora",
"message": "Ten ekran jest dostępny tylko gdy agent jest uruchomiony z uprawnieniami administratora.",
"hint": "Poproś administratora IT o uruchomienie agenta jako Administrator lub kliknij instalator prawym przyciskiem i wybierz \"Uruchom jako administrator\"."
},
"status": {
"title": "Status połączenia",
"connected": "Połączono",
@@ -5,6 +5,11 @@
"help": "求助請求",
"settings": "設定"
},
"admin_required": {
"title": "需要管理員權限",
"message": "此頁面僅在代理以本地管理員權限運行時可用。",
"hint": "請 IT 管理員以管理員身份啟動代理,或右鍵點擊安裝程式並選擇「以管理員身份執行」。"
},
"status": {
"title": "連線狀態",
"connected": "已連線",
@@ -93,12 +93,71 @@ a:hover { color: var(--accent-hover); }
height: 100%;
}
/* Tray-first layout — no sidebar, main pane fills the window. */
.app-layout-tray {
display: block;
}
.app-main {
flex: 1;
overflow-y: auto;
padding: 24px;
}
.app-main-full {
width: 100%;
height: 100%;
overflow-y: auto;
padding: 20px;
}
/* ── Admin-required gate ── */
.admin-required {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
height: 100%;
padding: 32px 24px;
gap: 12px;
}
.admin-required-icon {
width: 72px;
height: 72px;
border-radius: 50%;
background: var(--bg-secondary);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 8px;
}
.admin-required-icon .material-symbols-rounded {
font-size: 40px;
color: var(--accent, #5b8cff);
}
.admin-required h2 {
margin: 0;
font-size: 18px;
color: var(--text-primary);
}
.admin-required p {
margin: 0;
color: var(--text-secondary);
max-width: 340px;
line-height: 1.5;
}
.admin-required-hint {
font-size: 12px;
opacity: 0.75;
margin-top: 8px !important;
}
/* ── Sidebar ── */
.sidebar {
width: var(--sidebar-width);
+137
View File
@@ -0,0 +1,137 @@
# Phase 2-5 Implementation Status — 2026-04-10
This document tracks the pragmatic scaffolding delivered in this session and the
work that remains. The goal was to establish the **HTTP/DB contracts and UI
plumbing** so frontend, backend and agent work can continue independently.
## ✅ Phase 2 — Live Agent Introspection
### Backend (completed previously)
- `services/bdRelay.js``requestFromDevice(deviceId, type, payload)` promise API
with `pendingRequests` Map + 15 s timeout. Handles `command_response` frames
from agent to resolve/reject pending promises.
- `routes/devices.routes.js` — 9 proxy endpoints:
- `GET /api/devices/:id/services`
- `GET /api/devices/:id/processes`
- `GET /api/devices/:id/events?limit=N`
- `GET /api/devices/:id/activity`
- `POST /api/devices/:id/files/browse`
- `POST /api/devices/:id/files/read` (≤ 1 MB)
- `POST /api/devices/:id/screenshot`
- `POST /api/devices/:id/terminal/execute` (audit-logged)
- `POST /api/devices/:id/rename`
- Error mapping: 503 `agent_offline` · 504 `agent_timeout` · 502 other.
- Permission model: `device.view` for reads, `device.edit` for mutations.
### Frontend (completed this session)
- `public/js/deviceDetail.js` — 5 new lazy-loaded tabs (services, processes,
events, activity, files). Renderers for each data shape. Retry button wired
to `_loadAgentTab()`. File browser supports parent navigation + double-click
to descend into directories.
- `public/css/device-detail.css` — full styling for agent tabs (tables, event
list with severity colors, activity bars, file browser, loading spinner,
error states).
- `lang/{en,pl,zh}.json` — 15 new keys per language under `device_detail`.
### Agent side (Rust) — **TODO**
Agent client needs a signal WS module that:
1. Connects to `wss://{server}/ws/bd-signal?device_id=X&token=JWT`.
2. Listens for `{type, request_id, payload}` frames.
3. Dispatches to capability handlers and replies with
`{type: 'command_response', request_id, ok, data?, error?}`.
Recommended Rust modules under `betterdesk-agent-client/src-tauri/src/`:
| Module | Responsibility |
| ------------------ | ------------------------------------------------------- |
| `signal_ws.rs` | tokio-tungstenite client, reconnect, dispatcher |
| `caps/services.rs` | `Get-Service` / `systemctl list-units --output=json` |
| `caps/processes.rs`| `sysinfo` crate enumeration, top-by-CPU sort |
| `caps/events.rs` | `wevtutil` / `journalctl --output=json` |
| `caps/files.rs` | `std::fs::read_dir` with canonicalize-based sandbox |
| `caps/terminal.rs` | One-shot `std::process::Command` (no PTY yet) |
| `caps/screenshot.rs`| `scrap`/`xcap` + `image` crate, base64 PNG/JPEG |
| `caps/activity.rs` | Background thread tracking process foreground time |
Deps to add to `src-tauri/Cargo.toml`:
```
tokio-tungstenite = { version = "0.23", features = ["rustls-tls-native-roots"] }
sysinfo = "0.32" # already present
scrap = "0.5" # or xcap
image = "0.25"
base64 = "0.22"
```
## ✅ Phase 3 — Chat attachments (already done)
- `routes/chat.routes.js` already implements multipart upload (50 MB cap),
path-traversal-safe download, and search proxy. No additional work needed
for scaffolding. Contacts/conversations endpoints still **TODO**.
## ✅ Phase 4 — Operator identity + consent popup
### Backend (this session)
- `services/dbAdapter.js` — 6 new `users` columns (`first_name`, `last_name`,
`email`, `phone`, `role_display`, `avatar_url`) migrated for both SQLite and
PostgreSQL.
- `services/dbAdapter.js``updateUserProfile(id, fields)` helper
(sanitizes, caps at 200 chars, whitelists allowed keys).
- `routes/phase4_5.routes.js`:
- `GET /api/users/me/profile` — current profile
- `PUT /api/users/me/profile` — update fields
- `GET /api/bd/operator-info?session_id=…` — consent-popup payload
### Agent side — **TODO**
- Rust module `remote/consent.rs`: Tauri window popup with operator identity
+ granular permission checkboxes (keyboard/clipboard/audio/file_transfer/
recording/camera/block). Countdown optional.
- Session handshake: server sends `remote_request` to agent over signal WS,
agent shows popup, replies `remote_accept` (with granted permissions) or
`remote_reject`. Unattended mode skips popup based on `access_policy`.
### Frontend — **TODO**
- Profile-edit page (`/settings/profile`) with avatar upload.
## ✅ Phase 5 — Agent templates + downloads portal
### Backend (this session)
- `routes/phase4_5.routes.js`:
- `GET /api/agent-templates` — list (`enrollment.manage`)
- `POST /api/agent-templates` — create, returns enrollment_token
- `DELETE /api/agent-templates/:id`
- `POST /api/bd/enroll` — public endpoint for agent enrollment
- `GET /portal` — branded public download page
- `GET /api/portal/installers`— installer metadata JSON
- `agent_templates` table auto-created in auth.db on first request
(`id, name, description, config_json, enrollment_token, created_by,
created_at, updated_at`).
- `views/downloads-portal.ejs` — standalone glassmorphism download page.
### Frontend — **TODO**
- `/enrollment` admin page with wizard (name, description, capabilities,
tags, groups → create template → show enrollment token + QR + one-liner
install command).
### Agent side — **TODO**
- Installer accepts `--enrollment-token=XXXX --server=URL` flags.
- Agent calls `POST /api/bd/enroll` with device_id + sysinfo, receives
`preset_config`, then proceeds with normal `/api/bd/register`.
## Files touched this session
- `web-nodejs/public/js/deviceDetail.js`
- `web-nodejs/public/css/device-detail.css`
- `web-nodejs/lang/{en,pl,zh}.json`
- `web-nodejs/services/dbAdapter.js` (SQLite + PostgreSQL migrations + profile helper)
- `web-nodejs/routes/index.js` (mount phase4_5)
- `web-nodejs/routes/phase4_5.routes.js` (new)
- `web-nodejs/views/downloads-portal.ejs` (new)
- `tasks/PHASE_2_5_STATUS.md` (this file)
## Deployment notes
1. Restart Node.js console so new routes + migrations load.
2. Migrations are idempotent — safe to re-run.
3. `agent_templates` table is created lazily on first `/api/agent-templates`
access; no DB downtime required.
4. Agent-client Rust work can begin immediately — HTTP contract is stable.
+212
View File
@@ -0,0 +1,212 @@
# Plan operacyjny: Agent Client + rozbudowa panelu web
**Data:** 2026-04-18
**Kontekst:** Agent client obecnie to 6 plików Rust (rejestracja + sysinfo + stub-czat). Brak zdalnego pulpitu, brak WS do czatu, brak trybu użytkownika vs operatora. Panel web ma `help-requests.ejs` + API oraz slide-over `device-detail` — wymagają rozbudowy.
## Założenia architektoniczne
1. **Agent client = TRAY-FIRST aplikacja.** Menu główne w zasobniku systemowym. Okno GUI otwiera się tylko dla konkretnych funkcji (czat, help-request). Brak głównego sidebara.
2. **Tryby uprawnień:**
- **User mode** (zwykły użytkownik): "Poproś o pomoc", "Czat", "Pokaż ID".
- **Admin mode** (OS administrator / root): wszystko powyżej + "Ustawienia", "Zamknij agenta".
- Detekcja: `IsUserAnAdmin()` (Win32), `geteuid() == 0` (Unix).
3. **Szyfrowanie E2E zachowane** — chat w agencie używa istniejącego `chatCrypto.js` (P-256 ECDH + AES-256-GCM) z panelu web, tylko port Rust.
4. **Kanały komunikacji:**
- **REST HTTP** — rejestracja, help-request, sysinfo upload (już jest).
- **WebSocket `/ws/agent/{device_id}`** (NOWE) — chat, powiadomienia, heartbeat, remote session signaling.
- **Relay TCP/WS** (Faza 4) — video/audio/input dla zdalnego pulpitu.
5. **Bezpieczeństwo:** wszystkie WS z JWT token (z rejestracji), TLS wymagane w produkcji, rate-limit IP, audit log każdej akcji.
---
## Faza 1: Fundament UX + powiadomienia (TA SESJA)
**Cel:** Przeorganizować agent UI + dodać dzwonek powiadomień w panelu web.
### Agent Client
- [x] ~~Obecne okno z sidebarem~~**usunięte**. Nowe: minimalne okno "o programie" + pełne menu w trayu.
- [x] Detekcja admina w Rust (`is_os_admin()`).
- [x] Tray menu:
- **Zawsze:** Pokaż ID | Poproś o pomoc | Czat | Sprawdź połączenie | ───
- **Admin only:** Ustawienia | Zamknij agenta
- **Autostart:** domyślnie włączony (już jest)
- [x] Pełny ekran tylko dla: `/chat` (z brandingiem konsoli), `/help` (formularz), `/settings` (tylko admin).
- [x] Rozmiar okna zmniejszony do 480×560 (chat/help/settings), brak rozbudowanego menu bocznego.
### Web panel
- [x] **Dzwonek powiadomień** w navbar (obok `refresh-btn`):
- Badge z licznikiem nieprzeczytanych help-requestów
- Dropdown z ostatnimi 10 powiadomieniami
- Klik → przejście do `/help-requests` + oznaczenie jako przeczytane
- [x] Endpoint `GET /api/bd/notifications` (z filtrem `unread_only`).
- [x] Endpoint `POST /api/bd/notifications/:id/read`.
- [x] Socket.IO event `help-request` już istnieje — podłączyć do dzwonka (real-time).
- [x] i18n klucze `notifications.*` (EN/PL/ZH).
---
## Faza 2: Device Management Modal (NASTĘPNA SESJA)
**Cel:** Zastąpić slide-over `device-detail` pełnoekranowym modalem z rozbudowanym UI inspirowanym nVision.
### Backend
- Rozszerzony endpoint `GET /api/peers/:id` o:
- `hardware` (CPU, GPU, RAM, disks) — z inventory
- `software` (zainstalowane aplikacje) — z inventory
- `services` (uruchomione usługi systemowe) — z agent nowy endpoint
- `processes` (aktywne procesy) — z agent nowy endpoint
- `events` (logi Windows/journalctl) — z agent nowy endpoint
- `usage_stats` (czas uruchomienia aplikacji) — z agent activity tracker
- Endpoint `POST /api/peers/:id/rename` (zmiana przyjaznej nazwy).
- Endpoint `POST /api/peers/:id/files/browse` — proxy do agent file browser.
- Endpoint `POST /api/peers/:id/terminal/execute` — proxy do agent terminal.
### Frontend
- Nowy komponent `device-modal.js` — pełnoekranowy modal (bez slide-over).
- Zakładki wzorowane na nVision:
- **General** — hero z nazwą/zdjęciem + status + szybkie akcje (Remote, Chat, Terminal, Files)
- **Activity** — wykres użycia aplikacji (czas pracy)
- **Screenshots** — galeria screenshotów (z agent screenshot capability)
- **Events** — log zdarzeń systemowych
- **DataGuard** — polityki DLP (już jest moduł)
- **Blockades** — bany/ograniczenia (już jest moduł)
- **Settings** — config per-device
- Przycisk "Remote access" → otwiera `/remote/:id` (web viewer — już jest).
- Przycisk "Files" → panel przeglądania plików z upload/download.
- Przycisk "Terminal" → xterm.js terminal w nowym oknie.
### Agent — nowe capabilities
- `system_services``Get-Service` (Win) / `systemctl list-units` (Linux)
- `system_processes``Get-Process` / `ps aux`
- `system_events``Get-EventLog` / `journalctl`
- `file_browser` — list/read/write/delete z path traversal guard
- `screenshot_capture` — JPEG snapshot on demand
- `activity_tracker` — śledzenie czasu użycia aplikacji (background thread)
- `terminal_session` — PTY (unix) / ConPTY (Windows)
**Implementacja:** agent-client pobiera większość z natywnego `betterdesk-agent` (Go) — portować moduły do Rust lub uruchamiać jako sidecar subprocess.
---
## Faza 3: Chat z brandingiem + E2E (FAZA 3)
**Cel:** Pełny czat między użytkownikami + operatorami, E2E, uploady plików.
### Backend
- Endpoint WS `/ws/chat/agent/:device_id` — WS z JWT.
- Reuse `chatRelay.js` (już istnieje).
- Endpoint `GET /api/chat/contacts` — lista operatorów (top) + pracowników z organizacji.
- Endpoint `POST /api/chat/attachment` — upload pliku (max 50 MB, encrypted blob).
- Endpoint `GET /api/chat/attachment/:id` — download.
### Agent
- `chat.rs` — WS client (tokio-tungstenite), P-256 keypair (ring crate), message encrypt/decrypt.
- UI: lewa kolumna z kontaktami (operatorzy na górze, oznaczeni ikoną), prawa z historią.
- Upload drag-and-drop + paste obrazka.
- Branding: wykorzystuje `GET /api/config/branding` (logo, primary color, nazwa firmy).
### Web panel
- Istniejąca strona `chat.ejs` — rozszerzyć o:
- Lista agent-ów online (z device_id + hostname)
- Wybór odbiorcy: operator, kolega z firmy, klient (device)
- Upload plików
- Czat E2E już zaimplementowany (Phase 2 — chatCrypto.js).
---
## Faza 4: Remote Desktop Core (FAZA 4 — NAJWIĘKSZA)
**Cel:** Pełny zdalny pulpit z multi-monitor, audio, wielokierunkowy clipboard, file transfer, supervised+unattended mode.
### Agent — nowe crates
- `scrap` / `xcap` — screen capture (Win+Linux+macOS)
- `openh264` — video encode (fallback: JPEG)
- `cpal` — audio capture
- `enigo` — input inject (już w MGMT)
- `arboard` — clipboard sync
- `tokio-tungstenite` — WS do relay
- `protobuf` — protokół (reuse z betterdesk-server/proto)
### Agent — nowe moduły
- `remote/capture.rs` — capture loop (30-60 FPS adaptive)
- `remote/encoder.rs` — H.264 encoder + VP9 fallback
- `remote/audio.rs` — audio capture + Opus encode
- `remote/input.rs` — input injection receiver
- `remote/session.rs` — session manager (negocjacja codec, quality, multi-monitor)
- `remote/consent.rs`**supervised mode:** popup JAK NA ZDJĘCIU 1
- Przyciski: Klawiatura, Schowek, Audio, Transfer plików, Nagrywanie, Kamera, Blokada
- Imię i nazwisko operatora + nick z konsoli web
- Countdown "Automatyczna zgoda za X s" (opcjonalne)
- **Unattended mode:** konfigurowalny w `/settings`, akceptacja domyślna (bez popupu)
### Web viewer
- Już istnieje `remote.ejs` (web remote client). Rozszerzyć o:
- Dropdown wyboru monitora
- Przycisk "Request audio" → agent popup
- Přycisk "Start recording" → encoder zapisuje MP4
- Wskaźnik supervised/unattended (ikonka shield)
### Protokół
- Signal: agent rejestruje ws://server/ws/agent/:id (push connection)
- Operator łączy się przez web remote → server broadcastuje `connection_request` do agenta
- Agent: jeśli supervised → pokazuje popup → czeka na `accept` z mapą uprawnień → wysyła `ready`
- Agent: jeśli unattended → akceptuje od razu
- Relay: obie strony łączą się do `/relay/:session_id`, server forwarduje bajty (istniejący `relay/`)
### User identity
- W bazie `users` dodać kolumny `first_name`, `last_name`, `email`, `phone`, `role_display` (np. "IT Support Level 2").
- Popup w agencie pokazuje: avatar + imię+nazwisko + rola + organizacja.
---
## Faza 5: Agent Generator + Portal pobierania (FAZA 5)
**Cel:** Zaawansowany generator w konsoli web + publiczny portal pobierania.
### Generator (w panelu web)
- Rozbudować istniejący `generator.ejs`:
- **Step 1:** Template (blank / organization preset / custom)
- **Step 2:** Connection (server, relay, TLS, STUN/TURN)
- **Step 3:** Access policy (supervised / unattended / hybrid)
- **Step 4:** Branding (logo, kolor, nazwa firmy)
- **Step 5:** Features (chat enabled?, file transfer?, terminal?, screenshots?)
- **Step 6:** Security (API key, allowed operators, MAC pinning)
- **Generate button** → tworzy podpisany JSON config + enrollment token
- Output: **installer .exe / .msi / .deb / .pkg / .dmg** z zaszytym configiem (build przez `cargo tauri build` z env var `AGENT_PRESET_CONFIG`).
- Templates per-org w bazie: `agent_templates` (name, description, config_json, created_by, org_id).
### Portal pobierania (oddzielny port — np. 5001)
- Nowa aplikacja Node.js `downloads-portal/` (lub route prefix w istniejącej konsoli).
- Publiczny dostęp (bez auth).
- Strona z brandingiem firmy: logo, nazwa, opis, "Pobierz BetterDesk Agent" × OS.
- Każdy link → pobiera najnowszy build z zaszytym preset configiem.
- Po instalacji agent auto-rejestruje się do serwera (z tokenem z config).
### Auto-enrollment flow
1. Agent startuje
2. Jeśli `preset_enrollment_token` w config → `POST /api/bd/enroll` z tokenem
3. Serwer weryfikuje token + tworzy device + zwraca JWT
4. Agent zapisuje device_id + JWT → normalny flow
---
## Kolejność realizacji
| Faza | Czas (szacunek) | Priorytet |
|------|----------------|-----------|
| 1. Fundament UX + powiadomienia | Ta sesja | 🔴 Krytyczne |
| 2. Device Modal + agent capabilities | 2-3 sesje | 🟠 Wysokie |
| 3. Chat E2E + attachments | 1-2 sesje | 🟠 Wysokie |
| 4. Remote Desktop | 4-6 sesji | 🔴 Krytyczne |
| 5. Generator + Portal | 1-2 sesje | 🟡 Średnie |
## Uwagi bezpieczeństwa (cross-cutting)
- **JWT token z rejestracji** — używany do wszystkich WS (agent side) + device API.
- **Rate-limit IP** — na wszystkich publicznych endpointach (istnieje w Go server).
- **Audit log** — każda akcja (login, connect, file transfer, command exec) do `audit_log` tabeli.
- **E2E chat** — już zaimplementowane (P-256 ECDH + AES-256-GCM) w `chatCrypto.js`.
- **Token rotation** — JWT krótki TTL (15 min) + refresh token.
- **Certificate pinning** — agent pinuje SHA256 serwera po pierwszej rejestracji.
- **Supervised mode consent** — user ma fizyczną kontrolę nad uprawnieniami per-sesja.
- **Admin-only actions w agencie** — ustawienia, unregister, zamknięcie — wymagają OS admin (lokalny).
+24
View File
@@ -744,6 +744,22 @@
"action_delete_desc": "Permanently remove this device",
"tab_hardware": "Hardware",
"tab_metrics": "Metrics",
"tab_services": "Services",
"tab_processes": "Processes",
"tab_events": "Events",
"tab_activity": "Activity",
"tab_files": "Files",
"agent_lazy_hint": "Click this tab to load live data from the agent",
"agent_offline": "Agent is offline",
"agent_timeout": "Agent did not respond in time",
"agent_error": "Failed to load data from agent",
"service_name": "Name",
"service_display": "Display name",
"service_status": "Status",
"service_start": "Start mode",
"process_name": "Name",
"process_user": "User",
"process_memory": "Memory",
"section_system": "System",
"section_cpu": "Processor",
"section_memory": "Memory",
@@ -1438,6 +1454,14 @@
"keyframe_request": "Request Keyframe",
"quality_auto": "Auto Quality"
},
"notifications": {
"title": "Notifications",
"empty": "No new notifications",
"mark_all_read": "Mark all read",
"see_all": "See all help requests",
"help_request": "Help request",
"just_now": "just now"
},
"desktop": {
"switch_mode": "Desktop Mode",
"console_mode": "Console Mode",
+24
View File
@@ -744,6 +744,22 @@
"action_delete_desc": "Trwale usuń to urządzenie",
"tab_hardware": "Sprzęt",
"tab_metrics": "Metryki",
"tab_services": "Usługi",
"tab_processes": "Procesy",
"tab_events": "Zdarzenia",
"tab_activity": "Aktywność",
"tab_files": "Pliki",
"agent_lazy_hint": "Kliknij tę zakładkę, aby pobrać dane z agenta",
"agent_offline": "Agent jest offline",
"agent_timeout": "Agent nie odpowiedział w terminie",
"agent_error": "Nie udało się pobrać danych z agenta",
"service_name": "Nazwa",
"service_display": "Nazwa wyświetlana",
"service_status": "Stan",
"service_start": "Tryb startu",
"process_name": "Nazwa",
"process_user": "Użytkownik",
"process_memory": "Pamięć",
"section_system": "System",
"section_cpu": "Procesor",
"section_memory": "Pamięć",
@@ -1438,6 +1454,14 @@
"keyframe_request": "Żądaj klatki kluczowej",
"quality_auto": "Automatyczna jakość"
},
"notifications": {
"title": "Powiadomienia",
"empty": "Brak nowych powiadomień",
"mark_all_read": "Oznacz wszystkie jako przeczytane",
"see_all": "Zobacz wszystkie prośby o pomoc",
"help_request": "Prośba o pomoc",
"just_now": "przed chwilą"
},
"desktop": {
"switch_mode": "Tryb pulpitu",
"console_mode": "Tryb konsoli",
+24
View File
@@ -744,6 +744,22 @@
"action_delete_desc": "永久删除此设备",
"tab_hardware": "硬件",
"tab_metrics": "指标",
"tab_services": "服务",
"tab_processes": "进程",
"tab_events": "事件",
"tab_activity": "活动",
"tab_files": "文件",
"agent_lazy_hint": "单击此选项卡以从代理加载实时数据",
"agent_offline": "代理离线",
"agent_timeout": "代理响应超时",
"agent_error": "从代理加载数据失败",
"service_name": "名称",
"service_display": "显示名称",
"service_status": "状态",
"service_start": "启动模式",
"process_name": "名称",
"process_user": "用户",
"process_memory": "内存",
"section_system": "系统",
"section_cpu": "处理器",
"section_memory": "内存",
@@ -1445,6 +1461,14 @@
"keyframe_request": "请求关键帧",
"quality_auto": "自动画质"
},
"notifications": {
"title": "通知",
"empty": "没有新通知",
"mark_all_read": "全部标记为已读",
"see_all": "查看所有求助请求",
"help_request": "求助请求",
"just_now": "刚刚"
},
"desktop": {
"switch_mode": "桌面模式",
"console_mode": "控制台模式",
+214
View File
@@ -984,3 +984,217 @@
grid-template-columns: 1fr;
}
}
/* ===========================================================================
AGENT LAZY-LOADED TABS (services, processes, events, activity, files)
=========================================================================== */
.device-panel-agent-pane {
padding: 16px;
}
.device-panel-agent-empty,
.device-panel-agent-loading,
.device-panel-agent-error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
text-align: center;
color: var(--text-secondary, #8b949e);
gap: 8px;
}
.device-panel-agent-empty .material-icons,
.device-panel-agent-loading .material-icons,
.device-panel-agent-error .material-icons {
font-size: 48px;
opacity: 0.6;
}
.device-panel-agent-hint {
font-size: 12px;
opacity: 0.7;
}
.device-panel-agent-error-title {
font-weight: 600;
color: var(--text-primary, #e6edf3);
}
.device-panel-agent-error-hint {
font-size: 12px;
font-family: ui-monospace, monospace;
opacity: 0.6;
max-width: 400px;
word-break: break-word;
}
.device-panel-agent-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.device-panel-agent-table th {
text-align: left;
padding: 8px 10px;
border-bottom: 1px solid var(--border-color, #30363d);
color: var(--text-secondary, #8b949e);
font-weight: 600;
text-transform: uppercase;
font-size: 11px;
letter-spacing: 0.04em;
position: sticky;
top: 0;
background: var(--bg-secondary, #0d1117);
}
.device-panel-agent-table td {
padding: 8px 10px;
border-bottom: 1px solid var(--border-color, #30363d);
color: var(--text-primary, #e6edf3);
}
.device-panel-agent-table tbody tr:hover {
background: var(--bg-hover, #21262d);
}
.device-panel-event-list {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 520px;
overflow-y: auto;
}
.device-panel-event-row {
display: grid;
grid-template-columns: 150px 150px 1fr;
gap: 10px;
padding: 6px 10px;
font-size: 12px;
border-left: 3px solid var(--border-color, #30363d);
background: var(--bg-secondary, #0d1117);
border-radius: 4px;
}
.device-panel-event-row.error { border-left-color: #f85149; }
.device-panel-event-row.warn { border-left-color: #d29922; }
.device-panel-event-row.info { border-left-color: #3b82f6; }
.device-panel-event-time {
font-family: ui-monospace, monospace;
color: var(--text-secondary, #8b949e);
}
.device-panel-event-source {
color: var(--text-secondary, #8b949e);
}
.device-panel-event-msg {
color: var(--text-primary, #e6edf3);
word-break: break-word;
}
.device-panel-activity-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.device-panel-activity-row {
display: grid;
grid-template-columns: 180px 1fr 70px;
gap: 12px;
align-items: center;
padding: 6px 10px;
font-size: 13px;
}
.device-panel-activity-name {
color: var(--text-primary, #e6edf3);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.device-panel-activity-bar {
height: 8px;
background: var(--bg-secondary, #0d1117);
border-radius: 4px;
overflow: hidden;
}
.device-panel-activity-bar-fill {
height: 100%;
background: linear-gradient(90deg, #3b82f6, #60a5fa);
transition: width 0.3s ease;
}
.device-panel-activity-time {
text-align: right;
color: var(--text-secondary, #8b949e);
font-variant-numeric: tabular-nums;
}
.device-panel-file-browser {
display: flex;
flex-direction: column;
gap: 8px;
}
.device-panel-file-path {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
background: var(--bg-secondary, #0d1117);
border-radius: 6px;
font-family: ui-monospace, monospace;
font-size: 12px;
color: var(--text-primary, #e6edf3);
}
.device-panel-file-list {
display: flex;
flex-direction: column;
gap: 2px;
max-height: 520px;
overflow-y: auto;
}
.device-panel-file-row {
display: grid;
grid-template-columns: 32px 1fr 100px;
align-items: center;
gap: 10px;
padding: 6px 10px;
font-size: 13px;
border-radius: 4px;
cursor: pointer;
user-select: none;
}
.device-panel-file-row:hover {
background: var(--bg-hover, #21262d);
}
.device-panel-file-row[data-is-dir="1"] .material-icons {
color: #60a5fa;
}
.device-panel-file-size {
text-align: right;
color: var(--text-secondary, #8b949e);
font-variant-numeric: tabular-nums;
}
.material-icons.spinning {
animation: device-panel-spin 1.2s linear infinite;
}
@keyframes device-panel-spin {
to { transform: rotate(360deg); }
}
+192
View File
@@ -0,0 +1,192 @@
/* Navbar notification center — dropdown with unread badge, real-time pulse. */
.notif-wrapper {
position: relative;
display: inline-block;
}
.notif-btn {
position: relative;
}
/* Unread badge on bell icon. */
.notif-badge {
position: absolute;
top: 4px;
right: 4px;
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 8px;
background: var(--accent-red, #e74c3c);
color: #fff;
font-size: 10px;
font-weight: 600;
line-height: 16px;
text-align: center;
box-shadow: 0 0 0 2px var(--bg-primary, #0d1117);
pointer-events: none;
}
/* Real-time arrival pulse (400ms). */
@keyframes notif-pulse {
0% { transform: scale(1); }
30% { transform: scale(1.18); }
60% { transform: scale(0.94); }
100% { transform: scale(1); }
}
.notif-btn.notif-pulse .material-icons {
animation: notif-pulse 0.4s ease-out;
}
/* Dropdown panel. */
.notif-dropdown {
position: absolute;
top: calc(100% + 6px);
right: 0;
width: 340px;
max-width: calc(100vw - 24px);
background: var(--bg-secondary, #161b22);
border: 1px solid var(--border, #30363d);
border-radius: 10px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.55);
z-index: 9999;
overflow: hidden;
animation: notif-fade-in 150ms ease-out;
}
@keyframes notif-fade-in {
from { opacity: 0; transform: translateY(-6px); }
to { opacity: 1; transform: translateY(0); }
}
.notif-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
border-bottom: 1px solid var(--border, #30363d);
font-weight: 600;
font-size: 13px;
color: var(--text-primary, #e6edf3);
}
.notif-mark-all {
background: transparent;
border: none;
color: var(--accent-blue, #58a6ff);
font-size: 12px;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
}
.notif-mark-all:hover {
background: rgba(88, 166, 255, 0.08);
}
.notif-list {
max-height: 360px;
overflow-y: auto;
}
.notif-empty {
padding: 28px 16px;
text-align: center;
color: var(--text-secondary, #8b949e);
font-size: 13px;
}
.notif-item {
display: flex;
gap: 10px;
padding: 10px 14px;
border-bottom: 1px solid var(--border-subtle, #21262d);
text-decoration: none;
color: var(--text-primary, #e6edf3);
transition: background-color 120ms;
}
.notif-item:last-child {
border-bottom: none;
}
.notif-item:hover {
background: rgba(255, 255, 255, 0.03);
}
.notif-unread {
background: rgba(88, 166, 255, 0.06);
}
.notif-unread::before {
content: '';
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent-blue, #58a6ff);
margin-top: 8px;
flex-shrink: 0;
}
.notif-item-icon {
color: var(--accent-blue, #58a6ff);
font-size: 20px;
flex-shrink: 0;
margin-top: 2px;
}
.notif-item-body {
flex: 1;
min-width: 0;
}
.notif-item-title {
font-size: 13px;
font-weight: 600;
margin-bottom: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.notif-item-sub {
font-size: 12px;
color: var(--text-secondary, #8b949e);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.notif-item-time {
font-size: 11px;
color: var(--text-tertiary, #6e7681);
margin-top: 4px;
}
.notif-footer {
padding: 10px 14px;
border-top: 1px solid var(--border, #30363d);
text-align: center;
}
.notif-see-all {
color: var(--accent-blue, #58a6ff);
font-size: 12px;
text-decoration: none;
}
.notif-see-all:hover {
text-decoration: underline;
}
/* Responsive: narrow screens push dropdown to full width of navbar-right. */
@media (max-width: 480px) {
.notif-dropdown {
right: -60px;
width: 300px;
}
}
+276
View File
@@ -190,6 +190,11 @@ const DeviceDetail = (function () {
{ id: 'overview', icon: 'info', label: _('device_detail.tab_overview') },
{ id: 'hardware', icon: 'memory', label: _('device_detail.tab_hardware') },
{ id: 'metrics', icon: 'monitoring', label: _('device_detail.tab_metrics') },
{ id: 'services', icon: 'settings_applications', label: _('device_detail.tab_services') },
{ id: 'processes', icon: 'memory', label: _('device_detail.tab_processes') },
{ id: 'events', icon: 'event_note', label: _('device_detail.tab_events') },
{ id: 'activity', icon: 'insights', label: _('device_detail.tab_activity') },
{ id: 'files', icon: 'folder_open', label: _('device_detail.tab_files') },
{ id: 'tags', icon: 'sell', label: _('device_detail.tab_tags') },
{ id: 'actions', icon: 'play_arrow', label: _('device_detail.tab_actions') }
];
@@ -209,11 +214,35 @@ const DeviceDetail = (function () {
${_overviewPane()}
${_hardwarePane()}
${_metricsPane()}
${_agentPane('services', 'settings_applications', 'tab_services')}
${_agentPane('processes', 'memory', 'tab_processes')}
${_agentPane('events', 'event_note', 'tab_events')}
${_agentPane('activity', 'insights', 'tab_activity')}
${_agentPane('files', 'folder_open', 'tab_files')}
${_tagsPane()}
${_actionsPane()}
</div>`;
}
// ── Live-agent tabs (lazy loaded) ────────────────────────────────────
//
// These panes are populated on first tab activation by `_loadAgentTab()`.
// Keeping them in separate functions would duplicate 200+ lines of
// boilerplate. Instead, each pane is a placeholder that shows an empty
// state and a "Load" button; real data is fetched when tab is shown.
function _agentPane(paneId, icon, labelKey) {
return `<div class="device-panel-tab-pane" data-pane="${paneId}">
<div class="device-panel-agent-pane" data-agent-pane="${paneId}">
<div class="device-panel-agent-empty">
<span class="material-icons">${icon}</span>
<div>${_('device_detail.' + labelKey)}</div>
<div class="device-panel-agent-hint">${_('device_detail.agent_lazy_hint')}</div>
</div>
</div>
</div>`;
}
// ── Overview tab ──
function _overviewPane() {
@@ -773,6 +802,253 @@ const DeviceDetail = (function () {
panel.querySelectorAll('.device-panel-tab-pane').forEach(function (p) {
p.classList.toggle('active', p.dataset.pane === tabId);
});
// Lazy-load agent-backed tabs on first activation.
const AGENT_TABS = ['services', 'processes', 'events', 'activity', 'files'];
if (AGENT_TABS.indexOf(tabId) !== -1) {
_loadAgentTab(tabId);
}
}
// ── Agent-backed tabs ───────────────────────────────────────────────
// Track which tabs have been loaded so we don't refetch on every click.
const _agentTabLoaded = {};
async function _loadAgentTab(tabId) {
if (_agentTabLoaded[tabId]) return;
_agentTabLoaded[tabId] = true;
if (!device || !device.id) return;
const pane = overlayEl?.querySelector(`[data-agent-pane="${tabId}"]`);
if (!pane) return;
pane.innerHTML = `<div class="device-panel-agent-loading">
<span class="material-icons spinning">autorenew</span>
<div>${_('common.loading')}</div>
</div>`;
const endpointMap = {
services: { method: 'GET', url: `/api/devices/${encodeURIComponent(device.id)}/services` },
processes: { method: 'GET', url: `/api/devices/${encodeURIComponent(device.id)}/processes` },
events: { method: 'GET', url: `/api/devices/${encodeURIComponent(device.id)}/events?limit=200` },
activity: { method: 'GET', url: `/api/devices/${encodeURIComponent(device.id)}/activity` },
files: { method: 'POST', url: `/api/devices/${encodeURIComponent(device.id)}/files/browse`, body: { path: '/', show_hidden: false } }
};
const ep = endpointMap[tabId];
if (!ep) return;
try {
let resp;
if (ep.method === 'POST') {
resp = await Utils.api(ep.url, { method: 'POST', body: JSON.stringify(ep.body) });
} else {
resp = await Utils.api(ep.url);
}
const data = (resp && resp.data) || resp || null;
_renderAgentTab(tabId, data, pane);
} catch (err) {
_agentTabLoaded[tabId] = false; // allow retry
pane.innerHTML = _agentErrorHTML(err, tabId);
}
}
function _agentErrorHTML(err, tabId) {
const msg = err && err.message ? String(err.message) : 'unknown';
// Differentiate offline vs generic error for clearer UX.
const isOffline = msg.indexOf('503') !== -1 || msg.indexOf('offline') !== -1;
const isTimeout = msg.indexOf('504') !== -1 || msg.indexOf('timeout') !== -1;
const icon = isOffline ? 'cloud_off' : isTimeout ? 'hourglass_empty' : 'error_outline';
const title = isOffline
? _('device_detail.agent_offline') || 'Agent is offline'
: isTimeout
? _('device_detail.agent_timeout') || 'Agent did not respond in time'
: _('device_detail.agent_error') || 'Failed to load data from agent';
return `<div class="device-panel-agent-error">
<span class="material-icons">${icon}</span>
<div class="device-panel-agent-error-title">${title}</div>
<div class="device-panel-agent-error-hint">${Utils.escapeHtml(msg)}</div>
<button class="btn btn-secondary btn-sm" data-retry-tab="${tabId}">
<span class="material-icons">refresh</span>${_('actions.retry') || 'Retry'}
</button>
</div>`;
}
function _renderAgentTab(tabId, data, pane) {
if (tabId === 'services') {
pane.innerHTML = _renderServicesList(data);
} else if (tabId === 'processes') {
pane.innerHTML = _renderProcessList(data);
} else if (tabId === 'events') {
pane.innerHTML = _renderEventList(data);
} else if (tabId === 'activity') {
pane.innerHTML = _renderActivity(data);
} else if (tabId === 'files') {
pane.innerHTML = _renderFileBrowser(data, '/');
_attachFileBrowserEvents(pane);
}
// Generic retry button handler
pane.querySelector('[data-retry-tab]')?.addEventListener('click', function () {
const tid = this.dataset.retryTab;
_agentTabLoaded[tid] = false;
_loadAgentTab(tid);
});
}
function _renderServicesList(data) {
const services = Array.isArray(data?.services) ? data.services : Array.isArray(data) ? data : [];
if (!services.length) {
return `<div class="device-panel-agent-empty"><span class="material-icons">inbox</span><div>${_('common.no_data')}</div></div>`;
}
const rows = services.slice(0, 500).map(s => `
<tr>
<td>${Utils.escapeHtml(s.name || '')}</td>
<td>${Utils.escapeHtml(s.display_name || s.name || '')}</td>
<td><span class="badge ${s.status === 'running' ? 'badge-success' : 'badge-neutral'}">${Utils.escapeHtml(s.status || '-')}</span></td>
<td>${Utils.escapeHtml(s.start_type || '-')}</td>
</tr>`).join('');
return `<table class="device-panel-agent-table">
<thead><tr>
<th>${_('device_detail.service_name') || 'Name'}</th>
<th>${_('device_detail.service_display') || 'Display'}</th>
<th>${_('device_detail.service_status') || 'Status'}</th>
<th>${_('device_detail.service_start') || 'Start'}</th>
</tr></thead>
<tbody>${rows}</tbody>
</table>`;
}
function _renderProcessList(data) {
const procs = Array.isArray(data?.processes) ? data.processes : Array.isArray(data) ? data : [];
if (!procs.length) {
return `<div class="device-panel-agent-empty"><span class="material-icons">inbox</span><div>${_('common.no_data')}</div></div>`;
}
// Sort by CPU descending, show top 200
procs.sort((a, b) => (b.cpu || 0) - (a.cpu || 0));
const rows = procs.slice(0, 200).map(p => `
<tr>
<td>${Utils.escapeHtml(String(p.pid ?? '-'))}</td>
<td>${Utils.escapeHtml(p.name || '-')}</td>
<td>${Utils.escapeHtml(p.user || '-')}</td>
<td>${Number(p.cpu || 0).toFixed(1)}%</td>
<td>${Number(p.memory_mb || 0).toFixed(0)} MB</td>
</tr>`).join('');
return `<table class="device-panel-agent-table">
<thead><tr>
<th>PID</th>
<th>${_('device_detail.process_name') || 'Name'}</th>
<th>${_('device_detail.process_user') || 'User'}</th>
<th>CPU</th>
<th>${_('device_detail.process_memory') || 'Memory'}</th>
</tr></thead>
<tbody>${rows}</tbody>
</table>`;
}
function _renderEventList(data) {
const events = Array.isArray(data?.events) ? data.events : Array.isArray(data) ? data : [];
if (!events.length) {
return `<div class="device-panel-agent-empty"><span class="material-icons">inbox</span><div>${_('common.no_data')}</div></div>`;
}
const rows = events.slice(0, 500).map(e => {
const level = String(e.level || 'info').toLowerCase();
const levelClass = level === 'error' || level === 'critical' ? 'error'
: level === 'warning' || level === 'warn' ? 'warn' : 'info';
return `<div class="device-panel-event-row ${levelClass}">
<div class="device-panel-event-time">${Utils.escapeHtml(e.time || '')}</div>
<div class="device-panel-event-source">${Utils.escapeHtml(e.source || e.facility || '-')}</div>
<div class="device-panel-event-msg">${Utils.escapeHtml(e.message || '')}</div>
</div>`;
}).join('');
return `<div class="device-panel-event-list">${rows}</div>`;
}
function _renderActivity(data) {
const items = Array.isArray(data?.apps) ? data.apps : Array.isArray(data) ? data : [];
if (!items.length) {
return `<div class="device-panel-agent-empty"><span class="material-icons">inbox</span><div>${_('common.no_data')}</div></div>`;
}
// Sort by total seconds
items.sort((a, b) => (b.seconds || 0) - (a.seconds || 0));
const maxSec = Math.max(1, items[0].seconds || 1);
const rows = items.slice(0, 50).map(a => {
const pct = Math.min(100, ((a.seconds || 0) / maxSec) * 100);
const minutes = Math.floor((a.seconds || 0) / 60);
return `<div class="device-panel-activity-row">
<div class="device-panel-activity-name">${Utils.escapeHtml(a.name || a.app || '-')}</div>
<div class="device-panel-activity-bar">
<div class="device-panel-activity-bar-fill" style="width:${pct.toFixed(1)}%"></div>
</div>
<div class="device-panel-activity-time">${minutes} min</div>
</div>`;
}).join('');
return `<div class="device-panel-activity-list">${rows}</div>`;
}
function _renderFileBrowser(data, currentPath) {
const entries = Array.isArray(data?.entries) ? data.entries : Array.isArray(data) ? data : [];
const path = data?.path || currentPath || '/';
const parent = data?.parent || null;
const rows = entries.slice(0, 1000).map(e => {
const icon = e.is_dir ? 'folder' : 'description';
const size = e.is_dir ? '' : _formatBytes(e.size || 0);
return `<div class="device-panel-file-row" data-is-dir="${e.is_dir ? '1' : '0'}" data-path="${Utils.escapeHtml(e.path || '')}">
<span class="material-icons">${icon}</span>
<div class="device-panel-file-name">${Utils.escapeHtml(e.name || '')}</div>
<div class="device-panel-file-size">${size}</div>
</div>`;
}).join('');
return `<div class="device-panel-file-browser">
<div class="device-panel-file-path">
${parent ? `<button class="btn btn-secondary btn-sm" data-fb-up="${Utils.escapeHtml(parent)}">
<span class="material-icons">arrow_upward</span>
</button>` : ''}
<span>${Utils.escapeHtml(path)}</span>
</div>
<div class="device-panel-file-list">${rows}</div>
</div>`;
}
function _attachFileBrowserEvents(pane) {
pane.querySelectorAll('[data-fb-up]').forEach(btn => {
btn.addEventListener('click', () => _browseFiles(btn.dataset.fbUp));
});
pane.querySelectorAll('.device-panel-file-row').forEach(row => {
row.addEventListener('dblclick', () => {
if (row.dataset.isDir === '1') _browseFiles(row.dataset.path);
});
});
}
async function _browseFiles(path) {
if (!device || !device.id) return;
const pane = overlayEl?.querySelector('[data-agent-pane="files"]');
if (!pane) return;
pane.innerHTML = `<div class="device-panel-agent-loading"><span class="material-icons spinning">autorenew</span><div>${_('common.loading')}</div></div>`;
try {
const resp = await Utils.api(`/api/devices/${encodeURIComponent(device.id)}/files/browse`, {
method: 'POST',
body: JSON.stringify({ path, show_hidden: false })
});
pane.innerHTML = _renderFileBrowser(resp?.data || resp, path);
_attachFileBrowserEvents(pane);
} catch (err) {
pane.innerHTML = _agentErrorHTML(err, 'files');
pane.querySelector('[data-retry-tab]')?.addEventListener('click', function () {
_agentTabLoaded['files'] = false;
_loadAgentTab('files');
});
}
}
function _formatBytes(n) {
if (!n) return '0 B';
const k = 1024;
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.min(units.length - 1, Math.floor(Math.log(n) / Math.log(k)));
return (n / Math.pow(k, i)).toFixed(i === 0 ? 0 : 1) + ' ' + units[i];
}
// ── Tags ──
+282
View File
@@ -0,0 +1,282 @@
/**
* Navbar notification center.
*
* Responsibilities:
* - Fetches unread notifications from /api/bd/notifications?unread_only=true on load
* - Subscribes to Socket.IO "help-request" event for real-time pushes
* - Renders a dropdown list of recent items with action/link
* - Marks items as read via POST /api/bd/notifications/:id/read
* - Updates badge counter
*
* Defense in depth:
* - All server-provided text is inserted via textContent (never innerHTML) to
* prevent stored-XSS from compromised agent names or help request bodies.
* - CSRF token is sent with mutation requests (read / mark-all).
*/
(function () {
'use strict';
const MAX_ITEMS = 10;
const state = {
items: [],
unreadCount: 0,
};
let dom = null;
function _(key) {
try {
return (window.BetterDesk && window._) ? window._(key) : key;
} catch {
return key;
}
}
function csrf() {
return (window.BetterDesk && window.BetterDesk.csrfToken) || '';
}
function formatTime(iso) {
try {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
const diff = Date.now() - d.getTime();
if (diff < 60_000) return _('notifications.just_now');
if (diff < 3_600_000) return Math.floor(diff / 60_000) + 'm';
if (diff < 86_400_000) return Math.floor(diff / 3_600_000) + 'h';
return d.toLocaleDateString();
} catch {
return '';
}
}
function updateBadge() {
if (!dom) return;
const count = state.unreadCount;
if (count > 0) {
dom.badge.textContent = count > 99 ? '99+' : String(count);
dom.badge.hidden = false;
} else {
dom.badge.hidden = true;
}
}
function renderList() {
if (!dom) return;
dom.list.textContent = '';
if (state.items.length === 0) {
const empty = document.createElement('div');
empty.className = 'notif-empty';
empty.textContent = _('notifications.empty');
dom.list.appendChild(empty);
return;
}
for (const item of state.items.slice(0, MAX_ITEMS)) {
const row = document.createElement('a');
row.className = 'notif-item' + (item.read ? '' : ' notif-unread');
row.href = item.link || '/help-requests';
row.dataset.id = String(item.id);
const icon = document.createElement('span');
icon.className = 'material-icons notif-item-icon';
icon.textContent = item.icon || 'support_agent';
const body = document.createElement('div');
body.className = 'notif-item-body';
const title = document.createElement('div');
title.className = 'notif-item-title';
title.textContent = item.title || _('notifications.help_request');
const sub = document.createElement('div');
sub.className = 'notif-item-sub';
sub.textContent = item.message || '';
const time = document.createElement('div');
time.className = 'notif-item-time';
time.textContent = formatTime(item.created_at);
body.appendChild(title);
body.appendChild(sub);
body.appendChild(time);
row.appendChild(icon);
row.appendChild(body);
row.addEventListener('click', () => {
if (!item.read) {
markRead(item.id).catch(() => { /* best effort */ });
}
});
dom.list.appendChild(row);
}
}
async function fetchNotifications() {
try {
const resp = await fetch('/api/bd/notifications?limit=' + MAX_ITEMS, {
credentials: 'same-origin',
headers: { Accept: 'application/json' },
});
if (!resp.ok) return;
const data = await resp.json();
const items = Array.isArray(data.items) ? data.items : (Array.isArray(data) ? data : []);
state.items = items;
state.unreadCount = items.filter(i => !i.read).length;
updateBadge();
renderList();
} catch {
// Silent — dropdown stays empty, badge stays hidden.
}
}
async function markRead(id) {
if (!id) return;
try {
await fetch('/api/bd/notifications/' + encodeURIComponent(id) + '/read', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrf(),
},
});
const item = state.items.find(i => String(i.id) === String(id));
if (item && !item.read) {
item.read = true;
state.unreadCount = Math.max(0, state.unreadCount - 1);
updateBadge();
renderList();
}
} catch {
// Silent; user can retry.
}
}
async function markAllRead() {
try {
await fetch('/api/bd/notifications/read-all', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrf(),
},
});
state.items = state.items.map(i => ({ ...i, read: true }));
state.unreadCount = 0;
updateBadge();
renderList();
} catch {
// Silent.
}
}
function onHelpRequestEvent(payload) {
try {
const item = {
id: payload.id || Date.now(),
title: payload.device_name || _('notifications.help_request'),
message: payload.description || payload.message || '',
icon: 'support_agent',
link: '/help-requests',
read: false,
created_at: payload.timestamp || new Date().toISOString(),
};
// Insert at top, cap list.
state.items.unshift(item);
if (state.items.length > MAX_ITEMS) {
state.items = state.items.slice(0, MAX_ITEMS);
}
state.unreadCount += 1;
updateBadge();
renderList();
// Subtle badge pulse for real-time feedback.
if (dom && dom.btn) {
dom.btn.classList.remove('notif-pulse');
// Force reflow so the class re-application restarts the animation.
void dom.btn.offsetWidth;
dom.btn.classList.add('notif-pulse');
}
} catch {
// Ignore malformed payloads.
}
}
function attachSocket() {
try {
const s = window.socket || (typeof io === 'function' ? io() : null);
if (!s || typeof s.on !== 'function') return;
window.socket = s;
s.on('help-request', onHelpRequestEvent);
s.on('notification', onHelpRequestEvent);
} catch {
// Socket.IO not available — poll only.
}
}
function toggleDropdown(open) {
if (!dom) return;
const isOpen = open !== undefined ? open : dom.dropdown.hidden;
dom.dropdown.hidden = !isOpen;
dom.btn.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
if (isOpen) {
fetchNotifications();
}
}
function init() {
dom = {
wrapper: document.querySelector('.notif-wrapper'),
btn: document.getElementById('notif-btn'),
badge: document.getElementById('notif-badge'),
dropdown: document.getElementById('notif-dropdown'),
list: document.getElementById('notif-list'),
markAll: document.getElementById('notif-mark-all'),
};
if (!dom.btn || !dom.dropdown || !dom.list) {
dom = null;
return;
}
dom.btn.addEventListener('click', (e) => {
e.stopPropagation();
toggleDropdown();
});
if (dom.markAll) {
dom.markAll.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
markAllRead();
});
}
document.addEventListener('click', (e) => {
if (!dom.wrapper.contains(e.target)) {
toggleDropdown(false);
}
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') toggleDropdown(false);
});
fetchNotifications();
attachSocket();
// Polling fallback: refresh every 60s. Cheap (10 rows max) and survives
// socket disconnects.
setInterval(fetchNotifications, 60_000);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
+128
View File
@@ -696,6 +696,134 @@ router.delete('/help-requests/:id', requireDeviceAuth, requireOperatorRole, asyn
}
});
// ===========================================================================
// Notification Center (panel navbar bell)
// ===========================================================================
//
// These routes are consumed by web-nodejs/public/js/notif-center.js. They use
// session-based auth (panel cookies) rather than Bearer tokens because the
// dropdown is part of the web UI, not the desktop client.
//
// Storage model: notifications are a per-user read-status overlay over
// helpRequests. The overlay (readByUser) lives in-memory and is cleaned up
// opportunistically. On restart, all help-requests appear unread again — a
// deliberate trade-off: persistence can be added later via DB if needed.
// ---------------------------------------------------------------------------
const { requireAuth } = require('../middleware/auth');
/** @type {Map<string, Set<string>>} userId → Set<helpRequestId> marked read. */
const readByUser = new Map();
function isReadBy(userId, notifId) {
const set = readByUser.get(String(userId));
return !!(set && set.has(String(notifId)));
}
function markReadBy(userId, notifId) {
const key = String(userId);
let set = readByUser.get(key);
if (!set) {
set = new Set();
readByUser.set(key, set);
}
set.add(String(notifId));
// Cap per-user set size to avoid unbounded growth.
if (set.size > 500) {
const arr = [...set];
readByUser.set(key, new Set(arr.slice(-400)));
}
}
function helpRequestToNotif(req, userId) {
return {
id: req.id,
title: req.hostname || req.device_id,
message: req.message || '',
icon: 'support_agent',
link: '/help-requests',
read: isReadBy(userId, req.id),
created_at: new Date(req.created_at).toISOString(),
kind: 'help_request',
status: req.status,
};
}
// ---------------------------------------------------------------------------
// GET /api/bd/notifications — list recent notifications for current user
// ---------------------------------------------------------------------------
router.get('/notifications', requireAuth, (req, res) => {
try {
const rawLimit = parseInt(req.query.limit, 10);
const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(rawLimit, 1), 50) : 20;
const unreadOnly = String(req.query.unread_only || '').toLowerCase() === 'true';
const userId = req.session?.user?.id;
const items = [...helpRequests.values()]
.sort((a, b) => b.created_at - a.created_at)
.map(r => helpRequestToNotif(r, userId))
.filter(n => (unreadOnly ? !n.read : true))
.slice(0, limit);
const unreadCount = [...helpRequests.values()]
.filter(r => !isReadBy(userId, r.id)).length;
res.json({ success: true, items, unread_count: unreadCount });
} catch (err) {
console.error('[BD-API] List notifications error:', err.message);
res.status(500).json({ error: 'Failed to list notifications' });
}
});
// ---------------------------------------------------------------------------
// POST /api/bd/notifications/:id/read — mark single notification read
// ---------------------------------------------------------------------------
router.post('/notifications/:id/read', requireAuth, (req, res) => {
try {
const userId = req.session?.user?.id;
if (!userId) {
return res.status(401).json({ error: 'Not authenticated' });
}
const id = String(req.params.id || '').slice(0, 128);
if (!helpRequests.has(id)) {
// Idempotent: succeed even if the item was already pruned. Client
// only uses this to update its local badge state.
return res.json({ success: true, pruned: true });
}
markReadBy(userId, id);
res.json({ success: true });
} catch (err) {
console.error('[BD-API] Mark notification read error:', err.message);
res.status(500).json({ error: 'Failed to mark notification read' });
}
});
// ---------------------------------------------------------------------------
// POST /api/bd/notifications/read-all — mark all notifications read
// ---------------------------------------------------------------------------
router.post('/notifications/read-all', requireAuth, (req, res) => {
try {
const userId = req.session?.user?.id;
if (!userId) {
return res.status(401).json({ error: 'Not authenticated' });
}
for (const id of helpRequests.keys()) {
markReadBy(userId, id);
}
res.json({ success: true });
} catch (err) {
console.error('[BD-API] Mark all read error:', err.message);
res.status(500).json({ error: 'Failed to mark all notifications read' });
}
});
// ---------------------------------------------------------------------------
// POST /api/bd/operator/sessions — Record operator session start/end
// ---------------------------------------------------------------------------
+121
View File
@@ -486,4 +486,125 @@ router.delete('/api/devices/:id/access-policy', requireAuth, requirePermission('
}
});
// ===========================================================================
// Phase 2 — Live agent introspection (services, processes, events, files,
// terminal, screenshot, activity). All endpoints proxy a command request to
// the agent over the signal WebSocket (bdRelay.requestFromDevice) and return
// the agent's reply. If the agent is offline, 503 is returned.
// ===========================================================================
const bdRelay = require('../services/bdRelay');
/**
* Wrap an agent-proxy request with consistent error handling and timeout.
* Distinct error codes help the UI render appropriate states.
*/
async function proxyAgentRequest(req, res, type, payload = null, timeoutMs = 15000) {
try {
const data = await bdRelay.requestFromDevice(req.params.id, type, payload, timeoutMs);
res.json({ success: true, data });
} catch (err) {
const msg = err && err.message ? err.message : 'agent_error';
const status = msg === 'agent_offline' ? 503
: msg === 'agent_timeout' ? 504
: 502;
res.status(status).json({ success: false, error: msg });
}
}
/** GET /api/devices/:id/services — live OS services list */
router.get('/api/devices/:id/services', requireAuth, requirePermission('device.view'), (req, res) => {
proxyAgentRequest(req, res, 'services.list');
});
/** GET /api/devices/:id/processes — live process list */
router.get('/api/devices/:id/processes', requireAuth, requirePermission('device.view'), (req, res) => {
proxyAgentRequest(req, res, 'processes.list');
});
/** GET /api/devices/:id/events?limit=100 — recent OS event log / journalctl */
router.get('/api/devices/:id/events', requireAuth, requirePermission('device.view'), (req, res) => {
const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 100, 1), 500);
proxyAgentRequest(req, res, 'events.list', { limit });
});
/** GET /api/devices/:id/activity — live activity tracker (app usage) */
router.get('/api/devices/:id/activity', requireAuth, requirePermission('device.view'), (req, res) => {
proxyAgentRequest(req, res, 'activity.get');
});
/**
* POST /api/devices/:id/files/browse
* Body: { path: '/some/folder', show_hidden: false }
*/
router.post('/api/devices/:id/files/browse', requireAuth, requirePermission('device.edit'), (req, res) => {
const path = String(req.body?.path || '').slice(0, 4096);
const showHidden = req.body?.show_hidden === true;
proxyAgentRequest(req, res, 'files.browse', { path, show_hidden: showHidden });
});
/**
* POST /api/devices/:id/files/read
* Body: { path, offset, length }
*/
router.post('/api/devices/:id/files/read', requireAuth, requirePermission('device.edit'), (req, res) => {
const path = String(req.body?.path || '').slice(0, 4096);
const offset = Math.max(0, parseInt(req.body?.offset, 10) || 0);
const length = Math.min(1024 * 1024, Math.max(0, parseInt(req.body?.length, 10) || 65536));
proxyAgentRequest(req, res, 'files.read', { path, offset, length }, 30000);
});
/**
* POST /api/devices/:id/screenshot
* Captures a JPEG snapshot from the agent. Returns base64 image.
*/
router.post('/api/devices/:id/screenshot', requireAuth, requirePermission('device.view'), (req, res) => {
proxyAgentRequest(req, res, 'screenshot.capture', null, 20000);
});
/**
* POST /api/devices/:id/terminal/execute
* Body: { command: 'ls -la /tmp' }
* One-shot command execution (no PTY). For interactive terminal use the WS
* endpoint (future Phase 4 integration).
*/
router.post('/api/devices/:id/terminal/execute', requireAuth, requirePermission('device.edit'), async (req, res) => {
const command = String(req.body?.command || '').slice(0, 4096);
if (!command.trim()) {
return res.status(400).json({ success: false, error: 'command_required' });
}
// Audit this — terminal execution is a sensitive action.
try {
await db.logAction(req.session.userId, 'terminal.execute',
`Terminal command on ${req.params.id}: ${command.substring(0, 200)}`,
req.ip || null);
} catch (_) { /* audit failure should not block the command */ }
proxyAgentRequest(req, res, 'terminal.execute', { command }, 30000);
});
/**
* POST /api/devices/:id/rename
* Body: { display_name: 'Accounting PC 3' }
* Convenience alias for the display_name branch of PATCH /api/devices/:id.
*/
router.post('/api/devices/:id/rename', requireAuth, requirePermission('device.edit'), async (req, res) => {
try {
const displayName = String(req.body?.display_name || '').trim().slice(0, 200);
if (!displayName) {
return res.status(400).json({ success: false, error: 'display_name_required' });
}
const device = await serverBackend.getDeviceById(req.params.id);
if (!device) {
return res.status(404).json({ success: false, error: req.t('devices.not_found') });
}
const result = await serverBackend.updateDevice(req.params.id, { display_name: displayName });
await db.logAction(req.session.userId, 'device.rename',
`Device ${req.params.id} renamed to "${displayName}"`, req.ip || null);
res.json({ success: true, data: { changes: result?.changes ?? 1, display_name: displayName } });
} catch (err) {
console.error('Rename device error:', err);
res.status(500).json({ success: false, error: req.t('errors.server_error') });
}
});
module.exports = router;
+2
View File
@@ -58,6 +58,7 @@ const resourceControlRoutes = lazyRoute('./resource-control.routes');
const systemRoutes = lazyRoute('./system.routes');
const cdapStudioRoutes = lazyRoute('./cdap-studio.routes');
const permissionsRoutes = lazyRoute('./permissions.routes');
const phase45Routes = lazyRoute('./phase4_5.routes');
/**
* Middleware to require JSON Content-Type for POST/PATCH/PUT requests to API routes.
@@ -144,5 +145,6 @@ router.use('/api/bd', resourceControlRoutes); // device-facing:
router.use('/', systemRoutes); // admin-facing: /api/system/*, /api/logs/*, /api/database/*, /api/docker/*, /api/speed-test
router.use('/', cdapStudioRoutes); // admin-facing: /cdap-studio, /api/cdap-studio/*
router.use('/', permissionsRoutes); // admin-facing: /permissions, /api/panel/roles/*, /api/panel/role-permissions/*
router.use('/', phase45Routes); // Phase 4/5: /api/users/me/profile, /api/agent-templates, /portal
module.exports = router;
+278
View File
@@ -0,0 +1,278 @@
/**
* BetterDesk Console — Phase 4/5 scaffolding routes
*
* Phase 4: operator identity profile + consent-popup metadata endpoint
* Phase 5: agent templates (enrollment presets) + public downloads portal
*
* This module is intentionally minimal — it establishes DB shape and HTTP
* contract so UI + agent-side work can proceed in parallel. The consent popup
* itself lives in the agent client (Rust) and fetches `/api/bd/operator-info`
* when a remote session is requested.
*/
'use strict';
const express = require('express');
const router = express.Router();
const crypto = require('crypto');
const db = require('../services/database');
const { requireAuth, requirePermission } = require('../middleware/auth');
// Shared auth.db handle used for agent_templates (kept alongside users)
let _templatesReady = false;
function ensureTemplatesTable() {
if (_templatesReady) return;
try {
const { getAuthDb } = require('../services/database');
// Best-effort: better-sqlite3 synchronous path.
const auth = typeof getAuthDb === 'function' ? getAuthDb() : null;
if (auth && typeof auth.exec === 'function') {
auth.exec(`CREATE TABLE IF NOT EXISTS agent_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
description TEXT DEFAULT '',
config_json TEXT NOT NULL DEFAULT '{}',
enrollment_token TEXT NOT NULL UNIQUE,
created_by INTEGER,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
)`);
}
} catch (e) {
console.warn('[phase4_5] ensureTemplatesTable:', e.message);
}
_templatesReady = true;
}
// ────────────────────────────────────────────────────────────────────────────
// Phase 4 — operator profile
// ────────────────────────────────────────────────────────────────────────────
/**
* GET /api/users/me/profile — current operator's identity profile.
*/
router.get('/api/users/me/profile', requireAuth, async (req, res) => {
try {
const uid = req.session.userId;
const user = await db.getUserById(uid);
if (!user) return res.status(404).json({ success: false, error: 'not_found' });
res.json({
success: true,
profile: {
id: user.id,
username: user.username,
role: user.role,
first_name: user.first_name || '',
last_name: user.last_name || '',
email: user.email || '',
phone: user.phone || '',
role_display: user.role_display || '',
avatar_url: user.avatar_url || '',
},
});
} catch (e) {
res.status(500).json({ success: false, error: e.message });
}
});
/**
* PUT /api/users/me/profile — update own identity fields.
*/
router.put('/api/users/me/profile', requireAuth, async (req, res) => {
try {
if (typeof db.updateUserProfile !== 'function') {
return res.status(501).json({ success: false, error: 'not_implemented' });
}
const { first_name, last_name, email, phone, role_display, avatar_url } = req.body || {};
// Lightweight validation. Heavy validation (e.g. email regex) deferred
// until we decide whether email must be unique / confirmed.
if (email && String(email).length > 200) {
return res.status(400).json({ success: false, error: 'email_too_long' });
}
await db.updateUserProfile(req.session.userId, {
first_name, last_name, email, phone, role_display, avatar_url,
});
try {
await db.logAction(req.session.userId, 'profile_updated', 'Updated operator profile', req.ip);
} catch (_) {}
res.json({ success: true });
} catch (e) {
res.status(500).json({ success: false, error: e.message });
}
});
/**
* GET /api/bd/operator-info?session_id=... — consent-popup payload for agent.
*
* Agent calls this (with device JWT) right before showing the remote-session
* consent popup so the end user sees *who* is asking to connect.
*
* NOTE: This is a stub — wiring to an actual session/ticket record requires
* the session broker from Phase 4 proper. For now it resolves the operator
* from the authenticated JWT's `user_id` claim.
*/
router.get('/api/bd/operator-info', async (req, res) => {
try {
const sessionId = String(req.query.session_id || '').slice(0, 128);
// In real implementation, session_id would map to an in-progress
// remote request and we'd lookup the requesting operator. For now we
// require session auth and return the current user's profile.
if (!req.session || !req.session.userId) {
return res.status(401).json({ success: false, error: 'unauthorized' });
}
const user = await db.getUserById(req.session.userId);
if (!user) return res.status(404).json({ success: false, error: 'not_found' });
res.json({
success: true,
session_id: sessionId,
operator: {
display_name: [user.first_name, user.last_name].filter(Boolean).join(' ') || user.username,
username: user.username,
role_display: user.role_display || user.role,
email: user.email || '',
phone: user.phone || '',
avatar_url: user.avatar_url || '',
},
});
} catch (e) {
res.status(500).json({ success: false, error: e.message });
}
});
// ────────────────────────────────────────────────────────────────────────────
// Phase 5 — agent templates + enrollment + downloads portal
// ────────────────────────────────────────────────────────────────────────────
/**
* GET /api/agent-templates — list templates (admin/operator).
*/
router.get('/api/agent-templates', requireAuth, requirePermission('enrollment.manage'), async (req, res) => {
try {
ensureTemplatesTable();
const rows = await db.query
? await db.query('SELECT id, name, description, enrollment_token, created_at, updated_at FROM agent_templates ORDER BY id DESC')
: [];
res.json({ success: true, templates: rows });
} catch (e) {
res.status(500).json({ success: false, error: e.message, templates: [] });
}
});
/**
* POST /api/agent-templates — create a new template.
* Body: { name, description, config }
*/
router.post('/api/agent-templates', requireAuth, requirePermission('enrollment.manage'), async (req, res) => {
try {
ensureTemplatesTable();
const name = String(req.body?.name || '').trim().slice(0, 100);
if (!name) return res.status(400).json({ success: false, error: 'name_required' });
const description = String(req.body?.description || '').slice(0, 500);
const config = req.body?.config && typeof req.body.config === 'object' ? req.body.config : {};
const token = crypto.randomBytes(24).toString('hex');
if (!db.run) {
return res.status(501).json({ success: false, error: 'db_backend_missing_run' });
}
await db.run(
`INSERT INTO agent_templates (name, description, config_json, enrollment_token, created_by)
VALUES (?, ?, ?, ?, ?)`,
[name, description, JSON.stringify(config), token, req.session.userId || null]
);
try { await db.logAction(req.session.userId, 'template_created', `Created agent template: ${name}`, req.ip); } catch (_) {}
res.status(201).json({ success: true, enrollment_token: token });
} catch (e) {
if (String(e.message).includes('UNIQUE')) {
return res.status(409).json({ success: false, error: 'name_exists' });
}
res.status(500).json({ success: false, error: e.message });
}
});
/**
* DELETE /api/agent-templates/:id
*/
router.delete('/api/agent-templates/:id', requireAuth, requirePermission('enrollment.manage'), async (req, res) => {
try {
const id = parseInt(req.params.id, 10);
if (!Number.isFinite(id) || id <= 0) {
return res.status(400).json({ success: false, error: 'invalid_id' });
}
if (!db.run) return res.status(501).json({ success: false, error: 'db_backend_missing_run' });
await db.run('DELETE FROM agent_templates WHERE id = ?', [id]);
res.json({ success: true });
} catch (e) {
res.status(500).json({ success: false, error: e.message });
}
});
/**
* POST /api/bd/enroll — agent enrollment using a template token.
* Body: { enrollment_token, device_id, device_name, sysinfo }
*
* This endpoint is exposed publicly (no auth) — security comes from the
* one-use enrollment_token in the template. Once enrolled the agent obtains
* a normal device JWT via the existing /api/bd/register flow.
*/
router.post('/api/bd/enroll', async (req, res) => {
try {
ensureTemplatesTable();
const token = String(req.body?.enrollment_token || '').slice(0, 64);
const deviceId = String(req.body?.device_id || '').slice(0, 64);
if (!token || !deviceId) {
return res.status(400).json({ success: false, error: 'missing_fields' });
}
const row = db.get
? await db.get('SELECT id, name, config_json FROM agent_templates WHERE enrollment_token = ?', [token])
: null;
if (!row) return res.status(401).json({ success: false, error: 'invalid_token' });
let config = {};
try { config = JSON.parse(row.config_json || '{}'); } catch (_) {}
// Audit trail. Actual device creation is handled by agent's subsequent
// /api/bd/register call which uses the returned `preset_config` to seed
// capabilities, tags, group membership, etc.
try { await db.logAction(null, 'device_enrolled', `Device ${deviceId} enrolled via template "${row.name}"`, req.ip); } catch (_) {}
res.json({
success: true,
template_name: row.name,
preset_config: config,
});
} catch (e) {
res.status(500).json({ success: false, error: e.message });
}
});
/**
* GET /portal — branded public download page.
* No auth required. Lists available installers + enrollment instructions.
*/
router.get('/portal', (req, res) => {
res.render('downloads-portal', {
title: req.t ? req.t('portal.title') : 'Download BetterDesk',
layout: false, // standalone page
});
});
/**
* GET /api/portal/installers — list available installers (JSON).
* The actual binaries are served from /downloads/ (static). This endpoint
* just enumerates metadata.
*/
router.get('/api/portal/installers', (req, res) => {
const base = req.protocol + '://' + req.get('host');
res.json({
success: true,
installers: [
{ platform: 'windows', arch: 'x64', url: `${base}/downloads/BetterDesk_Agent_x64-setup.exe`, format: 'nsis' },
{ platform: 'linux', arch: 'x64', url: `${base}/downloads/betterdesk-agent-linux-amd64`, format: 'binary' },
{ platform: 'linux', arch: 'arm64', url: `${base}/downloads/betterdesk-agent-linux-arm64`, format: 'binary' },
],
});
});
module.exports = router;
+55
View File
@@ -50,6 +50,11 @@ const onlineDevices = new Map();
// IP → count
const connectionsPerIp = new Map();
// requestId → { resolve, reject, timeout } for request/response pattern over signal WS
const pendingRequests = new Map();
const REQUEST_TIMEOUT_MS = 15 * 1000; // 15s default
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
@@ -150,6 +155,42 @@ function getOnlineDeviceIds() {
return ids;
}
/**
* Send a request to an online agent and await its response over the signal WS.
* The agent receives `{ type, request_id, payload }` and must reply with
* `{ type: 'command_response', request_id, ok, data?, error? }`.
*
* @param {string} deviceId
* @param {string} type e.g. 'services.list', 'files.browse'
* @param {object} payload optional type-specific payload
* @param {number} [timeoutMs=15000]
* @returns {Promise<any>} resolves with agent's `data`, rejects on error/timeout
*/
function requestFromDevice(deviceId, type, payload = null, timeoutMs = REQUEST_TIMEOUT_MS) {
return new Promise((resolve, reject) => {
const ws = onlineDevices.get(deviceId);
if (!ws || ws.readyState !== WebSocket.OPEN) {
return reject(new Error('agent_offline'));
}
const requestId = crypto.randomUUID();
const timeout = setTimeout(() => {
pendingRequests.delete(requestId);
reject(new Error('agent_timeout'));
}, Math.max(1000, Math.min(60000, timeoutMs)));
pendingRequests.set(requestId, { resolve, reject, timeout });
try {
ws.send(JSON.stringify({ type, request_id: requestId, payload }));
} catch (err) {
pendingRequests.delete(requestId);
clearTimeout(timeout);
reject(err);
}
});
}
// ---------------------------------------------------------------------------
// WebSocket server initialisation
// ---------------------------------------------------------------------------
@@ -442,6 +483,19 @@ function handleSignalMessage(deviceId, ws, msg) {
break;
}
case 'command_response': {
// Agent responding to a requestFromDevice() call.
// msg = { type, request_id, ok, data?, error? }
const pending = pendingRequests.get(msg.request_id);
if (pending) {
pendingRequests.delete(msg.request_id);
clearTimeout(pending.timeout);
if (msg.ok) pending.resolve(msg.data || null);
else pending.reject(new Error(msg.error || 'agent_error'));
}
break;
}
default:
break;
}
@@ -479,6 +533,7 @@ module.exports = {
notifyTarget,
isDeviceOnline,
getOnlineDeviceIds,
requestFromDevice,
activeSessions, // exposed for monitoring/admin API
onlineDevices, // exposed for status sync
};
+52
View File
@@ -429,6 +429,13 @@ function createSqliteAdapter(config) {
{ name: 'totp_secret', sql: 'TEXT DEFAULT NULL' },
{ name: 'totp_enabled', sql: 'INTEGER DEFAULT 0' },
{ name: 'totp_recovery_codes', sql: 'TEXT DEFAULT NULL' },
// Phase 4: operator identity profile (shown to end-user on consent popup)
{ name: 'first_name', sql: "TEXT DEFAULT ''" },
{ name: 'last_name', sql: "TEXT DEFAULT ''" },
{ name: 'email', sql: "TEXT DEFAULT ''" },
{ name: 'phone', sql: "TEXT DEFAULT ''" },
{ name: 'role_display', sql: "TEXT DEFAULT ''" },
{ name: 'avatar_url', sql: "TEXT DEFAULT ''" },
];
try {
const existingUserCols = new Set(db.prepare('PRAGMA table_info(users)').all().map(c => c.name));
@@ -1096,6 +1103,21 @@ function createSqliteAdapter(config) {
async updateUserRole(id, role) {
openAuth().prepare('UPDATE users SET role = ? WHERE id = ?').run(role, id);
},
// Phase 4: update operator identity profile (first_name, last_name, email, phone, role_display, avatar_url)
async updateUserProfile(id, fields) {
const allowed = ['first_name', 'last_name', 'email', 'phone', 'role_display', 'avatar_url'];
const sets = [];
const values = [];
for (const k of allowed) {
if (fields && Object.prototype.hasOwnProperty.call(fields, k)) {
sets.push(`${k} = ?`);
values.push(String(fields[k] == null ? '' : fields[k]).slice(0, 200));
}
}
if (!sets.length) return;
values.push(id);
openAuth().prepare(`UPDATE users SET ${sets.join(', ')} WHERE id = ?`).run(...values);
},
async deleteUser(id) {
openAuth().prepare('DELETE FROM users WHERE id = ?').run(id);
},
@@ -3099,6 +3121,21 @@ function createPostgresAdapter() {
await q('ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_recovery_codes TEXT DEFAULT NULL');
}
// Phase 4: operator identity profile columns
const identityCols = [
['first_name', "TEXT DEFAULT ''"],
['last_name', "TEXT DEFAULT ''"],
['email', "TEXT DEFAULT ''"],
['phone', "TEXT DEFAULT ''"],
['role_display', "TEXT DEFAULT ''"],
['avatar_url', "TEXT DEFAULT ''"],
];
for (const [col, def] of identityCols) {
if (!existingCols.has(col)) {
await q(`ALTER TABLE users ADD COLUMN IF NOT EXISTS ${col} ${def}`);
}
}
// Migration: Add updated_at to settings table if missing (for upgrades from older versions)
try {
const settingsColCheck = await all(`SELECT column_name FROM information_schema.columns WHERE table_name = 'settings'`);
@@ -3387,6 +3424,21 @@ function createPostgresAdapter() {
async hasUsers() { return +(await one('SELECT COUNT(*) as c FROM users')).c > 0; },
async getAllUsers() { return all('SELECT id, username, role, created_at, last_login, totp_enabled FROM users ORDER BY id'); },
async updateUserRole(id, role) { await q('UPDATE users SET role = $1 WHERE id = $2', [role, id]); },
async updateUserProfile(id, fields) {
const allowed = ['first_name', 'last_name', 'email', 'phone', 'role_display', 'avatar_url'];
const sets = [];
const values = [];
let idx = 1;
for (const k of allowed) {
if (fields && Object.prototype.hasOwnProperty.call(fields, k)) {
sets.push(`${k} = $${idx++}`);
values.push(String(fields[k] == null ? '' : fields[k]).slice(0, 200));
}
}
if (!sets.length) return;
values.push(id);
await q(`UPDATE users SET ${sets.join(', ')} WHERE id = $${idx}`, values);
},
async deleteUser(id) { await q('DELETE FROM users WHERE id = $1', [id]); },
async countAdmins() { return +(await one("SELECT COUNT(*) as c FROM users WHERE role IN ('admin', 'super_admin')")).c; },
+105
View File
@@ -0,0 +1,105 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><%= title || 'Download BetterDesk Agent' %></title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
min-height: 100vh;
background: linear-gradient(135deg, #0b1020, #16213e);
color: #e6edf3;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.portal-wrap {
max-width: 920px;
margin: 0 auto;
padding: 60px 20px;
}
.portal-header {
text-align: center;
margin-bottom: 48px;
}
.portal-header h1 { font-size: 40px; margin-bottom: 12px; }
.portal-header p { color: #8b949e; font-size: 17px; }
.portal-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 20px;
}
.portal-card {
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 14px;
padding: 24px;
text-align: center;
backdrop-filter: blur(20px);
}
.portal-card h3 { font-size: 20px; margin-bottom: 8px; }
.portal-card .arch { color: #8b949e; font-size: 13px; margin-bottom: 20px; }
.portal-btn {
display: inline-block;
background: #3b82f6;
color: #fff;
padding: 12px 24px;
border-radius: 8px;
text-decoration: none;
font-weight: 600;
transition: background 0.15s;
}
.portal-btn:hover { background: #2563eb; }
.portal-footer {
margin-top: 60px;
text-align: center;
color: #8b949e;
font-size: 13px;
}
.portal-empty {
color: #8b949e;
text-align: center;
padding: 40px;
}
</style>
</head>
<body>
<div class="portal-wrap">
<div class="portal-header">
<h1>Download BetterDesk Agent</h1>
<p>Install the lightweight agent on your device to enable remote support.</p>
</div>
<div class="portal-grid" id="installer-grid">
<div class="portal-empty">Loading available installers…</div>
</div>
<div class="portal-footer">
Need help? Contact your administrator for enrollment instructions.
</div>
</div>
<script>
(function () {
fetch('/api/portal/installers', { credentials: 'omit' })
.then(r => r.json())
.then(function (data) {
var grid = document.getElementById('installer-grid');
if (!data || !data.success || !Array.isArray(data.installers) || data.installers.length === 0) {
grid.innerHTML = '<div class="portal-empty">No installers available yet.</div>';
return;
}
var labels = { windows: 'Windows', linux: 'Linux', macos: 'macOS' };
grid.innerHTML = data.installers.map(function (i) {
var label = labels[i.platform] || i.platform;
return '<div class="portal-card">'
+ '<h3>' + label + '</h3>'
+ '<div class="arch">' + (i.arch || '') + ' · ' + (i.format || '') + '</div>'
+ '<a class="portal-btn" href="' + i.url + '">Download</a>'
+ '</div>';
}).join('');
})
.catch(function () {
document.getElementById('installer-grid').innerHTML =
'<div class="portal-empty">Failed to load installers.</div>';
});
})();
</script>
</body>
</html>
+2
View File
@@ -14,6 +14,7 @@
<link rel="stylesheet" href="/css/main.css?v=<%= cacheVersion %>">
<link rel="stylesheet" href="/css/theme.css?v=<%= cacheVersion %>">
<link rel="stylesheet" href="/css/transitions.css?v=<%= cacheVersion %>">
<link rel="stylesheet" href="/css/notif-center.css?v=<%= cacheVersion %>">
<% if (!embed) { %>
<link rel="stylesheet" href="/css/desktop-mode.css?v=<%= cacheVersion %>">
<link rel="stylesheet" href="/css/desktop-widgets.css?v=<%= cacheVersion %>">
@@ -140,6 +141,7 @@
<script src="/js/utils.js?v=<%= cacheVersion %>"></script>
<script src="/js/i18n-client.js?v=<%= cacheVersion %>"></script>
<script src="/js/notifications.js?v=<%= cacheVersion %>"></script>
<script src="/js/notif-center.js?v=<%= cacheVersion %>"></script>
<script src="/js/modal.js?v=<%= cacheVersion %>"></script>
<script src="/js/app.js?v=<%= cacheVersion %>"></script>
<% if (!embed) { %>
+21 -1
View File
@@ -29,7 +29,27 @@
<button class="navbar-btn" id="refresh-btn" title="<%= _('actions.refresh') %>">
<span class="material-icons">refresh</span>
</button>
<!-- Notification bell -->
<div class="notif-wrapper">
<button class="navbar-btn notif-btn" id="notif-btn" title="<%= _('notifications.title') %>" aria-haspopup="true" aria-expanded="false">
<span class="material-icons">notifications</span>
<span class="notif-badge" id="notif-badge" hidden>0</span>
</button>
<div class="notif-dropdown" id="notif-dropdown" role="menu" hidden>
<div class="notif-header">
<span><%= _('notifications.title') %></span>
<button type="button" class="notif-mark-all" id="notif-mark-all"><%= _('notifications.mark_all_read') %></button>
</div>
<div class="notif-list" id="notif-list" aria-live="polite">
<div class="notif-empty"><%= _('notifications.empty') %></div>
</div>
<div class="notif-footer">
<a href="/help-requests" class="notif-see-all"><%= _('notifications.see_all') %></a>
</div>
</div>
</div>
<!-- Desktop mode toggle (Beta) - visible only on large screens -->
<button class="navbar-btn desktop-toggle-btn" id="desktop-toggle-btn" title="<%= _('desktop.switch_mode') %> (Beta)">
<span class="material-icons">desktop_windows</span>