mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-09-11 04:38:42 -06:00
First Basic Implementation of Remote Script Execution Functionality
This commit is contained in:
9
.gitignore
vendored
9
.gitignore
vendored
@@ -5,9 +5,12 @@ Borealis-Agent.exe
|
|||||||
Borealis-Server.exe
|
Borealis-Server.exe
|
||||||
|
|
||||||
# Production Deployment Folders
|
# Production Deployment Folders
|
||||||
/Server/
|
|
||||||
/Agent/
|
/Agent/
|
||||||
|
/Server/
|
||||||
/ElectronApp/
|
/ElectronApp/
|
||||||
|
/Databases/
|
||||||
|
/Logs/
|
||||||
|
/Temp/
|
||||||
|
|
||||||
# On-the-Fly Downloaded Dependencies
|
# On-the-Fly Downloaded Dependencies
|
||||||
/Dependencies/NodeJS/
|
/Dependencies/NodeJS/
|
||||||
@@ -15,11 +18,9 @@ Borealis-Server.exe
|
|||||||
/Dependencies/AutoHotKey/
|
/Dependencies/AutoHotKey/
|
||||||
/Data/Server/Python_API_Endpoints/Tesseract-OCR/
|
/Data/Server/Python_API_Endpoints/Tesseract-OCR/
|
||||||
|
|
||||||
# Server-Level Database
|
|
||||||
/Databases/
|
|
||||||
|
|
||||||
# Misc Files/Folders
|
# Misc Files/Folders
|
||||||
.vs/s
|
.vs/s
|
||||||
|
__pycache__
|
||||||
/Update_Staging/
|
/Update_Staging/
|
||||||
agent_settings.json
|
agent_settings.json
|
||||||
users.json
|
users.json
|
337
Borealis.ps1
337
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 ----------------------
|
# ---------------------- Common Initialization & Visuals ----------------------
|
||||||
Clear-Host
|
Clear-Host
|
||||||
|
|
||||||
@@ -504,6 +825,10 @@ switch ($choice) {
|
|||||||
Copy-Item "Data\Agent\agent_info.py" $agentDestinationFolder -Recurse
|
Copy-Item "Data\Agent\agent_info.py" $agentDestinationFolder -Recurse
|
||||||
Copy-Item "Data\Agent\agent_roles.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\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"
|
. "$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 "===================================================================================="
|
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" {
|
"3" {
|
||||||
|
@@ -25,3 +25,4 @@ pywinauto # Windows-based Macro Automation Library
|
|||||||
# Audio Streaming Dependencies
|
# Audio Streaming Dependencies
|
||||||
sounddevice
|
sounddevice
|
||||||
numpy
|
numpy
|
||||||
|
pywin32; platform_system == "Windows"
|
||||||
|
372
Data/Agent/agent_deployment.py
Normal file
372
Data/Agent/agent_deployment.py
Normal file
@@ -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:
|
||||||
|
<ProjectRoot>/Agent/Scripts/python.exe == sys.executable
|
||||||
|
<ProjectRoot>/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: <venv_python> -W ignore::SyntaxWarning <agent_script>
|
||||||
|
"""
|
||||||
|
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))
|
@@ -362,6 +362,204 @@ sio = socketio.AsyncClient(reconnection=True, reconnection_attempts=0, reconnect
|
|||||||
role_tasks = {}
|
role_tasks = {}
|
||||||
background_tasks = []
|
background_tasks = []
|
||||||
roles_ctx = None
|
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():
|
async def stop_all_roles():
|
||||||
print("[DEBUG] Stopping all roles.")
|
print("[DEBUG] Stopping all roles.")
|
||||||
@@ -393,6 +591,14 @@ async def send_heartbeat():
|
|||||||
"last_seen": int(time.time())
|
"last_seen": int(time.time())
|
||||||
}
|
}
|
||||||
await sio.emit("agent_heartbeat", payload)
|
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:
|
except Exception as e:
|
||||||
print(f"[WARN] heartbeat emit failed: {e}")
|
print(f"[WARN] heartbeat emit failed: {e}")
|
||||||
# Send periodic heartbeats every 60 seconds
|
# Send periodic heartbeats every 60 seconds
|
||||||
@@ -706,6 +912,18 @@ async def connect():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[WARN] initial heartbeat failed: {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})
|
await sio.emit('request_config', {"agent_id": AGENT_ID})
|
||||||
|
|
||||||
@sio.event
|
@sio.event
|
||||||
@@ -747,6 +965,15 @@ async def on_agent_config(cfg):
|
|||||||
task.cancel()
|
task.cancel()
|
||||||
role_tasks.clear()
|
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:
|
for role_cfg in roles:
|
||||||
nid = role_cfg.get('node_id')
|
nid = role_cfg.get('node_id')
|
||||||
role = role_cfg.get('role')
|
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))
|
task = asyncio.create_task(agent_roles.macro_task(roles_ctx, role_cfg))
|
||||||
role_tasks[nid] = task
|
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')
|
@sio.on('list_agent_windows')
|
||||||
async def handle_list_agent_windows(data):
|
async def handle_list_agent_windows(data):
|
||||||
windows = agent_roles.get_window_list()
|
windows = agent_roles.get_window_list()
|
||||||
@@ -811,6 +1081,11 @@ async def connect_loop():
|
|||||||
if __name__=='__main__':
|
if __name__=='__main__':
|
||||||
app=QtWidgets.QApplication(sys.argv)
|
app=QtWidgets.QApplication(sys.argv)
|
||||||
loop=QEventLoop(app); asyncio.set_event_loop(loop)
|
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()
|
dummy_window=PersistentWindow(); dummy_window.show()
|
||||||
# Initialize roles context for role tasks
|
# Initialize roles context for role tasks
|
||||||
roles_ctx = agent_roles.RolesContext(sio=sio, agent_id=AGENT_ID, config=CONFIG)
|
roles_ctx = agent_roles.RolesContext(sio=sio, agent_id=AGENT_ID, config=CONFIG)
|
||||||
|
120
Data/Agent/script_agent.py
Normal file
120
Data/Agent/script_agent.py
Normal file
@@ -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 <ProjectRoot>/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())
|
147
Data/Agent/tray_launcher.py
Normal file
147
Data/Agent/tray_launcher.py
Normal file
@@ -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: <Root>\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())
|
101
Data/Agent/windows_script_service.py
Normal file
101
Data/Agent/windows_script_service.py
Normal file
@@ -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)
|
57
Data/Server/Python_API_Endpoints/script_engines.py
Normal file
57
Data/Server/Python_API_Endpoints/script_engines.py
Normal file
@@ -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 ""
|
||||||
|
|
@@ -1,6 +1,6 @@
|
|||||||
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/Device_Details.js
|
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/Device_Details.js
|
||||||
|
|
||||||
import React, { useState, useEffect, useMemo } from "react";
|
import React, { useState, useEffect, useMemo, useCallback } from "react";
|
||||||
import {
|
import {
|
||||||
Paper,
|
Paper,
|
||||||
Box,
|
Box,
|
||||||
@@ -15,8 +15,19 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
LinearProgress,
|
LinearProgress,
|
||||||
TableSortLabel,
|
TableSortLabel,
|
||||||
TextField
|
TextField,
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions
|
||||||
} from "@mui/material";
|
} 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 }) {
|
export default function DeviceDetails({ device, onBack }) {
|
||||||
const [tab, setTab] = useState(0);
|
const [tab, setTab] = useState(0);
|
||||||
@@ -26,6 +37,13 @@ export default function DeviceDetails({ device, onBack }) {
|
|||||||
const [softwareOrder, setSoftwareOrder] = useState("asc");
|
const [softwareOrder, setSoftwareOrder] = useState("asc");
|
||||||
const [softwareSearch, setSoftwareSearch] = useState("");
|
const [softwareSearch, setSoftwareSearch] = useState("");
|
||||||
const [description, setDescription] = 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
|
// Snapshotted status for the lifetime of this page
|
||||||
const [lockedStatus, setLockedStatus] = useState(() => {
|
const [lockedStatus, setLockedStatus] = useState(() => {
|
||||||
// Prefer status provided by the device list row if available
|
// Prefer status provided by the device list row if available
|
||||||
@@ -89,6 +107,21 @@ export default function DeviceDetails({ device, onBack }) {
|
|||||||
load();
|
load();
|
||||||
}, [device]);
|
}, [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 () => {
|
const saveDescription = async () => {
|
||||||
if (!details.summary?.hostname) return;
|
if (!details.summary?.hostname) return;
|
||||||
try {
|
try {
|
||||||
@@ -136,6 +169,20 @@ export default function DeviceDetails({ device, onBack }) {
|
|||||||
return `${num.toFixed(1)} ${units[i]}`;
|
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) => {
|
const handleSoftwareSort = (col) => {
|
||||||
if (softwareOrderBy === col) {
|
if (softwareOrderBy === col) {
|
||||||
setSoftwareOrder(softwareOrder === "asc" ? "desc" : "asc");
|
setSoftwareOrder(softwareOrder === "asc" ? "desc" : "asc");
|
||||||
@@ -162,7 +209,15 @@ export default function DeviceDetails({ device, onBack }) {
|
|||||||
const summaryItems = [
|
const summaryItems = [
|
||||||
{ label: "Device Name", value: summary.hostname || agent.hostname || device?.hostname || "unknown" },
|
{ label: "Device Name", value: summary.hostname || agent.hostname || device?.hostname || "unknown" },
|
||||||
{ label: "Operating System", value: summary.operating_system || agent.agent_operating_system || "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: (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Box component="span" sx={{
|
||||||
|
display: 'inline-block', width: 10, height: 10, borderRadius: 10,
|
||||||
|
bgcolor: agent?.collector_active ? '#00d18c' : '#ff4f4f'
|
||||||
|
}} />
|
||||||
|
<span>{summary.last_user || 'unknown'}</span>
|
||||||
|
</Box>
|
||||||
|
) },
|
||||||
{ label: "Internal IP", value: summary.internal_ip || "unknown" },
|
{ label: "Internal IP", value: summary.internal_ip || "unknown" },
|
||||||
{ label: "External IP", value: summary.external_ip || "unknown" },
|
{ label: "External IP", value: summary.external_ip || "unknown" },
|
||||||
{ label: "Last Reboot", value: summary.last_reboot ? formatDateTime(summary.last_reboot) : "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 = () => (
|
||||||
|
<Box sx={{ maxHeight: 400, overflowY: "auto" }}>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell sortDirection={historyOrderBy === "script_name" ? historyOrder : false}>
|
||||||
|
<TableSortLabel
|
||||||
|
active={historyOrderBy === "script_name"}
|
||||||
|
direction={historyOrderBy === "script_name" ? historyOrder : "asc"}
|
||||||
|
onClick={() => handleHistorySort("script_name")}
|
||||||
|
>
|
||||||
|
Script Executed
|
||||||
|
</TableSortLabel>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sortDirection={historyOrderBy === "ran_at" ? historyOrder : false}>
|
||||||
|
<TableSortLabel
|
||||||
|
active={historyOrderBy === "ran_at"}
|
||||||
|
direction={historyOrderBy === "ran_at" ? historyOrder : "asc"}
|
||||||
|
onClick={() => handleHistorySort("ran_at")}
|
||||||
|
>
|
||||||
|
Ran On
|
||||||
|
</TableSortLabel>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sortDirection={historyOrderBy === "status" ? historyOrder : false}>
|
||||||
|
<TableSortLabel
|
||||||
|
active={historyOrderBy === "status"}
|
||||||
|
direction={historyOrderBy === "status" ? historyOrder : "asc"}
|
||||||
|
onClick={() => handleHistorySort("status")}
|
||||||
|
>
|
||||||
|
Job Status
|
||||||
|
</TableSortLabel>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
StdOut / StdErr
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{sortedHistory.map((r) => (
|
||||||
|
<TableRow key={r.id}>
|
||||||
|
<TableCell>{r.script_name}</TableCell>
|
||||||
|
<TableCell>{formatTimestamp(r.ran_at)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Box sx={{
|
||||||
|
display: "inline-block",
|
||||||
|
px: 1.2,
|
||||||
|
py: 0.25,
|
||||||
|
borderRadius: 999,
|
||||||
|
bgcolor: jobStatusColor(r.status),
|
||||||
|
color: "#000",
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: "12px"
|
||||||
|
}}>
|
||||||
|
{r.status}
|
||||||
|
</Box>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Box sx={{ display: "flex", gap: 1 }}>
|
||||||
|
{r.has_stdout ? (
|
||||||
|
<Button size="small" onClick={() => handleViewOutput(r, 'stdout')} sx={{ color: "#58a6ff", textTransform: "none", minWidth: 0, p: 0 }}>
|
||||||
|
StdOut
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{r.has_stderr ? (
|
||||||
|
<Button size="small" onClick={() => handleViewOutput(r, 'stderr')} sx={{ color: "#ff4f4f", textTransform: "none", minWidth: 0, p: 0 }}>
|
||||||
|
StdErr
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{sortedHistory.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={4} sx={{ color: "#888" }}>No activity yet.</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ label: "Summary", content: renderSummary() },
|
{ label: "Summary", content: renderSummary() },
|
||||||
{ label: "Software", content: renderSoftware() },
|
{ label: "Software", content: renderSoftware() },
|
||||||
{ label: "Memory", content: renderMemory() },
|
{ label: "Memory", content: renderMemory() },
|
||||||
{ label: "Storage", content: renderStorage() },
|
{ 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
|
// Use the snapshotted status so it stays static while on this page
|
||||||
const status = lockedStatus || statusFromHeartbeat(agent.last_seen || device?.lastSeen);
|
const status = lockedStatus || statusFromHeartbeat(agent.last_seen || device?.lastSeen);
|
||||||
@@ -514,6 +704,32 @@ export default function DeviceDetails({ device, onBack }) {
|
|||||||
))}
|
))}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
<Box sx={{ mt: 2 }}>{tabs[tab].content}</Box>
|
<Box sx={{ mt: 2 }}>{tabs[tab].content}</Box>
|
||||||
|
|
||||||
|
<Dialog open={outputOpen} onClose={() => setOutputOpen(false)} fullWidth maxWidth="md"
|
||||||
|
PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}
|
||||||
|
>
|
||||||
|
<DialogTitle>{outputTitle}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Box sx={{ border: "1px solid #333", borderRadius: 1, bgcolor: "#1e1e1e", maxHeight: 500, overflow: "auto" }}>
|
||||||
|
<Editor
|
||||||
|
value={outputContent}
|
||||||
|
onValueChange={() => {}}
|
||||||
|
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 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setOutputOpen(false)} sx={{ color: "#58a6ff" }}>Close</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -11,12 +11,15 @@ import {
|
|||||||
TableHead,
|
TableHead,
|
||||||
TableRow,
|
TableRow,
|
||||||
TableSortLabel,
|
TableSortLabel,
|
||||||
|
Checkbox,
|
||||||
|
Button,
|
||||||
IconButton,
|
IconButton,
|
||||||
Menu,
|
Menu,
|
||||||
MenuItem
|
MenuItem
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import MoreVertIcon from "@mui/icons-material/MoreVert";
|
import MoreVertIcon from "@mui/icons-material/MoreVert";
|
||||||
import { DeleteDeviceDialog } from "../Dialogs.jsx";
|
import { DeleteDeviceDialog } from "../Dialogs.jsx";
|
||||||
|
import QuickJob from "../Scheduling/Quick_Job.jsx";
|
||||||
|
|
||||||
function formatLastSeen(tsSec, offlineAfter = 120) {
|
function formatLastSeen(tsSec, offlineAfter = 120) {
|
||||||
if (!tsSec) return "unknown";
|
if (!tsSec) return "unknown";
|
||||||
@@ -48,6 +51,8 @@ export default function DeviceList({ onSelectDevice }) {
|
|||||||
const [menuAnchor, setMenuAnchor] = useState(null);
|
const [menuAnchor, setMenuAnchor] = useState(null);
|
||||||
const [selected, setSelected] = useState(null);
|
const [selected, setSelected] = useState(null);
|
||||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
|
const [selectedHosts, setSelectedHosts] = useState(() => new Set());
|
||||||
|
const [quickJobOpen, setQuickJobOpen] = useState(false);
|
||||||
|
|
||||||
const fetchAgents = useCallback(async () => {
|
const fetchAgents = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -117,19 +122,65 @@ export default function DeviceList({ onSelectDevice }) {
|
|||||||
setSelected(null);
|
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 (
|
return (
|
||||||
<Paper sx={{ m: 2, p: 0, bgcolor: "#1e1e1e" }} elevation={2}>
|
<Paper sx={{ m: 2, p: 0, bgcolor: "#1e1e1e" }} elevation={2}>
|
||||||
<Box sx={{ p: 2, pb: 1 }}>
|
<Box sx={{ p: 2, pb: 1, display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||||
<Typography variant="h6" sx={{ color: "#58a6ff", mb: 0 }}>
|
<Typography variant="h6" sx={{ color: "#58a6ff", mb: 0 }}>
|
||||||
Devices
|
Devices
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" sx={{ color: "#aaa" }}>
|
<Box>
|
||||||
Devices connected to Borealis via Agent and their last check-ins.
|
<Button
|
||||||
</Typography>
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
disabled={selectedHosts.size === 0}
|
||||||
|
onClick={() => setQuickJobOpen(true)}
|
||||||
|
sx={{
|
||||||
|
mr: 1,
|
||||||
|
color: selectedHosts.size === 0 ? "#666" : "#58a6ff",
|
||||||
|
borderColor: selectedHosts.size === 0 ? "#333" : "#58a6ff",
|
||||||
|
textTransform: "none"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Quick Job
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<Table size="small" sx={{ minWidth: 680 }}>
|
<Table size="small" sx={{ minWidth: 680 }}>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
|
<TableCell padding="checkbox">
|
||||||
|
<Checkbox
|
||||||
|
indeterminate={isIndeterminate}
|
||||||
|
checked={isAllChecked}
|
||||||
|
onChange={toggleAll}
|
||||||
|
sx={{ color: "#777" }}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
<TableCell sortDirection={orderBy === "status" ? order : false}>
|
<TableCell sortDirection={orderBy === "status" ? order : false}>
|
||||||
<TableSortLabel
|
<TableSortLabel
|
||||||
active={orderBy === "status"}
|
active={orderBy === "status"}
|
||||||
@@ -172,6 +223,13 @@ export default function DeviceList({ onSelectDevice }) {
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{sorted.map((r, i) => (
|
{sorted.map((r, i) => (
|
||||||
<TableRow key={r.id || i} hover>
|
<TableRow key={r.id || i} hover>
|
||||||
|
<TableCell padding="checkbox" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedHosts.has(r.hostname)}
|
||||||
|
onChange={toggleOne(r.hostname)}
|
||||||
|
sx={{ color: "#777" }}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Box sx={{ display: "flex", alignItems: "center" }}>
|
<Box sx={{ display: "flex", alignItems: "center" }}>
|
||||||
<Box
|
<Box
|
||||||
@@ -218,7 +276,7 @@ export default function DeviceList({ onSelectDevice }) {
|
|||||||
))}
|
))}
|
||||||
{sorted.length === 0 && (
|
{sorted.length === 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={5} sx={{ color: "#888" }}>
|
<TableCell colSpan={6} sx={{ color: "#888" }}>
|
||||||
No agents connected.
|
No agents connected.
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -238,6 +296,14 @@ export default function DeviceList({ onSelectDevice }) {
|
|||||||
onCancel={() => setConfirmOpen(false)}
|
onCancel={() => setConfirmOpen(false)}
|
||||||
onConfirm={handleDelete}
|
onConfirm={handleDelete}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{quickJobOpen && (
|
||||||
|
<QuickJob
|
||||||
|
open={quickJobOpen}
|
||||||
|
onClose={() => setQuickJobOpen(false)}
|
||||||
|
hostnames={[...selectedHosts]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
207
Data/Server/WebUI/src/Scheduling/Quick_Job.jsx
Normal file
207
Data/Server/WebUI/src/Scheduling/Quick_Job.jsx
Normal file
@@ -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) => (
|
||||||
|
<TreeItem
|
||||||
|
key={n.id}
|
||||||
|
itemId={n.id}
|
||||||
|
label={
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center" }}>
|
||||||
|
{n.isFolder ? (
|
||||||
|
<FolderIcon fontSize="small" sx={{ mr: 1, color: "#ccc" }} />
|
||||||
|
) : (
|
||||||
|
<DescriptionIcon fontSize="small" sx={{ mr: 1, color: "#ccc" }} />
|
||||||
|
)}
|
||||||
|
<Typography variant="body2" sx={{ color: "#e6edf3" }}>{n.label}</Typography>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{n.children && n.children.length ? renderNodes(n.children) : null}
|
||||||
|
</TreeItem>
|
||||||
|
));
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Dialog open={open} onClose={running ? undefined : onClose} fullWidth maxWidth="md"
|
||||||
|
PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}
|
||||||
|
>
|
||||||
|
<DialogTitle>Quick Job</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Typography variant="body2" sx={{ color: "#aaa", mb: 1 }}>
|
||||||
|
Select a script to run on {hostnames.length} device{hostnames.length !== 1 ? "s" : ""}.
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: "flex", gap: 2 }}>
|
||||||
|
<Paper sx={{ flex: 1, p: 1, bgcolor: "#1e1e1e", maxHeight: 400, overflow: "auto" }}>
|
||||||
|
<SimpleTreeView sx={{ color: "#e6edf3" }} onItemSelectionToggle={onItemSelect}>
|
||||||
|
{tree.length ? renderNodes(tree) : (
|
||||||
|
<Typography variant="body2" sx={{ color: "#888", p: 1 }}>
|
||||||
|
No scripts found.
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</SimpleTreeView>
|
||||||
|
</Paper>
|
||||||
|
<Box sx={{ width: 320 }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ color: "#ccc", mb: 1 }}>Selection</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: selectedPath ? "#e6edf3" : "#888" }}>
|
||||||
|
{selectedPath || "No script selected"}
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ mt: 2 }}>
|
||||||
|
<FormControlLabel
|
||||||
|
control={<Checkbox size="small" checked={runAsCurrentUser} onChange={(e) => setRunAsCurrentUser(e.target.checked)} />}
|
||||||
|
label={<Typography variant="body2">Run as currently logged-in user</Typography>}
|
||||||
|
/>
|
||||||
|
<Typography variant="caption" sx={{ color: "#888" }}>
|
||||||
|
Unchecked = run as SYSTEM (requires agent service)
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
{error && (
|
||||||
|
<Typography variant="body2" sx={{ color: "#ff4f4f", mt: 1 }}>{error}</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={onClose} disabled={running} sx={{ color: "#58a6ff" }}>Cancel</Button>
|
||||||
|
<Button onClick={onRun} disabled={running || !selectedPath}
|
||||||
|
sx={{ color: running || !selectedPath ? "#666" : "#58a6ff" }}
|
||||||
|
>
|
||||||
|
Run
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
@@ -19,6 +19,7 @@ import io
|
|||||||
|
|
||||||
# Borealis Python API Endpoints
|
# Borealis Python API Endpoints
|
||||||
from Python_API_Endpoints.ocr_engines import run_ocr_on_base64
|
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
|
# Flask + WebSocket Server Configuration
|
||||||
@@ -659,6 +660,22 @@ def init_db():
|
|||||||
cur.execute(
|
cur.execute(
|
||||||
"CREATE TABLE IF NOT EXISTS device_details (hostname TEXT PRIMARY KEY, description TEXT, details TEXT)"
|
"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.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
@@ -738,10 +755,20 @@ load_agents_from_db()
|
|||||||
|
|
||||||
@app.route("/api/agents")
|
@app.route("/api/agents")
|
||||||
def get_agents():
|
def get_agents():
|
||||||
"""
|
"""Return agents with collector activity indicator."""
|
||||||
Return a dict keyed by agent_id with hostname, os, last_seen, status.
|
now = time.time()
|
||||||
"""
|
out = {}
|
||||||
return jsonify(registered_agents)
|
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"])
|
@app.route("/api/agent/details", methods=["POST"])
|
||||||
@@ -840,6 +867,230 @@ def set_device_description(hostname: str):
|
|||||||
return jsonify({"error": str(e)}), 500
|
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/<hostname>", 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/<int:job_id>", 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/<agent_id>", methods=["DELETE"])
|
@app.route("/api/agent/<agent_id>", methods=["DELETE"])
|
||||||
def delete_agent(agent_id: str):
|
def delete_agent(agent_id: str):
|
||||||
"""Remove an agent from the registry and database."""
|
"""Remove an agent from the registry and database."""
|
||||||
@@ -979,7 +1230,8 @@ def receive_screenshot_task(data):
|
|||||||
"timestamp": time.time()
|
"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")
|
@socketio.on("connect_agent")
|
||||||
def connect_agent(data):
|
def connect_agent(data):
|
||||||
@@ -998,6 +1250,12 @@ def connect_agent(data):
|
|||||||
rec["agent_operating_system"] = rec.get("agent_operating_system", "-")
|
rec["agent_operating_system"] = rec.get("agent_operating_system", "-")
|
||||||
rec["last_seen"] = int(time.time())
|
rec["last_seen"] = int(time.time())
|
||||||
rec["status"] = "provisioned" if agent_id in agent_configurations else "orphaned"
|
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
|
# If we already know the hostname for this agent, persist last_seen so it
|
||||||
# can be restored after server restarts.
|
# can be restored after server restarts.
|
||||||
try:
|
try:
|
||||||
@@ -1056,7 +1314,8 @@ def receive_screenshot(data):
|
|||||||
"image_base64": image,
|
"image_base64": image,
|
||||||
"timestamp": time.time()
|
"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")
|
@socketio.on("disconnect")
|
||||||
def 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')}")
|
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")
|
@socketio.on("list_agent_windows")
|
||||||
def handle_list_agent_windows(data):
|
def handle_list_agent_windows(data):
|
||||||
"""
|
"""
|
||||||
Forwards list_agent_windows event to all agents (or filter for a specific agent_id).
|
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")
|
@socketio.on("agent_window_list")
|
||||||
def handle_agent_window_list(data):
|
def handle_agent_window_list(data):
|
||||||
"""
|
"""
|
||||||
Relay the list of windows from the agent back to all connected clients.
|
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
|
# Server Launch
|
||||||
|
@@ -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
|
|
5
Scripts/Examples/Write Canary to C Drive Root.ps1
Normal file
5
Scripts/Examples/Write Canary to C Drive Root.ps1
Normal file
@@ -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
|
8
Scripts/Examples/Write Canary to CurrentUser Desktop.ps1
Normal file
8
Scripts/Examples/Write Canary to CurrentUser Desktop.ps1
Normal file
@@ -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
|
Reference in New Issue
Block a user