diff --git a/Data/Agent/Roles/role_ScriptExec_SYSTEM.py b/Data/Agent/Roles/role_ScriptExec_SYSTEM.py index 4c8d456e..39d0240a 100644 --- a/Data/Agent/Roles/role_ScriptExec_SYSTEM.py +++ b/Data/Agent/Roles/role_ScriptExec_SYSTEM.py @@ -17,6 +17,76 @@ def _project_root(): return os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')) +def _find_borealis_root() -> Optional[str]: + override = os.environ.get('BOREALIS_ROOT') or os.environ.get('BOREALIS_PROJECT_ROOT') + if override: + candidate = os.path.abspath(override) + if os.path.isfile(os.path.join(candidate, 'Borealis.ps1')): + return candidate + + cur = _project_root() + for _ in range(8): + if os.path.isfile(os.path.join(cur, 'Borealis.ps1')): + return cur + parent = os.path.dirname(cur) + if parent == cur: + break + cur = parent + + fallback = os.path.abspath(os.path.join(_project_root(), '..')) + if os.path.isfile(os.path.join(fallback, 'Borealis.ps1')): + return fallback + return None + + +def _launch_silent_update_task(): + if os.name != 'nt': + raise RuntimeError('Silent update is supported on Windows hosts only.') + + root = _find_borealis_root() + if not root: + raise RuntimeError('Unable to locate Borealis.ps1 on this agent.') + + ps_exe = os.path.expandvars(r"%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe") + if not os.path.isfile(ps_exe): + ps_exe = 'powershell.exe' + + task_name = f"Borealis Agent - SilentUpdate - {uuid.uuid4().hex}" + task_literal = _ps_literal(task_name) + ps_literal = _ps_literal(ps_exe) + root_literal = _ps_literal(root) + args_literal = _ps_literal('-NoProfile -ExecutionPolicy Bypass -File .\\Borealis.ps1 -SilentUpdate') + + cleanup_script = ( + "Start-Job -ScriptBlock { Start-Sleep -Seconds 600; " + f"try {{ Unregister-ScheduledTask -TaskName {task_literal} -Confirm:$false -ErrorAction SilentlyContinue }} catch {{}} }} | Out-Null" + ) + + ps_script = "\n".join( + [ + "$ErrorActionPreference='Stop'", + f"$task = {task_literal}", + "try { Unregister-ScheduledTask -TaskName $task -Confirm:$false -ErrorAction SilentlyContinue } catch {}", + f"$action = New-ScheduledTaskAction -Execute {ps_literal} -Argument {args_literal} -WorkingDirectory {root_literal}", + "$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -DeleteExpiredTaskAfter (New-TimeSpan -Minutes 30)", + "$principal= New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest", + "Register-ScheduledTask -TaskName $task -Action $action -Settings $settings -Principal $principal -Force | Out-Null", + "Start-ScheduledTask -TaskName $task | Out-Null", + cleanup_script, + ] + ) + + flags = 0x08000000 if os.name == 'nt' else 0 + proc = subprocess.run( + [ps_exe, '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', ps_script], + capture_output=True, + text=True, + creationflags=flags, + ) + if proc.returncode != 0: + stderr = proc.stderr or proc.stdout or 'scheduled task registration failed' + raise RuntimeError(stderr.strip()) + def _canonical_env_key(name: str) -> str: cleaned = re.sub(r"[^A-Za-z0-9_]", "_", (name or "").strip()) return cleaned.upper() @@ -258,6 +328,46 @@ class Role: def register_events(self): sio = self.ctx.sio + @sio.on('agent_silent_update') + async def _on_agent_silent_update(payload): + hostname = None + details = payload if isinstance(payload, dict) else {} + try: + import socket + + hostname = socket.gethostname() + target = (details.get('target_hostname') or '').strip().lower() + if target and target != hostname.lower(): + return + + loop = asyncio.get_running_loop() + await loop.run_in_executor(None, _launch_silent_update_task) + + try: + await sio.emit( + 'agent_silent_update_status', + { + 'request_id': details.get('request_id') or '', + 'hostname': hostname, + 'status': 'started', + }, + ) + except Exception: + pass + except Exception as e: + try: + await sio.emit( + 'agent_silent_update_status', + { + 'request_id': details.get('request_id') or '', + 'hostname': hostname or '', + 'status': 'error', + 'error': str(e), + }, + ) + except Exception: + pass + @sio.on('quick_job_run') async def _on_quick_job_run(payload): try: diff --git a/Data/Server/WebUI/src/Devices/Device_List.jsx b/Data/Server/WebUI/src/Devices/Device_List.jsx index c43fadd3..a52f612e 100644 --- a/Data/Server/WebUI/src/Devices/Device_List.jsx +++ b/Data/Server/WebUI/src/Devices/Device_List.jsx @@ -18,7 +18,9 @@ import { MenuItem, Popover, TextField, - Tooltip + Tooltip, + Snackbar, + Alert } from "@mui/material"; import MoreVertIcon from "@mui/icons-material/MoreVert"; import FilterListIcon from "@mui/icons-material/FilterList"; @@ -61,6 +63,8 @@ export default function DeviceList({ onSelectDevice }) { // Track selection by agent id to avoid duplicate hostname collisions const [selectedIds, setSelectedIds] = useState(() => new Set()); const [quickJobOpen, setQuickJobOpen] = useState(false); + const [updateLoading, setUpdateLoading] = useState(false); + const [updateStatus, setUpdateStatus] = useState({ open: false, severity: "success", message: "" }); // Saved custom views (from server) const [views, setViews] = useState([]); // [{id, name, columns:[id], filters:{}}] @@ -422,6 +426,49 @@ export default function DeviceList({ onSelectDevice }) { }); }; + const triggerSilentUpdate = useCallback(async () => { + const hostnames = Array.from( + new Set( + rows + .filter((r) => selectedIds.has(r.id)) + .map((r) => r.hostname) + .filter(Boolean) + ) + ); + if (!hostnames.length) return; + + setUpdateLoading(true); + try { + const resp = await fetch('/api/agents/silent_update', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ hostnames }), + }); + let payload = null; + try { + payload = await resp.json(); + } catch (err) { + payload = null; + } + if (!resp.ok || (payload && payload.error)) { + const msg = payload && payload.error ? payload.error : `HTTP ${resp.status}`; + throw new Error(msg); + } + const count = Array.isArray(payload?.results) ? payload.results.length : hostnames.length; + const requestId = typeof payload?.request_id === 'string' && payload.request_id ? payload.request_id : ''; + setUpdateStatus({ + open: true, + severity: 'success', + message: `Silent update triggered for ${count} device${count === 1 ? '' : 's'}${requestId ? ` (request ${requestId})` : ''}.`, + }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + setUpdateStatus({ open: true, severity: 'error', message: `Failed to trigger silent update: ${message}` }); + } finally { + setUpdateLoading(false); + } + }, [rows, selectedIds]); + // Column drag handlers const onHeaderDragStart = (colId) => (e) => { dragColId.current = colId; @@ -572,7 +619,23 @@ export default function DeviceList({ onSelectDevice }) { {/* Second row: Quick Job button aligned under header title */} - + +