diff --git a/.gitignore b/.gitignore index 0d91707..a780e80 100644 --- a/.gitignore +++ b/.gitignore @@ -5,9 +5,12 @@ Borealis-Agent.exe Borealis-Server.exe # Production Deployment Folders -/Server/ /Agent/ +/Server/ /ElectronApp/ +/Databases/ +/Logs/ +/Temp/ # On-the-Fly Downloaded Dependencies /Dependencies/NodeJS/ @@ -15,11 +18,9 @@ Borealis-Server.exe /Dependencies/AutoHotKey/ /Data/Server/Python_API_Endpoints/Tesseract-OCR/ -# Server-Level Database -/Databases/ - # Misc Files/Folders .vs/s +__pycache__ /Update_Staging/ agent_settings.json users.json \ No newline at end of file diff --git a/Borealis.ps1 b/Borealis.ps1 index d3f8976..b5b88e9 100644 --- a/Borealis.ps1 +++ b/Borealis.ps1 @@ -282,6 +282,327 @@ function Install_Agent_Dependencies { } } +#<# ---------------------- Agent Service Helper Functions (Windows) [Deprecated: superseded by Data/Agent/agent_deployment.py] ---------------------- +function Ensure-BorealisAgent-Service { + param( + [string]$VenvPython, + [string]$ServiceScript + ) + if (-not (Test-IsWindows)) { + Write-Host "Agent service setup skipped (non-Windows)." -ForegroundColor DarkGray + return + } + + $serviceName = 'BorealisAgent' + $svc = Get-CimInstance -ClassName Win32_Service -Filter "Name='$serviceName'" -ErrorAction SilentlyContinue + $needsInstall = $false + if (-not $svc) { $needsInstall = $true } + else { + if ($svc.StartMode -notin @('Auto','Automatic')) { $needsInstall = $true } + if ($svc.StartName -ne 'LocalSystem') { $needsInstall = $true } + # Verify the service points to the current project venv (folder may have moved) + try { + $venvRoot = Split-Path $VenvPython -Parent + $pathName = $svc.PathName + if ($pathName -and $venvRoot) { + if ($pathName -notlike ("*" + $venvRoot + "*")) { $needsInstall = $true } + } + } catch {} + } + + if ($needsInstall) { + if (-not (Test-IsAdmin)) { + Write-Host "Admin rights required to install/update the agent service. Prompting UAC..." -ForegroundColor Yellow + $tmp = New-TemporaryFile + $logDir = Join-Path $scriptDir 'Logs' + if (-not (Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir -Force | Out-Null } + $log = Join-Path $logDir 'Borealis_AgentService_Install.log' + $vEsc = $VenvPython.Replace('"','""') + $sEsc = $ServiceScript.Replace('"','""') + $content = @" +$ErrorActionPreference = 'Continue' +$venv = "$vEsc" +$srv = "$sEsc" +$logPath = "$log" +try { + try { New-Item -ItemType Directory -Force -Path (Split-Path $logPath -Parent) | Out-Null } catch {} + # Ensure pywin32 postinstall is applied in this venv (required for services) + try { + & $venv -m pywin32_postinstall -install *>&1 | Tee-Object -FilePath $logPath -Append + } catch { } + "[INFO] Installing service via: $venv $srv --startup auto install" | Out-File -FilePath $logPath -Encoding UTF8 + & $venv $srv --startup auto install *>&1 | Tee-Object -FilePath $logPath -Append + try { sc.exe config BorealisAgent obj= LocalSystem | Tee-Object -FilePath $logPath -Append } catch {} + $code = $LASTEXITCODE + if ($code -eq $null) { $code = 0 } + "[INFO] Exit code: $code" | Tee-Object -FilePath $logPath -Append | Out-Host +} catch { + "[ERROR] $_" | Tee-Object -FilePath $logPath -Append | Out-Host + $code = 1 +} finally { + if ($code -ne 0 -or $env:BOREALIS_DEBUG_UAC -eq '1') { + Read-Host 'Press Enter to close this elevated window...' + } + exit $code +} +"@ + # pass key vars via env so the elevated scope can see them + $psi = New-Object System.Diagnostics.ProcessStartInfo + $psi.FileName = 'powershell.exe' + $psi.Verb = 'runas' + $psi.Arguments = "-NoProfile -ExecutionPolicy Bypass -File `"$($tmp.FullName)`"" + $psi.UseShellExecute = $true + $psi.WindowStyle = 'Normal' + Set-Content -Path $tmp.FullName -Value $content -Force -Encoding UTF8 + $proc = [System.Diagnostics.Process]::Start($psi) + $proc.WaitForExit() + Remove-Item $tmp.FullName -Force -ErrorAction SilentlyContinue + if (Test-Path $log) { + Write-Host "--- Elevated install log: $log ---" -ForegroundColor DarkCyan + Get-Content $log | Write-Host + } + } else { + try { & $VenvPython $ServiceScript remove } catch {} + & $VenvPython $ServiceScript --startup auto install + try { sc.exe config BorealisAgent obj= LocalSystem } catch {} + } + # Refresh service object + $svc = Get-CimInstance -ClassName Win32_Service -Filter "Name='$serviceName'" -ErrorAction SilentlyContinue + if (-not $svc) { + Write-Host "Failed to install Borealis Agent service." -ForegroundColor Red + return + } + } + + if ($svc.State -ne 'Running') { + if (-not (Test-IsAdmin)) { + Write-Host "Admin rights required to start the agent service. Prompting UAC..." -ForegroundColor Yellow + $tmp2 = New-TemporaryFile + $log2 = Join-Path $scriptDir 'Logs\Borealis_AgentService_Start.log' + $content2 = @" +$ErrorActionPreference = 'Continue' +try { + Start-Service -Name '$serviceName' + "[INFO] Started Borealis Agent service." | Out-File -FilePath "$log2" -Encoding UTF8 + $code = 0 +} catch { + "[ERROR] $_" | Out-File -FilePath "$log2" -Encoding UTF8 + $code = 1 +} finally { + if ($code -ne 0 -or $env:BOREALIS_DEBUG_UAC -eq '1') { Read-Host 'Press Enter to close this elevated window...' } + exit $code +} +"@ + Set-Content -Path $tmp2.FullName -Value $content2 -Force -Encoding UTF8 + $psi2 = New-Object System.Diagnostics.ProcessStartInfo + $psi2.FileName = 'powershell.exe' + $psi2.Verb = 'runas' + $psi2.Arguments = "-NoProfile -ExecutionPolicy Bypass -File `"$($tmp2.FullName)`"" + $psi2.UseShellExecute = $true + $psi2.WindowStyle = 'Normal' + $proc2 = [System.Diagnostics.Process]::Start($psi2) + $proc2.WaitForExit() + Remove-Item $tmp2.FullName -Force -ErrorAction SilentlyContinue + if (Test-Path $log2) { + Write-Host "--- Elevated start log: $log2 ---" -ForegroundColor DarkCyan + Get-Content $log2 | Write-Host + } + } else { + Start-Service -Name $serviceName + } + } + + Write-Host "Borealis Agent service is installed and running as LocalSystem (Automatic)." -ForegroundColor Green +} + +# Ensure Script Agent (LocalSystem Windows Service) +function Ensure-BorealisScriptAgent-Service { + param( + [string]$VenvPython, + [string]$ServiceScript + ) + if (-not (Test-IsWindows)) { return } + $serviceName = 'BorealisScriptService' + $svc = Get-CimInstance -ClassName Win32_Service -Filter "Name='$serviceName'" -ErrorAction SilentlyContinue + $needsInstall = $false + if (-not $svc) { $needsInstall = $true } + else { + if ($svc.StartMode -notin @('Auto','Automatic')) { $needsInstall = $true } + if ($svc.StartName -ne 'LocalSystem') { $needsInstall = $true } + try { + $venvRoot = Split-Path $VenvPython -Parent + if ($svc.PathName -and $venvRoot -and ($svc.PathName -notlike ("*" + $venvRoot + "*"))) { $needsInstall = $true } + } catch {} + } + + $logs = Join-Path $scriptDir 'Logs' + $tempDir = Join-Path $scriptDir 'Temp'; if (-not (Test-Path $tempDir)) { New-Item -ItemType Directory -Path $tempDir -Force | Out-Null } + if (-not (Test-Path $logs)) { New-Item -ItemType Directory -Path $logs -Force | Out-Null } + $preflight = Join-Path $logs 'Borealis_ScriptAgent_Preflight.log' + "$(Get-Date -Format s) Preflight: Ensure-BorealisScriptAgent-Service (needsInstall=$needsInstall)" | Out-File -FilePath $preflight -Append -Encoding UTF8 + + if ($needsInstall) { + if (-not (Test-IsAdmin)) { + Write-Host "Admin rights required to install/update the script agent service. Prompting UAC..." -ForegroundColor Yellow + $log = Join-Path $logs 'Borealis_ScriptAgent_Install.log' + $vEsc = $VenvPython.Replace('"','""') + $sEsc = $ServiceScript.Replace('"','""') + $content = @" +$ErrorActionPreference = 'Continue' +$venv = "$vEsc" +$srv = "$sEsc" +$logPath = "$log" +try { + try { New-Item -ItemType Directory -Force -Path (Split-Path $logPath -Parent) | Out-Null } catch {} + & $venv -m pywin32_postinstall -install *>&1 | Tee-Object -FilePath $logPath -Append + try { & $venv $srv remove *>&1 | Tee-Object -FilePath $logPath -Append } catch {} + & $venv $srv --startup auto install *>&1 | Tee-Object -FilePath $logPath -Append + sc.exe config $serviceName obj= LocalSystem | Tee-Object -FilePath $logPath -Append + $code = $LASTEXITCODE; if ($code -eq $null) { $code = 0 } +} catch { + "[ERROR] $_" | Tee-Object -FilePath $logPath -Append | Out-Host + $code = 1 +} finally { + if ($code -ne 0 -or $env:BOREALIS_DEBUG_UAC -eq '1') { Read-Host 'Press Enter to close this elevated window...' } + exit $code +} +"@ + $elevateScript = Join-Path $tempDir 'Elevated-Install-ScriptAgent.ps1' + Set-Content -Path $elevateScript -Value $content -Force -Encoding UTF8 + $psi = New-Object System.Diagnostics.ProcessStartInfo + $psi.FileName = 'powershell.exe' + $psi.Verb = 'runas' + $psi.Arguments = "-NoProfile -ExecutionPolicy Bypass -File `"$elevateScript`"" + $psi.UseShellExecute = $true + $psi.WindowStyle = 'Normal' + $psi.WorkingDirectory = $scriptDir + $proc = [System.Diagnostics.Process]::Start($psi) + $proc.WaitForExit() + if ($proc.ExitCode -eq 0) { Remove-Item $elevateScript -Force -ErrorAction SilentlyContinue } else { Write-Host "Keeping stub for troubleshooting: $elevateScript" -ForegroundColor Yellow } + if (Test-Path $log) { Write-Host "--- Elevated install log: $log ---" -ForegroundColor DarkCyan; Get-Content $log | Write-Host } + else { Write-Host "Install log not found at: $log" -ForegroundColor Yellow } + if ($proc.ExitCode -ne 0) { Write-Host "Elevated install returned code $($proc.ExitCode)" -ForegroundColor Red; return } + } else { + try { & $VenvPython $ServiceScript remove } catch {} + & $VenvPython $ServiceScript --startup auto install + try { sc.exe config $serviceName obj= LocalSystem } catch {} + } + } + + $svc = Get-CimInstance -ClassName Win32_Service -Filter "Name='$serviceName'" -ErrorAction SilentlyContinue + if (-not $svc) { Write-Host "ScriptAgent service not found after install." -ForegroundColor Red; return $false } + if ($svc.State -ne 'Running') { + if (-not (Test-IsAdmin)) { + Write-Host "Admin rights required to start the script agent service. Prompting UAC..." -ForegroundColor Yellow + $log2 = Join-Path $logs 'Borealis_ScriptAgent_Start.log' + $content2 = @" +$ErrorActionPreference = 'Continue' +try { Start-Service -Name '$serviceName'; "[INFO] Started." | Out-File -FilePath "$log2" -Encoding UTF8; $code = 0 } catch { "[ERROR] $_" | Out-File -FilePath "$log2" -Encoding UTF8; $code = 1 } finally { if ($code -ne 0 -or $env:BOREALIS_DEBUG_UAC -eq '1') { Read-Host 'Press Enter to close this elevated window...' }; exit $code } +"@ + $elevateStart = Join-Path $tempDir 'Elevated-Start-ScriptAgent.ps1' + Set-Content -Path $elevateStart -Value $content2 -Force -Encoding UTF8 + $psi2 = New-Object System.Diagnostics.ProcessStartInfo + $psi2.FileName = 'powershell.exe' + $psi2.Verb = 'runas' + $psi2.Arguments = "-NoProfile -ExecutionPolicy Bypass -File `"$elevateStart`"" + $psi2.UseShellExecute = $true + $psi2.WindowStyle = 'Normal' + $psi2.WorkingDirectory = $scriptDir + $proc2 = [System.Diagnostics.Process]::Start($psi2) + $proc2.WaitForExit() + if ($proc2.ExitCode -eq 0) { Remove-Item $elevateStart -Force -ErrorAction SilentlyContinue } else { Write-Host "Keeping stub for troubleshooting: $elevateStart" -ForegroundColor Yellow } + if (Test-Path $log2) { Write-Host "--- Elevated start log: $log2 ---" -ForegroundColor DarkCyan; Get-Content $log2 | Write-Host } + else { Write-Host "Start log not found at: $log2" -ForegroundColor Yellow } + if ($proc2.ExitCode -ne 0) { Write-Host "Elevated start returned code $($proc2.ExitCode)" -ForegroundColor Red; return } + } else { + Start-Service -Name $serviceName + } + Start-Sleep -Seconds 2 + $svc = Get-CimInstance -ClassName Win32_Service -Filter "Name='$serviceName'" -ErrorAction SilentlyContinue + if ($svc.State -ne 'Running') { Write-Host "ScriptAgent service failed to start." -ForegroundColor Red; return $false } + } + return $true +} + +# Ensure Collector Agent (per-user scheduled task) +function Ensure-BorealisCollector-Task { + param( + [string]$VenvPython, + [string]$CollectorScript + ) + $logs = Join-Path $scriptDir 'Logs' + if (-not (Test-Path $logs)) { New-Item -ItemType Directory -Path $logs -Force | Out-Null } + "$(Get-Date -Format s) Preflight: Ensure-BorealisCollector-Task" | Out-File -FilePath (Join-Path $logs 'Borealis_Collector_Preflight.log') -Append -Encoding UTF8 + + $tempDir = Join-Path $scriptDir 'Temp' + if (-not (Test-Path $tempDir)) { New-Item -ItemType Directory -Path $tempDir -Force | Out-Null } + $logC = Join-Path $logs 'Borealis_CollectorTask_Install.log' + + $taskName = 'BorealisCollectorAgent' + $taskExists = $false + try { + $null = schtasks.exe /Query /TN $taskName 2>$null + if ($LASTEXITCODE -eq 0) { $taskExists = $true } + } catch {} + + $quoted = '"' + $VenvPython + '" -W ignore::SyntaxWarning "' + $CollectorScript + '"' + + if ($taskExists) { + # Replace to ensure it points to current project + # Attempt delete in current user context first + try { + schtasks.exe /Delete /TN $taskName /F 2>&1 | Tee-Object -FilePath $logC -Append | Out-Host + } catch {} + if ($LASTEXITCODE -ne 0) { + # If deletion failed (likely created under elevated/Admin previously), delete via elevated stub + $stubDel = Join-Path $tempDir 'Elevated-Delete-CollectorTask.ps1' + $tnEsc = $taskName.Replace('"','""') + $lgEsc = $logC.Replace('"','""') + $contentDel = @" +$ErrorActionPreference = 'Continue' +try { + schtasks.exe /Delete /TN "$tnEsc" /F *>&1 | Tee-Object -FilePath "$lgEsc" -Append + $code = $LASTEXITCODE; if ($code -eq $null) { $code = 0 } +} catch { "[ERROR] $_" | Tee-Object -FilePath "$lgEsc" -Append; $code = 1 } finally { + if ($code -ne 0 -or $env:BOREALIS_DEBUG_UAC -eq '1') { Read-Host 'Press Enter to close this elevated window...' } + exit $code +} +"@ + Set-Content -Path $stubDel -Value $contentDel -Force -Encoding UTF8 + $psiD = New-Object System.Diagnostics.ProcessStartInfo + $psiD.FileName = 'powershell.exe' + $psiD.Verb = 'runas' + $psiD.Arguments = "-NoProfile -ExecutionPolicy Bypass -File `"$stubDel`"" + $psiD.UseShellExecute = $true + $psiD.WindowStyle = 'Normal' + try { $psiD.WorkingDirectory = $scriptDir; $pd = [System.Diagnostics.Process]::Start($psiD); $pd.WaitForExit() } catch {} + Remove-Item $stubDel -Force -ErrorAction SilentlyContinue + } + # Re-evaluate existence; if still present, abort + try { $null = schtasks.exe /Query /TN $taskName 2>$null } catch {} + if ($LASTEXITCODE -eq 0) { + Write-Host "Failed to remove existing Collector task (permissions)." -ForegroundColor Red + if (Test-Path $logC) { Write-Host "--- Collector task log: $logC ---" -ForegroundColor DarkCyan; Get-Content $logC | Write-Host } + return $false + } + } + + # Create per-user task in the current (non-elevated) user context + # Do NOT elevate creation to avoid creating task under Administrator by mistake + schtasks.exe /Create /SC ONLOGON /TN $taskName /TR $quoted /F /RL LIMITED 2>&1 | Tee-Object -FilePath $logC -Append | Out-Host + if ($LASTEXITCODE -ne 0) { + Write-Host "Failed to create Collector task." -ForegroundColor Red + if (Test-Path $logC) { Write-Host "--- Collector task log: $logC ---" -ForegroundColor DarkCyan; Get-Content $logC | Write-Host } + return $false + } + # If currently logged in, start now + try { schtasks.exe /Run /TN $taskName 2>&1 | Tee-Object -FilePath $logC -Append | Out-Host } catch {} + # Validate task exists + try { $null = schtasks.exe /Query /TN $taskName 2>$null } catch {} + if ($LASTEXITCODE -ne 0) { Write-Host "Collector task missing after create." -ForegroundColor Red; return $false } + return $true +} +#> # ---------------------- Common Initialization & Visuals ---------------------- Clear-Host @@ -504,6 +825,10 @@ switch ($choice) { Copy-Item "Data\Agent\agent_info.py" $agentDestinationFolder -Recurse Copy-Item "Data\Agent\agent_roles.py" $agentDestinationFolder -Recurse Copy-Item "Data\Agent\Python_API_Endpoints" $agentDestinationFolder -Recurse + Copy-Item "Data\Agent\windows_script_service.py" $agentDestinationFolder -Force + Copy-Item "Data\Agent\agent_deployment.py" $agentDestinationFolder -Force + Copy-Item "Data\Agent\tray_launcher.py" $agentDestinationFolder -Force + if (Test-Path "Data\Agent\Borealis.ico") { Copy-Item "Data\Agent\Borealis.ico" $agentDestinationFolder -Force } } . "$venvFolder\Scripts\Activate" } @@ -514,9 +839,17 @@ switch ($choice) { } } - Write-Host "`nLaunching Borealis Agent..." -ForegroundColor Blue + Write-Host "`nConfiguring Borealis Agent (service + logon task)..." -ForegroundColor Blue Write-Host "====================================================================================" - & $venvPython -W ignore::SyntaxWarning $agentDestinationFile + $deployScript = Join-Path $agentDestinationFolder 'agent_deployment.py' + & $venvPython -W ignore::SyntaxWarning $deployScript ensure-all + if ($LASTEXITCODE -ne 0) { + Write-Host "Agent setup FAILED." -ForegroundColor Red + Write-Host " - See logs under: $(Join-Path $scriptDir 'Logs')" -ForegroundColor Red + exit 1 + } else { + Write-Host "Agent setup complete. Service + logon task ensured." -ForegroundColor DarkGreen + } } "3" { diff --git a/Data/Agent/agent-requirements.txt b/Data/Agent/agent-requirements.txt index d8edbab..04f735c 100644 --- a/Data/Agent/agent-requirements.txt +++ b/Data/Agent/agent-requirements.txt @@ -24,4 +24,5 @@ pywinauto # Windows-based Macro Automation Library # Audio Streaming Dependencies sounddevice -numpy \ No newline at end of file +numpy +pywin32; platform_system == "Windows" diff --git a/Data/Agent/agent_deployment.py b/Data/Agent/agent_deployment.py new file mode 100644 index 0000000..a5cb6a3 --- /dev/null +++ b/Data/Agent/agent_deployment.py @@ -0,0 +1,372 @@ +import os +import sys +import subprocess +import shlex +import ctypes +from datetime import datetime + + +def _now(): + return datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + +def project_paths(): + r"""Derive important paths relative to the venv python (sys.executable). + + Layout assumed at runtime: + /Agent/Scripts/python.exe == sys.executable + /Agent/Borealis/*.py == deployed agent files + """ + venv_scripts = os.path.dirname(os.path.abspath(sys.executable)) + venv_root = os.path.abspath(os.path.join(venv_scripts, os.pardir)) + project_root = os.path.abspath(os.path.join(venv_root, os.pardir)) + borealis_dir = os.path.join(venv_root, "Borealis") + logs_dir = os.path.join(project_root, "Logs") + temp_dir = os.path.join(project_root, "Temp") + return { + "project_root": project_root, + "venv_root": venv_root, + "venv_python": sys.executable, + "venv_pythonw": os.path.join(venv_scripts, "pythonw.exe"), + "borealis_dir": borealis_dir, + "logs_dir": logs_dir, + "temp_dir": temp_dir, + "service_script": os.path.join(borealis_dir, "windows_script_service.py"), + # Use tray launcher for the scheduled task + "agent_script": os.path.join(borealis_dir, "tray_launcher.py"), + } + + +def ensure_dirs(paths): + os.makedirs(paths["logs_dir"], exist_ok=True) + os.makedirs(paths["temp_dir"], exist_ok=True) + + +def log_write(paths, name, text): + try: + p = os.path.join(paths["logs_dir"], name) + with open(p, "a", encoding="utf-8") as f: + f.write(f"{_now()} {text}\n") + except Exception: + pass + + +def is_admin(): + try: + return ctypes.windll.shell32.IsUserAnAdmin() != 0 # type: ignore[attr-defined] + except Exception: + return False + + +def run(cmd, cwd=None, capture=False): + if isinstance(cmd, str): + shell = True + args = cmd + else: + shell = False + args = cmd + return subprocess.run( + args, + cwd=cwd, + shell=shell, + text=True, + capture_output=capture, + check=False, + ) + + +def run_elevated_powershell(paths, ps_content, log_name): + """Run a short PowerShell script elevated and wait for completion. + Writes combined output to Logs/log_name. + """ + ensure_dirs(paths) + log_path = os.path.join(paths["logs_dir"], log_name) + stub_path = os.path.join(paths["temp_dir"], f"elevate_{os.getpid()}_{log_name.replace('.', '_')}.ps1") + with open(stub_path, "w", encoding="utf-8") as f: + f.write(ps_content) + + # Build powershell command + ps = "powershell.exe" + args = f"-NoProfile -ExecutionPolicy Bypass -File \"{stub_path}\"" + + # ShellExecute to run as admin + SEE_MASK_NOCLOSEPROCESS = 0x00000040 + class SHELLEXECUTEINFO(ctypes.Structure): + _fields_ = [ + ("cbSize", ctypes.c_ulong), + ("fMask", ctypes.c_ulong), + ("hwnd", ctypes.c_void_p), + ("lpVerb", ctypes.c_wchar_p), + ("lpFile", ctypes.c_wchar_p), + ("lpParameters", ctypes.c_wchar_p), + ("lpDirectory", ctypes.c_wchar_p), + ("nShow", ctypes.c_int), + ("hInstApp", ctypes.c_void_p), + ("lpIDList", ctypes.c_void_p), + ("lpClass", ctypes.c_wchar_p), + ("hkeyClass", ctypes.c_void_p), + ("dwHotKey", ctypes.c_ulong), + ("hIcon", ctypes.c_void_p), + ("hProcess", ctypes.c_void_p), + ] + + sei = SHELLEXECUTEINFO() + sei.cbSize = ctypes.sizeof(SHELLEXECUTEINFO) + sei.fMask = SEE_MASK_NOCLOSEPROCESS + sei.hwnd = None + sei.lpVerb = "runas" + sei.lpFile = ps + sei.lpParameters = args + sei.lpDirectory = paths["project_root"] + sei.nShow = 1 + if not ctypes.windll.shell32.ShellExecuteExW(ctypes.byref(sei)): + log_write(paths, log_name, "[ERROR] UAC elevation failed (ShellExecuteExW)") + try: + os.remove(stub_path) + except Exception: + pass + return 1 + # Wait for elevated process + ctypes.windll.kernel32.WaitForSingleObject(sei.hProcess, 0xFFFFFFFF) + # Capture output from stub if it appended + try: + with open(log_path, "a", encoding="utf-8") as f: + f.write("") + except Exception: + pass + try: + os.remove(stub_path) + except Exception: + pass + return 0 + + +def current_service_path(service_name): + """Return the configured BinaryPathName for a service (or None).""" + try: + r = run(["sc.exe", "qc", service_name], capture=True) + if r.returncode != 0: + return None + for line in (r.stdout or "").splitlines(): + if "BINARY_PATH_NAME" in line: + # Example: "BINARY_PATH_NAME : C:\\...\\python.exe C:\\...\\windows_script_service.py" + parts = line.split(":", 1) + if len(parts) == 2: + return parts[1].strip() + except Exception: + pass + return None + + +def ensure_script_service(paths): + service_name = "BorealisScriptService" + log_name = "Borealis_ScriptService_Install.log" + ensure_dirs(paths) + log_write(paths, log_name, "[INFO] Ensuring script execution service...") + + # Decide if install/update needed + need_install = False + bin_path = current_service_path(service_name) + expected_root = paths["venv_root"].lower() + if not bin_path: + need_install = True + else: + if expected_root not in bin_path.lower(): + need_install = True + + if not is_admin(): + # Relaunch elevated to perform service installation/update + venv = paths["venv_python"].replace("\"", "\"\"") + srv = paths["service_script"].replace("\"", "\"\"") + log = os.path.join(paths["logs_dir"], log_name).replace("\"", "\"\"") + venv_dir = os.path.dirname(paths["venv_python"]).replace("\"", "\"\"") + postinstall = os.path.join(venv_dir, "pywin32_postinstall.py").replace("\"", "\"\"") + py_home = paths["venv_root"].replace("\"", "\"\"") + content = f""" +$ErrorActionPreference = 'Continue' +$venv = "{venv}" +$srv = "{srv}" +$log = "{log}" +$post = "{postinstall}" +$pyhome = "{py_home}" +try {{ + try {{ New-Item -ItemType Directory -Force -Path (Split-Path $log -Parent) | Out-Null }} catch {{}} + # Remove legacy service name if present + try {{ sc.exe stop BorealisScriptAgent 2>$null | Out-Null }} catch {{}} + try {{ sc.exe delete BorealisScriptAgent 2>$null | Out-Null }} catch {{}} + if (Test-Path $post) {{ & $venv $post -install *>> "$log" }} else {{ & $venv -m pywin32_postinstall -install *>> "$log" }} + try {{ & $venv $srv remove *>> "$log" }} catch {{}} + & $venv $srv --startup auto install *>> "$log" + # Ensure registry points to correct module and PY path + reg add "HKLM\SYSTEM\CurrentControlSet\Services\{service_name}\PythonClass" /ve /t REG_SZ /d "windows_script_service.BorealisScriptAgentService" /f | Out-Null + reg add "HKLM\SYSTEM\CurrentControlSet\Services\{service_name}\PythonPath" /ve /t REG_SZ /d "{paths['borealis_dir']}" /f | Out-Null + reg add "HKLM\SYSTEM\CurrentControlSet\Services\{service_name}\PythonHome" /ve /t REG_SZ /d "$pyhome" /f | Out-Null + sc.exe config {service_name} obj= LocalSystem start= auto | Out-File -FilePath "$log" -Append -Encoding UTF8 + sc.exe start {service_name} | Out-File -FilePath "$log" -Append -Encoding UTF8 + "[INFO] Completed service ensure." | Out-File -FilePath "$log" -Append -Encoding UTF8 +}} catch {{ + "[ERROR] $_" | Out-File -FilePath "$log" -Append -Encoding UTF8 + exit 1 +}} +""" + rc = run_elevated_powershell(paths, content, log_name) + return rc == 0 + + # Admin path: perform directly + try: + # Ensure pywin32 service hooks present in this venv + post_py = os.path.join(os.path.dirname(paths["venv_python"]), "pywin32_postinstall.py") + if os.path.isfile(post_py): + run([paths["venv_python"], post_py, "-install"]) # ignore rc + else: + run([paths["venv_python"], "-m", "pywin32_postinstall", "-install"]) # ignore rc + except Exception: + pass + try: + # Remove legacy service if it exists + run(["sc.exe", "stop", "BorealisScriptAgent"]) # ignore rc + run(["sc.exe", "delete", "BorealisScriptAgent"]) # ignore rc + if need_install: + run([paths["venv_python"], paths["service_script"], "remove"]) # ignore rc + r1 = run([paths["venv_python"], paths["service_script"], "--startup", "auto", "install"], capture=True) + log_write(paths, log_name, f"[INFO] install rc={r1.returncode} out={r1.stdout}\nerr={r1.stderr}") + # fix registry for module import and path + run(["reg", "add", fr"HKLM\\SYSTEM\\CurrentControlSet\\Services\\{service_name}\\PythonClass", "/ve", "/t", "REG_SZ", "/d", "windows_script_service.BorealisScriptAgentService", "/f"]) # noqa + run(["reg", "add", fr"HKLM\\SYSTEM\\CurrentControlSet\\Services\\{service_name}\\PythonPath", "/ve", "/t", "REG_SZ", "/d", paths["borealis_dir"], "/f"]) # noqa + run(["reg", "add", fr"HKLM\\SYSTEM\\CurrentControlSet\\Services\\{service_name}\\PythonHome", "/ve", "/t", "REG_SZ", "/d", paths["venv_root"], "/f"]) # noqa + run(["sc.exe", "config", service_name, "obj=", "LocalSystem"]) # ensure LocalSystem + run(["sc.exe", "start", service_name]) + # quick validate + qc = run(["sc.exe", "query", service_name], capture=True) + ok = (qc.returncode == 0) + log_write(paths, log_name, f"[INFO] ensure complete (ok={ok})") + return ok + except Exception as e: + log_write(paths, log_name, f"[ERROR] ensure (admin) failed: {e}") + return False + + +def ensure_user_logon_task(paths): + """Ensure the per-user scheduled task that launches the agent in the user's session. + + Name: "Borealis Agent" + Trigger: On logon (current user) + Action: -W ignore::SyntaxWarning + """ + ensure_dirs(paths) + log_name = "Borealis_CollectorTask_Install.log" + task_name = "Borealis Agent" + # Use pythonw.exe to avoid opening a console window in the user's session + pyw = paths.get("venv_pythonw") or paths["venv_python"] + cmd = f"\"{pyw}\" -W ignore::SyntaxWarning \"{paths['agent_script']}\"" + + # If task exists, try remove (non-elevated first) + q = run(["schtasks.exe", "/Query", "/TN", task_name]) + if q.returncode == 0: + d = run(["schtasks.exe", "/Delete", "/TN", task_name, "/F"], capture=True) + if d.returncode != 0: + # Attempt elevated deletion (task might have been created under admin previously) + ps = f""" +$ErrorActionPreference = 'SilentlyContinue' +try {{ schtasks.exe /Delete /TN "{task_name}" /F | Out-Null }} catch {{}} +""" + run_elevated_powershell(paths, ps, log_name) + + c = run(["schtasks.exe", "/Create", "/SC", "ONLOGON", "/TN", task_name, "/TR", cmd, "/F", "/RL", "LIMITED"], capture=True) + log_write(paths, log_name, f"[INFO] create rc={c.returncode} out={c.stdout} err={c.stderr}") + if c.returncode != 0: + # Fallback: elevate and register task for the current user SID + user = os.environ.get("USERNAME", "") + domain = os.environ.get("USERDOMAIN", os.environ.get("COMPUTERNAME", "")) + py = (paths.get("venv_pythonw") or paths["venv_python"]).replace("\"", "\"\"") + ag = paths["agent_script"].replace("\"", "\"\"") + content = f""" +$ErrorActionPreference = 'Continue' +$task = "{task_name}" +$py = "{py}" +$arg = "-W ignore::SyntaxWarning {ag}" +$cmd = '"' + $py + '" ' + $arg +$user = "{domain}\{user}" +try {{ + # Resolve user SID + $sid = (New-Object System.Security.Principal.NTAccount($user)).Translate([System.Security.Principal.SecurityIdentifier]).Value +}} catch {{ $sid = $null }} +try {{ + # Delete any existing task (any scope) + try {{ schtasks.exe /Delete /TN $task /F | Out-Null }} catch {{}} + $action = New-ScheduledTaskAction -Execute $py -Argument $arg + $trigger = New-ScheduledTaskTrigger -AtLogOn + $settings = New-ScheduledTaskSettingsSet -Hidden + if ($sid) {{ + $principal = New-ScheduledTaskPrincipal -UserId $sid -LogonType Interactive -RunLevel Limited + Register-ScheduledTask -TaskName $task -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force + }} else {{ + # Fallback: bind by username (use Interactive to avoid password) + $principal = New-ScheduledTaskPrincipal -UserId "{domain}\{user}" -LogonType Interactive -RunLevel Limited + Register-ScheduledTask -TaskName $task -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force + }} +}} catch {{ + "[ERROR] Task register failed: $_" | Out-File -FilePath "{os.path.join(paths['logs_dir'], log_name)}" -Append -Encoding UTF8 + exit 1 +}} +""" + rc = run_elevated_powershell(paths, content, log_name) + if rc != 0: + return False + else: + # Created via schtasks; set Hidden=true via elevated PowerShell (schtasks lacks a /HIDDEN switch) + ps_hide = f""" +$ErrorActionPreference = 'SilentlyContinue' +try {{ + $settings = New-ScheduledTaskSettingsSet -Hidden + Set-ScheduledTask -TaskName "{task_name}" -Settings $settings | Out-Null +}} catch {{}} +""" + run_elevated_powershell(paths, ps_hide, log_name) + # Start immediately (if a session is active) + run(["schtasks.exe", "/Run", "/TN", task_name]) + return True + + +def ensure_all(): + paths = project_paths() + ensure_dirs(paths) + ok_svc = ensure_script_service(paths) + ok_task = ensure_user_logon_task(paths) + return 0 if (ok_svc and ok_task) else 1 + + +def main(argv): + # Simple CLI + if len(argv) <= 1: + print("Usage: agent_deployment.py [ensure-all|service-install|service-remove|task-ensure|task-remove]") + return 2 + cmd = argv[1].lower() + paths = project_paths() + ensure_dirs(paths) + + if cmd == "ensure-all": + return ensure_all() + if cmd == "service-install": + return 0 if ensure_script_service(paths) else 1 + if cmd == "service-remove": + name = "BorealisScriptService" + if not is_admin(): + ps = f"try {{ sc.exe stop {name} }} catch {{}}; try {{ sc.exe delete {name} }} catch {{}}" + return run_elevated_powershell(paths, ps, "Borealis_ScriptService_Remove.log") + run(["sc.exe", "stop", name]) + r = run(["sc.exe", "delete", name]) + return r.returncode + if cmd == "task-ensure": + return 0 if ensure_user_logon_task(paths) else 1 + if cmd == "task-remove": + tn = "Borealis Agent" + r = run(["schtasks.exe", "/Delete", "/TN", tn, "/F"]) + return r.returncode + + print(f"Unknown command: {cmd}") + return 2 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/Data/Agent/borealis-agent.py b/Data/Agent/borealis-agent.py index 4702351..98e8929 100644 --- a/Data/Agent/borealis-agent.py +++ b/Data/Agent/borealis-agent.py @@ -362,6 +362,204 @@ sio = socketio.AsyncClient(reconnection=True, reconnection_attempts=0, reconnect role_tasks = {} background_tasks = [] roles_ctx = None +AGENT_LOOP = None + +# ---------------- Local IPC Bridge (Service -> Agent) ---------------- +def start_agent_bridge_pipe(loop_ref): + import threading + import win32pipe, win32file, win32con, pywintypes + + pipe_name = r"\\.\pipe\Borealis_Agent_Bridge" + + def forward_to_server(msg: dict): + try: + evt = msg.get('type') + if evt == 'screenshot': + payload = { + 'agent_id': AGENT_ID, + 'node_id': msg.get('node_id') or 'user_session', + 'image_base64': msg.get('image_base64') or '', + 'timestamp': msg.get('timestamp') or int(time.time()) + } + asyncio.run_coroutine_threadsafe(sio.emit('agent_screenshot_task', payload), loop_ref) + except Exception: + pass + + def server_thread(): + while True: + try: + handle = win32pipe.CreateNamedPipe( + pipe_name, + win32con.PIPE_ACCESS_DUPLEX, + win32con.PIPE_TYPE_MESSAGE | win32con.PIPE_READMODE_MESSAGE | win32con.PIPE_WAIT, + 1, 65536, 65536, 0, None) + except pywintypes.error: + time.sleep(1.0) + continue + + try: + win32pipe.ConnectNamedPipe(handle, None) + except pywintypes.error: + try: + win32file.CloseHandle(handle) + except Exception: + pass + time.sleep(0.5) + continue + + # Read loop per connection + try: + while True: + try: + hr, data = win32file.ReadFile(handle, 65536) + if not data: + break + try: + obj = json.loads(data.decode('utf-8', errors='ignore')) + forward_to_server(obj) + except Exception: + pass + except pywintypes.error: + break + finally: + try: + win32file.CloseHandle(handle) + except Exception: + pass + time.sleep(0.2) + + t = threading.Thread(target=server_thread, daemon=True) + t.start() + +def send_service_control(msg: dict): + try: + import win32file + pipe = r"\\.\pipe\Borealis_Service_Control" + h = win32file.CreateFile( + pipe, + win32file.GENERIC_WRITE, + 0, + None, + win32file.OPEN_EXISTING, + 0, + None, + ) + try: + win32file.WriteFile(h, json.dumps(msg).encode('utf-8')) + finally: + win32file.CloseHandle(h) + except Exception: + pass +IS_WINDOWS = sys.platform.startswith('win') + +def _is_admin_windows(): + if not IS_WINDOWS: + return False + try: + import ctypes + return ctypes.windll.shell32.IsUserAnAdmin() != 0 + except Exception: + return False + +def _write_temp_script(content: str, suffix: str): + import tempfile + temp_dir = os.path.join(tempfile.gettempdir(), "Borealis", "quick_jobs") + os.makedirs(temp_dir, exist_ok=True) + fd, path = tempfile.mkstemp(prefix="bj_", suffix=suffix, dir=temp_dir, text=True) + with os.fdopen(fd, 'w', encoding='utf-8', newline='\n') as fh: + fh.write(content or "") + return path + +async def _run_powershell_local(path: str): + """Run powershell script as current user hidden window and capture output.""" + ps = None + if IS_WINDOWS: + ps = os.path.expandvars(r"%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe") + if not os.path.isfile(ps): + ps = "powershell.exe" + else: + ps = "pwsh" + try: + proc = await asyncio.create_subprocess_exec( + ps, + "-ExecutionPolicy", "Bypass" if IS_WINDOWS else "Bypass", + "-NoProfile", + "-File", path, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + creationflags=(0x08000000 if IS_WINDOWS else 0) # CREATE_NO_WINDOW + ) + out_b, err_b = await proc.communicate() + rc = proc.returncode + out = (out_b or b"").decode(errors='replace') + err = (err_b or b"").decode(errors='replace') + return rc, out, err + except Exception as e: + return -1, "", str(e) + +async def _run_powershell_as_system(path: str): + """Attempt to run as SYSTEM using schtasks; requires admin.""" + if not IS_WINDOWS: + return -1, "", "SYSTEM run not supported on this OS" + # Name with timestamp to avoid collisions + name = f"Borealis_QuickJob_{int(time.time())}_{random.randint(1000,9999)}" + # Create scheduled task + # Start time: 1 minute from now (HH:MM) + t = time.localtime(time.time() + 60) + st = f"{t.tm_hour:02d}:{t.tm_min:02d}" + create_cmd = [ + "schtasks", "/Create", "/TN", name, + "/TR", f"\"powershell.exe -ExecutionPolicy Bypass -NoProfile -File \"\"{path}\"\"\"", + "/SC", "ONCE", "/ST", st, "/RL", "HIGHEST", "/RU", "SYSTEM", "/F" + ] + try: + p1 = await asyncio.create_subprocess_exec(*create_cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE) + c_out, c_err = await p1.communicate() + if p1.returncode != 0: + return p1.returncode, "", (c_err or b"").decode(errors='replace') + # Run immediately + run_cmd = ["schtasks", "/Run", "/TN", name] + p2 = await asyncio.create_subprocess_exec(*run_cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE) + r_out, r_err = await p2.communicate() + # Give some time for task to run and finish (best-effort) + await asyncio.sleep(5) + # We cannot reliably capture stdout from scheduled task directly; advise writing output to file in script if needed. + # Return status of scheduling; actual script result unknown. We will try to check last run result. + query_cmd = ["schtasks", "/Query", "/TN", name, "/V", "/FO", "LIST"] + p3 = await asyncio.create_subprocess_exec(*query_cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE) + q_out, q_err = await p3.communicate() + status_txt = (q_out or b"").decode(errors='replace') + # Cleanup + await asyncio.create_subprocess_exec("schtasks", "/Delete", "/TN", name, "/F") + # We cannot get stdout/stderr; return status text to stderr and treat success based on return codes + status = "Success" if p2.returncode == 0 else "Failed" + return 0 if status == "Success" else 1, "", status_txt + except Exception as e: + return -1, "", str(e) + +async def _run_powershell_with_credentials(path: str, username: str, password: str): + if not IS_WINDOWS: + return -1, "", "Credentialed run not supported on this OS" + # Build a one-liner to convert plaintext password to SecureString and run Start-Process -Credential + ps_cmd = ( + f"$user=\"{username}\"; " + f"$pass=\"{password}\"; " + f"$sec=ConvertTo-SecureString $pass -AsPlainText -Force; " + f"$cred=New-Object System.Management.Automation.PSCredential($user,$sec); " + f"Start-Process powershell -ArgumentList '-ExecutionPolicy Bypass -NoProfile -File \"{path}\"' -Credential $cred -WindowStyle Hidden -PassThru | Wait-Process;" + ) + try: + proc = await asyncio.create_subprocess_exec( + "powershell.exe", "-NoProfile", "-Command", ps_cmd, + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, + creationflags=(0x08000000 if IS_WINDOWS else 0) + ) + out_b, err_b = await proc.communicate() + out = (out_b or b"").decode(errors='replace') + err = (err_b or b"").decode(errors='replace') + return proc.returncode, out, err + except Exception as e: + return -1, "", str(e) async def stop_all_roles(): print("[DEBUG] Stopping all roles.") @@ -393,6 +591,14 @@ async def send_heartbeat(): "last_seen": int(time.time()) } await sio.emit("agent_heartbeat", payload) + # Also report collector status alive ping with last_user + import getpass + await sio.emit('collector_status', { + 'agent_id': AGENT_ID, + 'hostname': socket.gethostname(), + 'active': True, + 'last_user': f"{os.environ.get('USERDOMAIN') or socket.gethostname()}\\{getpass.getuser()}" + }) except Exception as e: print(f"[WARN] heartbeat emit failed: {e}") # Send periodic heartbeats every 60 seconds @@ -706,6 +912,18 @@ async def connect(): except Exception as e: print(f"[WARN] initial heartbeat failed: {e}") + # Let server know collector is active and who the user is + try: + import getpass + await sio.emit('collector_status', { + 'agent_id': AGENT_ID, + 'hostname': socket.gethostname(), + 'active': True, + 'last_user': f"{os.environ.get('USERDOMAIN') or socket.gethostname()}\\{getpass.getuser()}" + }) + except Exception: + pass + await sio.emit('request_config', {"agent_id": AGENT_ID}) @sio.event @@ -747,6 +965,15 @@ async def on_agent_config(cfg): task.cancel() role_tasks.clear() + # Forward screenshot config to service helper (interval only) + try: + for role_cfg in roles: + if role_cfg.get('role') == 'screenshot': + interval_ms = int(role_cfg.get('interval', 1000)) + send_service_control({ 'type': 'screenshot_config', 'interval_ms': interval_ms }) + except Exception: + pass + for role_cfg in roles: nid = role_cfg.get('node_id') role = role_cfg.get('role') @@ -759,6 +986,49 @@ async def on_agent_config(cfg): task = asyncio.create_task(agent_roles.macro_task(roles_ctx, role_cfg)) role_tasks[nid] = task +@sio.on('quick_job_run') +async def on_quick_job_run(payload): + try: + target = (payload.get('target_hostname') or '').strip().lower() + if not target or target != socket.gethostname().lower(): + return + job_id = payload.get('job_id') + script_type = (payload.get('script_type') or '').lower() + run_mode = (payload.get('run_mode') or 'current_user').lower() + content = payload.get('script_content') or '' + if script_type != 'powershell': + await sio.emit('quick_job_result', { 'job_id': job_id, 'status': 'Failed', 'stdout': '', 'stderr': f"Unsupported type: {script_type}" }) + return + path = _write_temp_script(content, '.ps1') + rc = 0; out = ''; err = '' + if run_mode == 'system': + if not _is_admin_windows(): + rc, out, err = -1, '', 'Agent is not elevated. SYSTEM execution requires running the agent as Administrator or service.' + else: + rc, out, err = await _run_powershell_as_system(path) + elif run_mode == 'admin': + # Admin credentialed runs are disabled in current design + rc, out, err = -1, '', 'Admin credentialed runs are disabled; use SYSTEM (service) or Current User.' + else: + rc, out, err = await _run_powershell_local(path) + status = 'Success' if rc == 0 else 'Failed' + await sio.emit('quick_job_result', { + 'job_id': job_id, + 'status': status, + 'stdout': out, + 'stderr': err, + }) + except Exception as e: + try: + await sio.emit('quick_job_result', { + 'job_id': payload.get('job_id') if isinstance(payload, dict) else None, + 'status': 'Failed', + 'stdout': '', + 'stderr': str(e), + }) + except Exception: + pass + @sio.on('list_agent_windows') async def handle_list_agent_windows(data): windows = agent_roles.get_window_list() @@ -811,6 +1081,11 @@ async def connect_loop(): if __name__=='__main__': app=QtWidgets.QApplication(sys.argv) loop=QEventLoop(app); asyncio.set_event_loop(loop) + AGENT_LOOP = loop + try: + start_agent_bridge_pipe(loop) + except Exception: + pass dummy_window=PersistentWindow(); dummy_window.show() # Initialize roles context for role tasks roles_ctx = agent_roles.RolesContext(sio=sio, agent_id=AGENT_ID, config=CONFIG) diff --git a/Data/Agent/script_agent.py b/Data/Agent/script_agent.py new file mode 100644 index 0000000..3d669aa --- /dev/null +++ b/Data/Agent/script_agent.py @@ -0,0 +1,120 @@ +import os +import sys +import time +import socket +import asyncio +import json +import subprocess +import tempfile + +import socketio + + +def get_project_root(): + return os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) + + +def get_server_url(): + # Try to reuse the agent config if present + cfg_path = os.path.join(get_project_root(), "agent_settings.json") + try: + if os.path.isfile(cfg_path): + with open(cfg_path, "r", encoding="utf-8") as f: + data = json.load(f) + url = data.get("borealis_server_url") + if isinstance(url, str) and url.strip(): + return url.strip() + except Exception: + pass + return "http://localhost:5000" + + +def run_powershell_script_content(content: str): + # Store ephemeral script under /Temp + temp_dir = os.path.join(get_project_root(), "Temp") + os.makedirs(temp_dir, exist_ok=True) + fd, path = tempfile.mkstemp(prefix="sj_", suffix=".ps1", dir=temp_dir, text=True) + with os.fdopen(fd, 'w', encoding='utf-8', newline='\n') as fh: + fh.write(content or "") + + ps = os.path.expandvars(r"%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe") + if not os.path.isfile(ps): + ps = "powershell.exe" + try: + proc = subprocess.run( + [ps, "-ExecutionPolicy", "Bypass", "-NoProfile", "-File", path], + capture_output=True, + text=True, + timeout=60*60, + ) + return proc.returncode, proc.stdout or "", proc.stderr or "" + except Exception as e: + return -1, "", str(e) + + +async def main(): + sio = socketio.AsyncClient(reconnection=True) + hostname = socket.gethostname() + + @sio.event + async def connect(): + print("[ScriptAgent] Connected to server") + # Identify as script agent (no heartbeat to avoid UI duplication) + try: + await sio.emit("connect_agent", {"agent_id": f"{hostname}-script"}) + except Exception: + pass + + @sio.on("quick_job_run") + async def on_quick_job_run(payload): + # Treat as generic script_run internally + try: + target = (payload.get('target_hostname') or '').strip().lower() + if target and target != hostname.lower(): + return + job_id = payload.get('job_id') + script_type = (payload.get('script_type') or '').lower() + content = payload.get('script_content') or '' + if script_type != 'powershell': + await sio.emit('quick_job_result', { + 'job_id': job_id, + 'status': 'Failed', + 'stdout': '', + 'stderr': f"Unsupported type: {script_type}" + }) + return + rc, out, err = run_powershell_script_content(content) + status = 'Success' if rc == 0 else 'Failed' + await sio.emit('quick_job_result', { + 'job_id': job_id, + 'status': status, + 'stdout': out, + 'stderr': err, + }) + except Exception as e: + try: + await sio.emit('quick_job_result', { + 'job_id': payload.get('job_id') if isinstance(payload, dict) else None, + 'status': 'Failed', + 'stdout': '', + 'stderr': str(e), + }) + except Exception: + pass + + @sio.event + async def disconnect(): + print("[ScriptAgent] Disconnected") + + url = get_server_url() + while True: + try: + await sio.connect(url, transports=['websocket']) + await sio.wait() + except Exception as e: + print(f"[ScriptAgent] reconnect in 5s: {e}") + await asyncio.sleep(5) + + +if __name__ == '__main__': + asyncio.run(main()) diff --git a/Data/Agent/tray_launcher.py b/Data/Agent/tray_launcher.py new file mode 100644 index 0000000..59465f6 --- /dev/null +++ b/Data/Agent/tray_launcher.py @@ -0,0 +1,147 @@ +import os +import sys +import subprocess +import signal +from PyQt5 import QtWidgets, QtGui + + +def project_paths(): + # Expected layout when running from venv: \Agent\Borealis + borealis_dir = os.path.dirname(os.path.abspath(__file__)) + agent_dir = os.path.abspath(os.path.join(borealis_dir, os.pardir)) + venv_scripts = os.path.join(agent_dir, 'Scripts') + pyw = os.path.join(venv_scripts, 'pythonw.exe') + py = os.path.join(venv_scripts, 'python.exe') + icon_path = os.path.join(borealis_dir, 'Borealis.ico') + agent_script = os.path.join(borealis_dir, 'borealis-agent.py') + return { + 'borealis_dir': borealis_dir, + 'venv_scripts': venv_scripts, + 'pythonw': pyw if os.path.isfile(pyw) else sys.executable, + 'python': py if os.path.isfile(py) else sys.executable, + 'agent_script': agent_script, + 'icon': icon_path if os.path.isfile(icon_path) else None, + } + + +class TrayApp(QtWidgets.QSystemTrayIcon): + def __init__(self, app): + self.app = app + paths = project_paths() + self.paths = paths + icon = QtGui.QIcon(paths['icon']) if paths['icon'] else app.style().standardIcon(QtWidgets.QStyle.SP_ComputerIcon) + super().__init__(icon) + self.setToolTip('Borealis Agent') + self.menu = QtWidgets.QMenu() + + self.action_show_console = self.menu.addAction('Switch to Foreground Mode') + self.action_hide_console = self.menu.addAction('Switch to Background Mode') + self.action_restart = self.menu.addAction('Restart Agent') + self.action_restart_service = self.menu.addAction('Restart Script Execution Service') + self.menu.addSeparator() + self.action_quit = self.menu.addAction('Quit Agent and Tray') + + self.action_show_console.triggered.connect(self.switch_to_console) + self.action_hide_console.triggered.connect(self.switch_to_background) + self.action_restart.triggered.connect(self.restart_agent) + self.action_restart_service.triggered.connect(self.restart_script_service) + self.action_quit.triggered.connect(self.quit_all) + self.setContextMenu(self.menu) + + self.proc = None + self.console_mode = False + # Start in background mode by default + self.switch_to_background() + self.show() + + def _start_agent(self, console=False): + self._stop_agent() + exe = self.paths['python'] if console else self.paths['pythonw'] + args = [exe, '-W', 'ignore::SyntaxWarning', self.paths['agent_script']] + creationflags = 0 + if not console and os.name == 'nt': + # CREATE_NO_WINDOW + creationflags = 0x08000000 + try: + self.proc = subprocess.Popen(args, cwd=self.paths['borealis_dir'], creationflags=creationflags) + self.console_mode = console + self._update_actions(console) + except Exception: + self.proc = None + + def _stop_agent(self): + if self.proc is not None: + try: + if os.name == 'nt': + self.proc.send_signal(signal.SIGTERM) + else: + self.proc.terminate() + except Exception: + pass + try: + self.proc.wait(timeout=3) + except Exception: + try: + self.proc.kill() + except Exception: + pass + self.proc = None + + def _update_actions(self, console): + self.action_show_console.setEnabled(not console) + self.action_hide_console.setEnabled(console) + + def switch_to_console(self): + self._start_agent(console=True) + + def switch_to_background(self): + self._start_agent(console=False) + + def restart_agent(self): + # Restart using current mode + self._start_agent(console=self.console_mode) + + def restart_script_service(self): + # Try direct stop/start; if fails (likely due to permissions), attempt elevation via PowerShell + service_name = 'BorealisScriptService' + try: + # Stop service + subprocess.run(["sc.exe", "stop", service_name], check=False, capture_output=True) + # Start service + subprocess.run(["sc.exe", "start", service_name], check=False, capture_output=True) + return + except Exception: + pass + + # Fallback: elevate via PowerShell + try: + script = ( + f"$ErrorActionPreference='Continue'; " + f"try {{ Stop-Service -Name '{service_name}' -Force -ErrorAction SilentlyContinue }} catch {{}}; " + f"Start-Sleep -Seconds 1; " + f"try {{ Start-Service -Name '{service_name}' }} catch {{}};" + ) + # Start-Process PowerShell -Verb RunAs to elevate + ps_cmd = [ + 'powershell.exe', '-NoProfile', '-ExecutionPolicy', 'Bypass', + '-Command', + "Start-Process PowerShell -Verb RunAs -ArgumentList '-NoProfile -ExecutionPolicy Bypass -Command \"" + script.replace("\"", "\\\"") + "\"'" + ] + subprocess.Popen(ps_cmd) + except Exception: + pass + + def quit_all(self): + self._stop_agent() + self.hide() + self.app.quit() + + +def main(): + app = QtWidgets.QApplication(sys.argv) + tray = TrayApp(app) + return app.exec_() + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/Data/Agent/windows_script_service.py b/Data/Agent/windows_script_service.py new file mode 100644 index 0000000..69e5b50 --- /dev/null +++ b/Data/Agent/windows_script_service.py @@ -0,0 +1,101 @@ +import win32serviceutil +import win32service +import win32event +import servicemanager +import subprocess +import os +import sys +import datetime + + +class BorealisScriptAgentService(win32serviceutil.ServiceFramework): + _svc_name_ = "BorealisScriptService" + _svc_display_name_ = "Borealis Script Execution Service" + _svc_description_ = "Executes automation scripts (PowerShell, etc.) as LocalSystem and bridges to Borealis Server." + + def __init__(self, args): + win32serviceutil.ServiceFramework.__init__(self, args) + self.hWaitStop = win32event.CreateEvent(None, 0, 0, None) + self.proc = None + try: + self._log("Service initialized") + except Exception: + pass + + def SvcStop(self): + self.ReportServiceStatus(win32service.SERVICE_STOP_PENDING) + try: + self._log("Stop requested") + except Exception: + pass + try: + if self.proc and self.proc.poll() is None: + try: + self.proc.terminate() + except Exception: + pass + except Exception: + pass + win32event.SetEvent(self.hWaitStop) + + def SvcDoRun(self): + try: + servicemanager.LogMsg(servicemanager.EVENTLOG_INFORMATION_TYPE, + servicemanager.PYS_SERVICE_STARTED, + (self._svc_name_, '')) + except Exception: + pass + try: + self._log("SvcDoRun entered") + except Exception: + pass + # Mark the service as running once initialized + try: + self.ReportServiceStatus(win32service.SERVICE_RUNNING) + except Exception: + pass + self.main() + + def main(self): + script_dir = os.path.dirname(os.path.abspath(__file__)) + agent_py = os.path.join(script_dir, 'script_agent.py') + python = sys.executable + try: + self._log(f"Launching script_agent via {python}") + self.proc = subprocess.Popen( + [python, '-W', 'ignore::SyntaxWarning', agent_py], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + creationflags=0x08000000 if os.name == 'nt' else 0 + ) + self._log("script_agent started") + except Exception: + self.proc = None + try: + self._log("Failed to start script_agent") + except Exception: + pass + + # Wait until stop or child exits + while True: + rc = win32event.WaitForSingleObject(self.hWaitStop, 1000) + if rc == win32event.WAIT_OBJECT_0: + break + if self.proc and self.proc.poll() is not None: + break + + def _log(self, msg: str): + try: + root = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir, os.pardir)) + logs = os.path.join(root, 'Logs') + os.makedirs(logs, exist_ok=True) + p = os.path.join(logs, 'ScriptService_Runtime.log') + ts = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') + with open(p, 'a', encoding='utf-8') as f: + f.write(f"{ts} {msg}\n") + except Exception: + pass + + +if __name__ == '__main__': + win32serviceutil.HandleCommandLine(BorealisScriptAgentService) diff --git a/Data/Server/Python_API_Endpoints/script_engines.py b/Data/Server/Python_API_Endpoints/script_engines.py new file mode 100644 index 0000000..7c789b1 --- /dev/null +++ b/Data/Server/Python_API_Endpoints/script_engines.py @@ -0,0 +1,57 @@ +import os +import subprocess +import sys +import platform + + +def run_powershell_script(script_path: str): + """ + Execute a PowerShell script with ExecutionPolicy Bypass. + + Returns (returncode, stdout, stderr) + """ + if not script_path or not os.path.isfile(script_path): + raise FileNotFoundError(f"Script not found: {script_path}") + + if not script_path.lower().endswith(".ps1"): + raise ValueError("run_powershell_script only accepts .ps1 files") + + system = platform.system() + + # Choose powershell binary + ps_bin = None + if system == "Windows": + # Prefer Windows PowerShell + ps_bin = os.path.expandvars(r"%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe") + if not os.path.isfile(ps_bin): + ps_bin = "powershell.exe" + else: + # PowerShell Core (pwsh) may exist cross-platform + ps_bin = "pwsh" + + # Build command + # -ExecutionPolicy Bypass (Windows only), -NoProfile, -File "script" + cmd = [ps_bin] + if system == "Windows": + cmd += ["-ExecutionPolicy", "Bypass"] + cmd += ["-NoProfile", "-File", script_path] + + # Hide window on Windows + creationflags = 0 + startupinfo = None + if system == "Windows": + creationflags = 0x08000000 # CREATE_NO_WINDOW + startupinfo = subprocess.STARTUPINFO() + startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW + + proc = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + universal_newlines=True, + creationflags=creationflags, + startupinfo=startupinfo, + ) + out, err = proc.communicate() + return proc.returncode, out or "", err or "" + diff --git a/Data/Server/WebUI/src/Devices/Device_Details.jsx b/Data/Server/WebUI/src/Devices/Device_Details.jsx index 59980ce..043179d 100644 --- a/Data/Server/WebUI/src/Devices/Device_Details.jsx +++ b/Data/Server/WebUI/src/Devices/Device_Details.jsx @@ -1,6 +1,6 @@ ////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: /Data/WebUI/src/Device_Details.js -import React, { useState, useEffect, useMemo } from "react"; +import React, { useState, useEffect, useMemo, useCallback } from "react"; import { Paper, Box, @@ -15,8 +15,19 @@ import { Button, LinearProgress, TableSortLabel, - TextField + TextField, + Dialog, + DialogTitle, + DialogContent, + DialogActions } from "@mui/material"; +import Prism from "prismjs"; +import "prismjs/components/prism-yaml"; +import "prismjs/components/prism-bash"; +import "prismjs/components/prism-powershell"; +import "prismjs/components/prism-batch"; +import "prismjs/themes/prism-okaidia.css"; +import Editor from "react-simple-code-editor"; export default function DeviceDetails({ device, onBack }) { const [tab, setTab] = useState(0); @@ -26,6 +37,13 @@ export default function DeviceDetails({ device, onBack }) { const [softwareOrder, setSoftwareOrder] = useState("asc"); const [softwareSearch, setSoftwareSearch] = useState(""); const [description, setDescription] = useState(""); + const [historyRows, setHistoryRows] = useState([]); + const [historyOrderBy, setHistoryOrderBy] = useState("ran_at"); + const [historyOrder, setHistoryOrder] = useState("desc"); + const [outputOpen, setOutputOpen] = useState(false); + const [outputTitle, setOutputTitle] = useState(""); + const [outputContent, setOutputContent] = useState(""); + const [outputLang, setOutputLang] = useState("powershell"); // Snapshotted status for the lifetime of this page const [lockedStatus, setLockedStatus] = useState(() => { // Prefer status provided by the device list row if available @@ -89,6 +107,21 @@ export default function DeviceDetails({ device, onBack }) { load(); }, [device]); + const loadHistory = useCallback(async () => { + if (!device?.hostname) return; + try { + const resp = await fetch(`/api/device/activity/${encodeURIComponent(device.hostname)}`); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const data = await resp.json(); + setHistoryRows(data.history || []); + } catch (e) { + console.warn("Failed to load activity history", e); + setHistoryRows([]); + } + }, [device]); + + useEffect(() => { loadHistory(); }, [loadHistory]); + const saveDescription = async () => { if (!details.summary?.hostname) return; try { @@ -136,6 +169,20 @@ export default function DeviceDetails({ device, onBack }) { return `${num.toFixed(1)} ${units[i]}`; }; + const formatTimestamp = (epochSec) => { + const ts = Number(epochSec || 0); + if (!ts) return "unknown"; + const d = new Date(ts * 1000); + const mm = String(d.getMonth() + 1).padStart(2, "0"); + const dd = String(d.getDate()).padStart(2, "0"); + const yyyy = d.getFullYear(); + let hh = d.getHours(); + const ampm = hh >= 12 ? "PM" : "AM"; + hh = hh % 12 || 12; + const min = String(d.getMinutes()).padStart(2, "0"); + return `${mm}/${dd}/${yyyy} @ ${hh}:${min} ${ampm}`; + }; + const handleSoftwareSort = (col) => { if (softwareOrderBy === col) { setSoftwareOrder(softwareOrder === "asc" ? "desc" : "asc"); @@ -162,7 +209,15 @@ export default function DeviceDetails({ device, onBack }) { const summaryItems = [ { label: "Device Name", value: summary.hostname || agent.hostname || device?.hostname || "unknown" }, { label: "Operating System", value: summary.operating_system || agent.agent_operating_system || "unknown" }, - { label: "Last User", value: summary.last_user || "unknown" }, + { label: "Last User", value: ( + + + {summary.last_user || 'unknown'} + + ) }, { label: "Internal IP", value: summary.internal_ip || "unknown" }, { label: "External IP", value: summary.external_ip || "unknown" }, { label: "Last Reboot", value: summary.last_reboot ? formatDateTime(summary.last_reboot) : "unknown" }, @@ -469,12 +524,147 @@ export default function DeviceDetails({ device, onBack }) { ); }; + const jobStatusColor = (s) => { + const val = String(s || "").toLowerCase(); + if (val === "running") return "#58a6ff"; // borealis blue + if (val === "success") return "#00d18c"; + if (val === "failed") return "#ff4f4f"; + return "#666"; + }; + + const highlightCode = (code, lang) => { + try { + return Prism.highlight(code ?? "", Prism.languages[lang] || Prism.languages.markup, lang); + } catch { + return String(code || ""); + } + }; + + const handleViewOutput = async (row, which) => { + try { + const resp = await fetch(`/api/device/activity/job/${row.id}`); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const data = await resp.json(); + const lang = ((data.script_path || "").toLowerCase().endsWith(".ps1")) ? "powershell" + : ((data.script_path || "").toLowerCase().endsWith(".bat")) ? "batch" + : ((data.script_path || "").toLowerCase().endsWith(".sh")) ? "bash" + : ((data.script_path || "").toLowerCase().endsWith(".yml")) ? "yaml" : "powershell"; + setOutputLang(lang); + setOutputTitle(`${which === 'stderr' ? 'StdErr' : 'StdOut'} - ${data.script_name}`); + setOutputContent(which === 'stderr' ? (data.stderr || "") : (data.stdout || "")); + setOutputOpen(true); + } catch (e) { + console.warn("Failed to load output", e); + } + }; + + const handleHistorySort = (col) => { + if (historyOrderBy === col) setHistoryOrder(historyOrder === "asc" ? "desc" : "asc"); + else { + setHistoryOrderBy(col); + setHistoryOrder("asc"); + } + }; + + const sortedHistory = useMemo(() => { + const dir = historyOrder === "asc" ? 1 : -1; + return [...historyRows].sort((a, b) => { + const A = a[historyOrderBy]; + const B = b[historyOrderBy]; + if (historyOrderBy === "ran_at") return ((A || 0) - (B || 0)) * dir; + return String(A ?? "").localeCompare(String(B ?? "")) * dir; + }); + }, [historyRows, historyOrderBy, historyOrder]); + + const renderHistory = () => ( + + + + + + handleHistorySort("script_name")} + > + Script Executed + + + + handleHistorySort("ran_at")} + > + Ran On + + + + handleHistorySort("status")} + > + Job Status + + + + StdOut / StdErr + + + + + {sortedHistory.map((r) => ( + + {r.script_name} + {formatTimestamp(r.ran_at)} + + + {r.status} + + + + + {r.has_stdout ? ( + + ) : null} + {r.has_stderr ? ( + + ) : null} + + + + ))} + {sortedHistory.length === 0 && ( + + No activity yet. + + )} + +
+
+ ); + const tabs = [ { label: "Summary", content: renderSummary() }, { label: "Software", content: renderSoftware() }, { label: "Memory", content: renderMemory() }, { label: "Storage", content: renderStorage() }, - { label: "Network", content: renderNetwork() } + { label: "Network", content: renderNetwork() }, + { label: "Activity History", content: renderHistory() } ]; // Use the snapshotted status so it stays static while on this page const status = lockedStatus || statusFromHeartbeat(agent.last_seen || device?.lastSeen); @@ -514,6 +704,32 @@ export default function DeviceDetails({ device, onBack }) { ))} {tabs[tab].content} + + setOutputOpen(false)} fullWidth maxWidth="md" + PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }} + > + {outputTitle} + + + {}} + highlight={(code) => highlightCode(code, outputLang)} + padding={12} + style={{ + fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace', + fontSize: 12, + color: "#e6edf3", + minHeight: 200 + }} + textareaProps={{ readOnly: true }} + /> + + + + + + ); } diff --git a/Data/Server/WebUI/src/Devices/Device_List.jsx b/Data/Server/WebUI/src/Devices/Device_List.jsx index ce959c9..b6d2564 100644 --- a/Data/Server/WebUI/src/Devices/Device_List.jsx +++ b/Data/Server/WebUI/src/Devices/Device_List.jsx @@ -11,12 +11,15 @@ import { TableHead, TableRow, TableSortLabel, + Checkbox, + Button, IconButton, Menu, MenuItem } from "@mui/material"; import MoreVertIcon from "@mui/icons-material/MoreVert"; import { DeleteDeviceDialog } from "../Dialogs.jsx"; +import QuickJob from "../Scheduling/Quick_Job.jsx"; function formatLastSeen(tsSec, offlineAfter = 120) { if (!tsSec) return "unknown"; @@ -48,6 +51,8 @@ export default function DeviceList({ onSelectDevice }) { const [menuAnchor, setMenuAnchor] = useState(null); const [selected, setSelected] = useState(null); const [confirmOpen, setConfirmOpen] = useState(false); + const [selectedHosts, setSelectedHosts] = useState(() => new Set()); + const [quickJobOpen, setQuickJobOpen] = useState(false); const fetchAgents = useCallback(async () => { try { @@ -117,19 +122,65 @@ export default function DeviceList({ onSelectDevice }) { setSelected(null); }; + const isAllChecked = sorted.length > 0 && sorted.every((r) => selectedHosts.has(r.hostname)); + const isIndeterminate = selectedHosts.size > 0 && !isAllChecked; + const toggleAll = (e) => { + const checked = e.target.checked; + setSelectedHosts((prev) => { + const next = new Set(prev); + if (checked) { + sorted.forEach((r) => next.add(r.hostname)); + } else { + next.clear(); + } + return next; + }); + }; + + const toggleOne = (hostname) => (e) => { + const checked = e.target.checked; + setSelectedHosts((prev) => { + const next = new Set(prev); + if (checked) next.add(hostname); + else next.delete(hostname); + return next; + }); + }; + return ( - + Devices - - Devices connected to Borealis via Agent and their last check-ins. - + + + + + + {sorted.map((r, i) => ( + e.stopPropagation()}> + + - + No agents connected. @@ -238,6 +296,14 @@ export default function DeviceList({ onSelectDevice }) { onCancel={() => setConfirmOpen(false)} onConfirm={handleDelete} /> + + {quickJobOpen && ( + setQuickJobOpen(false)} + hostnames={[...selectedHosts]} + /> + )} ); } diff --git a/Data/Server/WebUI/src/Scheduling/Quick_Job.jsx b/Data/Server/WebUI/src/Scheduling/Quick_Job.jsx new file mode 100644 index 0000000..d683cae --- /dev/null +++ b/Data/Server/WebUI/src/Scheduling/Quick_Job.jsx @@ -0,0 +1,207 @@ +import React, { useEffect, useMemo, useState, useCallback } from "react"; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + Box, + Typography, + Paper, + FormControlLabel, + Checkbox +} from "@mui/material"; +import { Folder as FolderIcon, Description as DescriptionIcon } from "@mui/icons-material"; +import { SimpleTreeView, TreeItem } from "@mui/x-tree-view"; + +function buildTree(scripts, folders) { + const map = {}; + const rootNode = { + id: "root", + label: "Scripts", + path: "", + isFolder: true, + children: [] + }; + map[rootNode.id] = rootNode; + + (folders || []).forEach((f) => { + const parts = (f || "").split("/"); + let children = rootNode.children; + let parentPath = ""; + parts.forEach((part) => { + const path = parentPath ? `${parentPath}/${part}` : part; + let node = children.find((n) => n.id === path); + if (!node) { + node = { id: path, label: part, path, isFolder: true, children: [] }; + children.push(node); + map[path] = node; + } + children = node.children; + parentPath = path; + }); + }); + + (scripts || []).forEach((s) => { + const parts = (s.rel_path || "").split("/"); + let children = rootNode.children; + let parentPath = ""; + parts.forEach((part, idx) => { + const path = parentPath ? `${parentPath}/${part}` : part; + const isFile = idx === parts.length - 1; + let node = children.find((n) => n.id === path); + if (!node) { + node = { + id: path, + label: isFile ? s.file_name : part, + path, + isFolder: !isFile, + fileName: s.file_name, + script: isFile ? s : null, + children: [] + }; + children.push(node); + map[path] = node; + } + if (!isFile) { + children = node.children; + parentPath = path; + } + }); + }); + + return { root: [rootNode], map }; +} + +export default function QuickJob({ open, onClose, hostnames = [] }) { + const [tree, setTree] = useState([]); + const [nodeMap, setNodeMap] = useState({}); + const [selectedPath, setSelectedPath] = useState(""); + const [running, setRunning] = useState(false); + const [error, setError] = useState(""); + const [runAsCurrentUser, setRunAsCurrentUser] = useState(false); + + const loadTree = useCallback(async () => { + try { + const resp = await fetch("/api/scripts/list"); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const data = await resp.json(); + const { root, map } = buildTree(data.scripts || [], data.folders || []); + setTree(root); + setNodeMap(map); + } catch (err) { + console.error("Failed to load scripts:", err); + setTree([]); + setNodeMap({}); + } + }, []); + + useEffect(() => { + if (open) { + setSelectedPath(""); + setError(""); + loadTree(); + } + }, [open, loadTree]); + + const renderNodes = (nodes = []) => + nodes.map((n) => ( + + {n.isFolder ? ( + + ) : ( + + )} + {n.label} + + } + > + {n.children && n.children.length ? renderNodes(n.children) : null} + + )); + + const onItemSelect = (_e, itemId) => { + const node = nodeMap[itemId]; + if (node && !node.isFolder) { + setSelectedPath(node.path); + setError(""); + } + }; + + const onRun = async () => { + if (!selectedPath) { + setError("Please choose a script to run."); + return; + } + setRunning(true); + setError(""); + try { + const resp = await fetch("/api/scripts/quick_run", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ script_path: selectedPath, hostnames, run_mode: runAsCurrentUser ? "current_user" : "system" }) + }); + const data = await resp.json(); + if (!resp.ok) throw new Error(data.error || `HTTP ${resp.status}`); + onClose && onClose(); + } catch (err) { + setError(String(err.message || err)); + } finally { + setRunning(false); + } + }; + + return ( + + Quick Job + + + Select a script to run on {hostnames.length} device{hostnames.length !== 1 ? "s" : ""}. + + + + + {tree.length ? renderNodes(tree) : ( + + No scripts found. + + )} + + + + Selection + + {selectedPath || "No script selected"} + + + setRunAsCurrentUser(e.target.checked)} />} + label={Run as currently logged-in user} + /> + + Unchecked = run as SYSTEM (requires agent service) + + + {error && ( + {error} + )} + + + + + + + + + ); +} diff --git a/Data/Server/server.py b/Data/Server/server.py index 95eebc0..0c05751 100644 --- a/Data/Server/server.py +++ b/Data/Server/server.py @@ -19,6 +19,7 @@ import io # Borealis Python API Endpoints from Python_API_Endpoints.ocr_engines import run_ocr_on_base64 +from Python_API_Endpoints.script_engines import run_powershell_script # --------------------------------------------- # Flask + WebSocket Server Configuration @@ -659,6 +660,22 @@ def init_db(): cur.execute( "CREATE TABLE IF NOT EXISTS device_details (hostname TEXT PRIMARY KEY, description TEXT, details TEXT)" ) + # Activity history table for script/job runs + cur.execute( + """ + CREATE TABLE IF NOT EXISTS activity_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + hostname TEXT, + script_path TEXT, + script_name TEXT, + script_type TEXT, + ran_at INTEGER, + status TEXT, + stdout TEXT, + stderr TEXT + ) + """ + ) conn.commit() conn.close() @@ -738,10 +755,20 @@ load_agents_from_db() @app.route("/api/agents") def get_agents(): - """ - Return a dict keyed by agent_id with hostname, os, last_seen, status. - """ - return jsonify(registered_agents) + """Return agents with collector activity indicator.""" + now = time.time() + out = {} + for aid, info in (registered_agents or {}).items(): + # Hide script-execution agents from the public list + if aid and isinstance(aid, str) and aid.lower().endswith('-script'): + continue + if info.get('is_script_agent'): + continue + d = dict(info) + ts = d.get('collector_active_ts') or 0 + d['collector_active'] = bool(ts and (now - float(ts) < 130)) + out[aid] = d + return jsonify(out) @app.route("/api/agent/details", methods=["POST"]) @@ -840,6 +867,230 @@ def set_device_description(hostname: str): return jsonify({"error": str(e)}), 500 +# --------------------------------------------- +# Quick Job Execution + Activity History +# --------------------------------------------- +def _detect_script_type(fn: str) -> str: + fn = (fn or "").lower() + if fn.endswith(".yml"): + return "ansible" + if fn.endswith(".ps1"): + return "powershell" + if fn.endswith(".bat"): + return "batch" + if fn.endswith(".sh"): + return "bash" + return "unknown" + + +def _safe_filename(rel_path: str) -> str: + try: + return os.path.basename(rel_path or "") + except Exception: + return rel_path or "" + + +@app.route("/api/scripts/quick_run", methods=["POST"]) +def scripts_quick_run(): + """Queue a Quick Job to agents via WebSocket and record Running status. + + Payload: { script_path: str, hostnames: [str], run_mode?: 'current_user'|'admin'|'system', admin_user?, admin_pass? } + """ + data = request.get_json(silent=True) or {} + rel_path = (data.get("script_path") or "").strip() + hostnames = data.get("hostnames") or [] + run_mode = (data.get("run_mode") or "system").strip().lower() + admin_user = "" + admin_pass = "" + + if not rel_path or not isinstance(hostnames, list) or not hostnames: + return jsonify({"error": "Missing script_path or hostnames[]"}), 400 + + scripts_root = _scripts_root() + abs_path = os.path.abspath(os.path.join(scripts_root, rel_path)) + if not abs_path.startswith(scripts_root) or not os.path.isfile(abs_path): + return jsonify({"error": "Script not found"}), 404 + + script_type = _detect_script_type(abs_path) + if script_type != "powershell": + return jsonify({"error": f"Unsupported script type '{script_type}'. Only powershell is supported for Quick Job currently."}), 400 + + try: + with open(abs_path, "r", encoding="utf-8", errors="replace") as fh: + content = fh.read() + except Exception as e: + return jsonify({"error": f"Failed to read script: {e}"}), 500 + + now = int(time.time()) + results = [] + for host in hostnames: + job_id = None + try: + conn = sqlite3.connect(DB_PATH) + cur = conn.cursor() + cur.execute( + """ + INSERT INTO activity_history(hostname, script_path, script_name, script_type, ran_at, status, stdout, stderr) + VALUES(?,?,?,?,?,?,?,?) + """, + ( + host, + rel_path.replace(os.sep, "/"), + _safe_filename(rel_path), + script_type, + now, + "Running", + "", + "", + ), + ) + job_id = cur.lastrowid + conn.commit() + conn.close() + except Exception as db_err: + return jsonify({"error": f"DB insert failed: {db_err}"}), 500 + + payload = { + "job_id": job_id, + "target_hostname": host, + "script_type": script_type, + "script_name": _safe_filename(rel_path), + "script_path": rel_path.replace(os.sep, "/"), + "script_content": content, + "run_mode": run_mode, + "admin_user": admin_user, + "admin_pass": admin_pass, + } + # Broadcast to all connected clients; no broadcast kw in python-socketio v5 + socketio.emit("quick_job_run", payload) + results.append({"hostname": host, "job_id": job_id, "status": "Running"}) + + return jsonify({"results": results}) + + +@app.route("/api/device/activity/", methods=["GET"]) +def device_activity(hostname: str): + try: + conn = sqlite3.connect(DB_PATH) + cur = conn.cursor() + cur.execute( + "SELECT id, script_name, script_path, script_type, ran_at, status, LENGTH(stdout), LENGTH(stderr) FROM activity_history WHERE hostname = ? ORDER BY ran_at DESC, id DESC", + (hostname,), + ) + rows = cur.fetchall() + conn.close() + out = [] + for (jid, name, path, stype, ran_at, status, so_len, se_len) in rows: + out.append({ + "id": jid, + "script_name": name, + "script_path": path, + "script_type": stype, + "ran_at": ran_at, + "status": status, + "has_stdout": bool(so_len or 0), + "has_stderr": bool(se_len or 0), + }) + return jsonify({"history": out}) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +@app.route("/api/device/activity/job/", methods=["GET"]) +def device_activity_job(job_id: int): + try: + conn = sqlite3.connect(DB_PATH) + cur = conn.cursor() + cur.execute( + "SELECT id, hostname, script_name, script_path, script_type, ran_at, status, stdout, stderr FROM activity_history WHERE id = ?", + (job_id,), + ) + row = cur.fetchone() + conn.close() + if not row: + return jsonify({"error": "Not found"}), 404 + (jid, hostname, name, path, stype, ran_at, status, stdout, stderr) = row + return jsonify({ + "id": jid, + "hostname": hostname, + "script_name": name, + "script_path": path, + "script_type": stype, + "ran_at": ran_at, + "status": status, + "stdout": stdout or "", + "stderr": stderr or "", + }) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +@socketio.on("quick_job_result") +def handle_quick_job_result(data): + """Agent reports back stdout/stderr/status for a job.""" + try: + job_id = int(data.get("job_id")) + except Exception: + return + status = (data.get("status") or "").strip() or "Failed" + stdout = data.get("stdout") or "" + stderr = data.get("stderr") or "" + try: + conn = sqlite3.connect(DB_PATH) + cur = conn.cursor() + cur.execute( + "UPDATE activity_history SET status=?, stdout=?, stderr=? WHERE id=?", + (status, stdout, stderr, job_id), + ) + conn.commit() + conn.close() + except Exception as e: + print(f"[ERROR] quick_job_result DB update failed for job {job_id}: {e}") + + +@socketio.on("collector_status") +def handle_collector_status(data): + """Collector agent reports activity and optional last_user.""" + agent_id = (data or {}).get('agent_id') + hostname = (data or {}).get('hostname') + active = bool((data or {}).get('active')) + last_user = (data or {}).get('last_user') + if not agent_id: + return + rec = registered_agents.setdefault(agent_id, {}) + rec['agent_id'] = agent_id + if hostname: + rec['hostname'] = hostname + if active: + rec['collector_active_ts'] = time.time() + if last_user and (hostname or rec.get('hostname')): + try: + conn = sqlite3.connect(DB_PATH) + cur = conn.cursor() + cur.execute( + "SELECT details, description FROM device_details WHERE hostname = ?", + (hostname or rec.get('hostname'),), + ) + row = cur.fetchone() + details = {} + if row and row[0]: + try: + details = json.loads(row[0]) + except Exception: + details = {} + summary = details.get('summary') or {} + summary['last_user'] = last_user + details['summary'] = summary + cur.execute( + "REPLACE INTO device_details (hostname, description, details) VALUES (?, COALESCE((SELECT description FROM device_details WHERE hostname=?), ''), ?)", + ((hostname or rec.get('hostname')), (hostname or rec.get('hostname')), json.dumps(details)) + ) + conn.commit() + conn.close() + except Exception: + pass + + @app.route("/api/agent/", methods=["DELETE"]) def delete_agent(agent_id: str): """Remove an agent from the registry and database.""" @@ -979,7 +1230,8 @@ def receive_screenshot_task(data): "timestamp": time.time() } - emit("agent_screenshot_task", data, broadcast=True) + # Relay to all connected clients; use server-level emit + socketio.emit("agent_screenshot_task", data) @socketio.on("connect_agent") def connect_agent(data): @@ -998,6 +1250,12 @@ def connect_agent(data): rec["agent_operating_system"] = rec.get("agent_operating_system", "-") rec["last_seen"] = int(time.time()) rec["status"] = "provisioned" if agent_id in agent_configurations else "orphaned" + # Flag script agents so they can be filtered out elsewhere if desired + try: + if isinstance(agent_id, str) and agent_id.lower().endswith('-script'): + rec['is_script_agent'] = True + except Exception: + pass # If we already know the hostname for this agent, persist last_seen so it # can be restored after server restarts. try: @@ -1056,7 +1314,8 @@ def receive_screenshot(data): "image_base64": image, "timestamp": time.time() } - emit("new_screenshot", {"agent_id": agent_id, "image_base64": image}, broadcast=True) + # Broadcast to all clients; use server-level emit + socketio.emit("new_screenshot", {"agent_id": agent_id, "image_base64": image}) @socketio.on("disconnect") def on_disconnect(): @@ -1076,21 +1335,24 @@ def receive_macro_status(data): } """ print(f"[Macro Status] Agent {data.get('agent_id')} Node {data.get('node_id')} Success: {data.get('success')} Msg: {data.get('message')}") - emit("macro_status", data, broadcast=True) + # Broadcast to all; use server-level emit for v5 API + socketio.emit("macro_status", data) @socketio.on("list_agent_windows") def handle_list_agent_windows(data): """ Forwards list_agent_windows event to all agents (or filter for a specific agent_id). """ - emit("list_agent_windows", data, broadcast=True) + # Forward to all agents/clients + socketio.emit("list_agent_windows", data) @socketio.on("agent_window_list") def handle_agent_window_list(data): """ Relay the list of windows from the agent back to all connected clients. """ - emit("agent_window_list", data, broadcast=True) + # Relay the list to all interested clients + socketio.emit("agent_window_list", data) # --------------------------------------------- # Server Launch diff --git a/Scripts/Examples/Deploy-Rizz.ps1 b/Scripts/Examples/Deploy-Rizz.ps1 deleted file mode 100644 index 2e6f0d8..0000000 --- a/Scripts/Examples/Deploy-Rizz.ps1 +++ /dev/null @@ -1,33 +0,0 @@ -# 🚨 WARNING 🚨 -# This script contains 200mg of raw Gen-Z energy. -# Proceed with caution. - -# Function name is cursed but still works. -# PowerShell lets you use dashes, so let's abuse that. -function Summon-Rizzler { - <# - .SYNOPSIS - Deploys peak sigma grindset energy. - - .DESCRIPTION - This function takes no parameters, just vibes. - Outputs a "Hello, World!" message... but with ✨style✨ - #> - - # Variables nobody asked for: - $vibeCheck = "Hello, World!" - $npcDialogue = "πŸ’€ bruh moment detected πŸ’€" - $ohioMode = $true - $skibidiRizzGyatt = 69420 # totally necessary - - # Drop the hello world - Write-Host "$vibeCheck (powered by $skibidiRizzGyatt braincells)" -ForegroundColor Cyan - - # Sprinkle in random zoomer nonsense if ohioMode is on - if ($ohioMode) { - Write-Host $npcDialogue -ForegroundColor Magenta - } -} - -# πŸš€ Deploy the sigma energy -Summon-Rizzler diff --git a/Scripts/Examples/Write Canary to C Drive Root.ps1 b/Scripts/Examples/Write Canary to C Drive Root.ps1 new file mode 100644 index 0000000..a1d7a2a --- /dev/null +++ b/Scripts/Examples/Write Canary to C Drive Root.ps1 @@ -0,0 +1,5 @@ +# Define the file path +$filePath = "C:\Canary.txt" + +# Write some content into the file +"SYSTEM Canary is alive." | Out-File -FilePath $filePath -Encoding UTF8 diff --git a/Scripts/Examples/Write Canary to CurrentUser Desktop.ps1 b/Scripts/Examples/Write Canary to CurrentUser Desktop.ps1 new file mode 100644 index 0000000..d82ad73 --- /dev/null +++ b/Scripts/Examples/Write Canary to CurrentUser Desktop.ps1 @@ -0,0 +1,8 @@ +# Dynamically get the current user's Desktop path +$desktopPath = [Environment]::GetFolderPath('Desktop') + +# Define the file path relative to the Desktop +$filePath = Join-Path $desktopPath "Canary.txt" + +# Write some content into the file +"USER Canary is alive." | Out-File -FilePath $filePath -Encoding UTF8