diff --git a/Borealis.ps1 b/Borealis.ps1 index 00690ad..0b04924 100644 --- a/Borealis.ps1 +++ b/Borealis.ps1 @@ -430,15 +430,17 @@ function InstallOrUpdate-BorealisAgent { Remove-BorealisServicesAndTasks -LogName 'Install.log' Write-Host "Deploying Borealis Agent..." -ForegroundColor Blue - $venvFolder = "Agent" - $agentSourcePath = "Data\Agent\agent.py" - $agentRequirements = "Data\Agent\agent-requirements.txt" - $agentDestinationFolder = "$venvFolder\Borealis" - $agentDestinationFile = "$venvFolder\Borealis\agent.py" - $venvPython = Join-Path $scriptDir $venvFolder | Join-Path -ChildPath 'Scripts\python.exe' + # Resolve all paths relative to the script directory to avoid CWD issues + $venvFolderPath = Join-Path $scriptDir 'Agent' + $agentSourceRoot = Join-Path $scriptDir 'Data\Agent' + $agentSourcePath = Join-Path $agentSourceRoot 'agent.py' + $agentRequirements = Join-Path $agentSourceRoot 'agent-requirements.txt' + $agentDestinationFolder = Join-Path $venvFolderPath 'Borealis' + $agentDestinationFile = Join-Path $agentDestinationFolder 'agent.py' + $venvPython = Join-Path $venvFolderPath 'Scripts\python.exe' Run-Step "Create Virtual Python Environment" { - if (-not (Test-Path "$venvFolder\Scripts\Activate")) { + if (-not (Test-Path (Join-Path $venvFolderPath 'Scripts\Activate'))) { $pythonForVenv = $pythonExe if (-not (Test-Path $pythonForVenv)) { $pyCmd = Get-Command py -ErrorAction SilentlyContinue @@ -450,23 +452,27 @@ function InstallOrUpdate-BorealisAgent { exit 1 } } - & $pythonForVenv -m venv $venvFolder + & $pythonForVenv -m venv $venvFolderPath } if (Test-Path $agentSourcePath) { + # Cleanup Previous Agent Folder & Create New Folder Remove-Item $agentDestinationFolder -Recurse -Force -ErrorAction SilentlyContinue New-Item -Path $agentDestinationFolder -ItemType Directory -Force | Out-Null - Copy-Item "Data\Agent\agent.py" $agentDestinationFolder -Recurse - # agent_info has been migrated into roles; no longer copied - # Legacy agent_roles kept for compatibility only if needed - Copy-Item "Data\Agent\Python_API_Endpoints" $agentDestinationFolder -Recurse - Copy-Item "Data\Agent\role_manager.py" $agentDestinationFolder -Force - if (Test-Path "Data\Agent\Roles") { Copy-Item "Data\Agent\Roles" $agentDestinationFolder -Recurse } - Copy-Item "Data\Agent\agent_deployment.py" $agentDestinationFolder -Force - # tray is now embedded in CURRENTUSER role; no launcher to copy - if (Test-Path "Data\Agent\Borealis.ico") { Copy-Item "Data\Agent\Borealis.ico" $agentDestinationFolder -Force } - if (Test-Path "Data\Agent\launch_service.ps1") { Copy-Item "Data\Agent\launch_service.ps1" $agentDestinationFolder -Force } + + # Copy Agent Files to Virtual Python Environment + $coreAgentFiles = @( + (Join-Path $agentSourceRoot 'agent.py'), + (Join-Path $agentSourceRoot 'Python_API_Endpoints'), + (Join-Path $agentSourceRoot 'agent_deployment.py'), + (Join-Path $agentSourceRoot 'Borealis.ico'), + (Join-Path $agentSourceRoot 'launch_service.ps1'), + (Join-Path $agentSourceRoot 'role_manager.py'), + (Join-Path $agentSourceRoot 'Roles') + ) + + Copy-Item $coreAgentFiles -Destination $agentDestinationFolder -Recurse -Force } - . "$venvFolder\Scripts\Activate" + . (Join-Path $venvFolderPath 'Scripts\Activate') } Run-Step "Install Python Dependencies" { diff --git a/Data/Agent/Roles/Device_Audit.yml b/Data/Agent/Roles/Device_Audit.yml new file mode 100644 index 0000000..f85ea02 --- /dev/null +++ b/Data/Agent/Roles/Device_Audit.yml @@ -0,0 +1,120 @@ +- hosts: all + gather_facts: false + tasks: + - name: Collect summary via PowerShell + ansible.builtin.shell: | + $ErrorActionPreference = 'Stop' + $hostname = $env:COMPUTERNAME + $username = $env:USERNAME + $domain = $env:USERDOMAIN + $w = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' + $product = [string]$w.ProductName + $display = [string]$w.DisplayVersion + $build = [string]$w.CurrentBuildNumber + $os = ($product + ' ' + $display).Trim() + $boot = (Get-CimInstance Win32_OperatingSystem).LastBootUpTime + $uptime = [int]((Get-Date) - $boot).TotalSeconds + $epoch = [int](((Get-Date $boot).ToUniversalTime() - [datetime]'1970-01-01').TotalSeconds) + $out = [pscustomobject]@{ hostname=$hostname; os=$os; username=$username; domain=$domain; uptime_sec=$uptime; last_reboot=$epoch } + $out | ConvertTo-Json -Depth 4 + register: summary_raw + changed_when: false + + - name: Parse summary JSON + ansible.builtin.set_fact: + _summary: "{{ summary_raw.stdout | from_json | default({}, true) }}" + + - name: Collect installed software + ansible.builtin.shell: | + $ErrorActionPreference = 'SilentlyContinue' + $paths = @('HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*', + 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*', + 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*') + $items = foreach ($p in $paths) { + if (Test-Path $p) { + Get-ItemProperty -Path $p | Where-Object { $_.DisplayName } | ForEach-Object { + [pscustomobject]@{ name=[string]$_.DisplayName; version=[string]$_.DisplayVersion } + } + } + } + $items | Sort-Object name -Unique | ConvertTo-Json -Depth 4 + register: software_raw + changed_when: false + + - name: Parse software JSON + ansible.builtin.set_fact: + _software: "{{ software_raw.stdout | default('[]') | from_json | default([], true) }}" + + - name: Collect memory modules + ansible.builtin.shell: | + $ErrorActionPreference = 'SilentlyContinue' + Get-CimInstance Win32_PhysicalMemory | ForEach-Object { + [pscustomobject]@{ slot=$_.BankLabel; speed=[string]$_.Speed; serial=[string]$_.SerialNumber; capacity=[string]$_.Capacity } + } | ConvertTo-Json -Depth 4 + register: memory_raw + changed_when: false + + - name: Parse memory JSON + ansible.builtin.set_fact: + _memory: "{{ memory_raw.stdout | default('[]') | from_json | default([], true) }}" + + - name: Collect storage volumes + ansible.builtin.shell: | + $ErrorActionPreference = 'SilentlyContinue' + Get-CimInstance Win32_LogicalDisk | ForEach-Object { + $total = [double]$_.Size; $free = [double]$_.FreeSpace; $used = $total - $free; + $usage = if ($total -gt 0) { [math]::Round(($used / $total) * 100, 2) } else { 0 } + $type = switch ($_.DriveType) { 2 {'Removable'} 3 {'Fixed Disk'} default {'Unknown'} } + [pscustomobject]@{ drive=$_.DeviceID; disk_type=$type; usage=$usage; total=$total; free=$free; used=$used } + } | ConvertTo-Json -Depth 4 + register: storage_raw + changed_when: false + + - name: Parse storage JSON + ansible.builtin.set_fact: + _storage: "{{ storage_raw.stdout | default('[]') | from_json | default([], true) }}" + + - name: Collect network adapters + ansible.builtin.shell: | + $ErrorActionPreference = 'SilentlyContinue' + try { + $ip = Get-NetIPAddress -AddressFamily IPv4 -ErrorAction Stop | Where-Object { $_.IPAddress -and $_.IPAddress -notmatch '^169\.254\.' -and $_.IPAddress -ne '127.0.0.1' } + $ad = Get-NetAdapter | ForEach-Object { $_ | Select-Object -Property Name, InterfaceAlias, MacAddress } + $map = @{}; foreach($a in $ad){ $map[$a.InterfaceAlias] = $a.MacAddress } + $tmp = @{} + foreach($e in $ip){ + $alias = if ($e.InterfaceAlias) { $e.InterfaceAlias } else { 'unknown' } + $item = $tmp[$alias] + if (-not $item) { $item = [pscustomobject]@{ adapter=$alias; ips=@(); mac='' }; $tmp[$alias] = $item } + $item.mac = $map[$alias] + if ($e.IPAddress -and $item.ips -notcontains $e.IPAddress) { $item.ips += $e.IPAddress } + } + $out = $tmp.GetEnumerator() | ForEach-Object { $_.Value } + } catch { $out = @() } + $out | ConvertTo-Json -Depth 4 + register: network_raw + changed_when: false + + - name: Parse network JSON + ansible.builtin.set_fact: + _network: "{{ network_raw.stdout | default('[]') | from_json | default([], true) }}" + + - name: Compose device details structure + ansible.builtin.set_fact: + device_details: + summary: "{{ _summary }}" + software: "{{ _software }}" + memory: "{{ _memory }}" + storage: "{{ _storage }}" + network: "{{ _network }}" + + - name: Derive internal IP + ansible.builtin.set_fact: + device_details: "{{ device_details | combine({'summary': (device_details.summary | combine({'internal_ip': ( (_network | map(attribute='ips') | list | first | default([])) | first | default('') ) })) }) }}" + + - name: Write device details JSON + ansible.builtin.copy: + content: "{{ device_details | to_nice_json }}" + dest: "{{ output_file }}" + run_once: true + diff --git a/Data/Agent/Roles/role_DeviceAudit.py b/Data/Agent/Roles/role_DeviceAudit.py index 1f1961e..710ceaf 100644 --- a/Data/Agent/Roles/role_DeviceAudit.py +++ b/Data/Agent/Roles/role_DeviceAudit.py @@ -1,4 +1,5 @@ import os +import sys import json import time import socket @@ -7,6 +8,7 @@ import subprocess import shutil import string import asyncio +from pathlib import Path try: import psutil # type: ignore @@ -124,6 +126,79 @@ def collect_summary(CONFIG): return {'hostname': socket.gethostname()} +def _project_root(): + try: + # Agent layout: Agent/Borealis/{this_file}; root is two levels up + return os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')) + except Exception: + return os.getcwd() + + +def _run_ansible_audit(ctx) -> dict: + try: + exe_dir = os.path.dirname(sys.executable) + candidate = os.path.join(exe_dir, 'ansible-playbook.exe' if IS_WINDOWS else 'ansible-playbook') + ansible_playbook = candidate if os.path.isfile(candidate) else 'ansible-playbook' + + base = os.path.join(_project_root(), 'Logs', 'Agent', 'ansible') + os.makedirs(base, exist_ok=True) + out_path = os.path.join(base, 'audit.json') + + # Require an external playbook; look next to this role first, then source tree as fallback + roles_dir = os.path.dirname(__file__) + pb_candidates = [ + os.path.join(roles_dir, 'Device_Audit.yml'), + os.path.join(_project_root(), 'Data', 'Agent', 'Roles', 'Device_Audit.yml'), + ] + pb_path = next((p for p in pb_candidates if os.path.isfile(p)), None) + if not pb_path: + # Log helpful error and return empty + try: + with open(os.path.join(base, 'ansible.err.log'), 'w', encoding='utf-8', newline='\n') as ef: + ef.write('Device_Audit.yml not found in roles directory.\n') + ef.write('Searched:\n - ' + '\n - '.join(pb_candidates)) + except Exception: + pass + return {} + + out_posix = Path(out_path).as_posix() + py_interp = Path(sys.executable).as_posix() + + env = os.environ.copy() + env.setdefault('PYTHONIOENCODING', 'utf-8') + env.setdefault('ANSIBLE_FORCE_COLOR', '0') + + cmd = [ + ansible_playbook, + '-i', 'localhost,', + '-c', 'local', + pb_path, + '-e', f'ansible_python_interpreter={py_interp}', + '-e', 'ansible_shell_type=powershell' if IS_WINDOWS else 'ansible_shell_type=sh', + '-e', 'ansible_shell_executable=powershell.exe' if IS_WINDOWS else 'ansible_shell_executable=/bin/sh', + '-e', f'output_file={out_posix}', + ] + + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=300, env=env) + if proc.returncode != 0: + try: + with open(os.path.join(base, 'ansible.err.log'), 'w', encoding='utf-8', newline='\n') as ef: + ef.write(proc.stdout or '') + ef.write('\n--- STDERR ---\n') + ef.write(proc.stderr or '') + except Exception: + pass + return {} + try: + with open(out_path, 'r', encoding='utf-8') as jf: + details = json.load(jf) + return details if isinstance(details, dict) else {} + except Exception: + return {} + except Exception: + return {} + + def _ps_json(cmd: str, timeout: int = 60): try: out = subprocess.run(["powershell", "-NoProfile", "-Command", cmd], capture_output=True, text=True, timeout=timeout) @@ -425,6 +500,9 @@ class Role: self.ctx = ctx self._ext_ip = None self._ext_ip_ts = 0 + self._ansible_cache = None + self._ansible_ts = 0 + self._last_details = None try: # Set OS string once self.ctx.config.data['agent_operating_system'] = detect_agent_os() @@ -445,78 +523,35 @@ class Role: pass async def _report_loop(self): + interval_sec = 300 # post heartbeat/details every 5 minutes while True: try: - details = { - 'summary': collect_summary(self.ctx.config), - 'software': collect_software(), - 'memory': collect_memory(), - 'storage': collect_storage(), - 'network': collect_network(), - } - # Derive additional summary fields + # Determine audit refresh interval (minutes), default 30 try: - # Internal IP: first IPv4 on first adapter - internal_ip = '' - for a in (details.get('network') or []): - for ip in (a.get('ips') or []): - if ip and not ip.startswith('169.254.') and ip != '127.0.0.1': - internal_ip = ip - break - if internal_ip: - break - details['summary']['internal_ip'] = internal_ip + refresh_min = int(self.ctx.config.data.get('audit_interval_minutes', 30)) except Exception: - pass - try: - details['summary']['device_type'] = detect_device_type() - except Exception: - pass - try: - if psutil: - details['summary']['last_reboot'] = int(psutil.boot_time()) - except Exception: - pass + refresh_min = 30 + refresh_sec = max(300, refresh_min * 60) + + now = time.time() + need_refresh = (not self._last_details) or ((now - self._ansible_ts) > refresh_sec) + if need_refresh: + details = _run_ansible_audit(self.ctx) + if details: + self._last_details = details + self._ansible_ts = now + + # Always post the latest available details (possibly cached) + details_to_send = self._last_details or {'summary': collect_summary(self.ctx.config)} url = (self.ctx.config.data.get('borealis_server_url', 'http://localhost:5000') or '').rstrip('/') + '/api/agent/details' payload = { 'agent_id': self.ctx.agent_id, - 'hostname': details.get('summary', {}).get('hostname', socket.gethostname()), - 'details': details, + 'hostname': details_to_send.get('summary', {}).get('hostname', socket.gethostname()), + 'details': details_to_send, } if aiohttp is not None: async with aiohttp.ClientSession() as session: - # External IP: refresh at most every 30 minutes - try: - now = time.time() - if (now - self._ext_ip_ts) > 1800: - # Try ipify JSON; fallback to plain-text ifconfig.me - ok = False - try: - async with session.get('https://api.ipify.org?format=json', timeout=8) as resp: - if resp.status == 200: - j = await resp.json() - self._ext_ip = (j.get('ip') or '').strip() - self._ext_ip_ts = now - ok = True - except Exception: - pass - if not ok: - try: - async with session.get('https://ifconfig.me/ip', timeout=8) as resp2: - if resp2.status == 200: - t = (await resp2.text()) or '' - t = t.strip() - if t: - self._ext_ip = t - self._ext_ip_ts = now - ok = True - except Exception: - pass - if self._ext_ip: - details['summary']['external_ip'] = self._ext_ip - except Exception: - pass await session.post(url, json=payload, timeout=10) except Exception: pass - await asyncio.sleep(300) + await asyncio.sleep(interval_sec) diff --git a/Data/Agent/agent-requirements.txt b/Data/Agent/agent-requirements.txt index fa2e316..3df67a5 100644 --- a/Data/Agent/agent-requirements.txt +++ b/Data/Agent/agent-requirements.txt @@ -26,3 +26,7 @@ pywinauto # Windows-based Macro Automation Library sounddevice numpy pywin32; platform_system == "Windows" + +# Ansible-based inventory collection (Windows local-only) +# Note: ansible-core is heavy; enable via config flag in DeviceAudit role +ansible-core