mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-10-26 22:21:58 -06:00
Refactored & Modularized Agent Roles
This commit is contained in:
@@ -6,7 +6,7 @@ $venvDir = "$packagingDir\Pyinstaller_Virtual_Environment"
|
||||
$distDir = "$packagingDir\dist"
|
||||
$buildDir = "$packagingDir\build"
|
||||
$specPath = "$packagingDir"
|
||||
$agentScript = "borealis-agent.py"
|
||||
$agentScript = "agent.py"
|
||||
$outputName = "Borealis-Agent"
|
||||
$finalExeName = "$outputName.exe"
|
||||
$requirementsPath = "agent-requirements.txt"
|
||||
|
||||
2
Data/Agent/Roles/__init__.py
Normal file
2
Data/Agent/Roles/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# Roles package for Borealis Agent
|
||||
|
||||
280
Data/Agent/Roles/role_DeviceInventory.py
Normal file
280
Data/Agent/Roles/role_DeviceInventory.py
Normal file
@@ -0,0 +1,280 @@
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import socket
|
||||
import platform
|
||||
import subprocess
|
||||
import shutil
|
||||
import string
|
||||
import asyncio
|
||||
|
||||
try:
|
||||
import psutil # type: ignore
|
||||
except Exception:
|
||||
psutil = None
|
||||
|
||||
try:
|
||||
import aiohttp
|
||||
except Exception:
|
||||
aiohttp = None
|
||||
|
||||
|
||||
ROLE_NAME = 'device_inventory'
|
||||
ROLE_CONTEXTS = ['interactive']
|
||||
|
||||
|
||||
IS_WINDOWS = os.name == 'nt'
|
||||
|
||||
|
||||
def detect_agent_os():
|
||||
try:
|
||||
plat = platform.system().lower()
|
||||
if plat.startswith('win'):
|
||||
try:
|
||||
import winreg # type: ignore
|
||||
reg_path = r"SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion"
|
||||
access = getattr(winreg, 'KEY_READ', 0x20019)
|
||||
try:
|
||||
access |= winreg.KEY_WOW64_64KEY
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
key = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, reg_path, 0, access)
|
||||
except OSError:
|
||||
key = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, reg_path, 0, winreg.KEY_READ)
|
||||
|
||||
def _get(name, default=None):
|
||||
try:
|
||||
return winreg.QueryValueEx(key, name)[0]
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
product_name = _get("ProductName", "")
|
||||
display_version = _get("DisplayVersion", "")
|
||||
release_id = _get("ReleaseId", "")
|
||||
build_number = _get("CurrentBuildNumber", "") or _get("CurrentBuild", "")
|
||||
ubr = _get("UBR", None)
|
||||
|
||||
try:
|
||||
build_int = int(str(build_number).split(".")[0]) if build_number else 0
|
||||
except Exception:
|
||||
build_int = 0
|
||||
if build_int >= 22000:
|
||||
major_label = "11"
|
||||
elif build_int >= 10240:
|
||||
major_label = "10"
|
||||
else:
|
||||
major_label = platform.release()
|
||||
|
||||
os_name = f"Windows {major_label}"
|
||||
version_label = display_version or release_id or ""
|
||||
if isinstance(ubr, int):
|
||||
build_str = f"{build_number}.{ubr}" if build_number else str(ubr)
|
||||
else:
|
||||
try:
|
||||
build_str = f"{build_number}.{int(ubr)}" if build_number and ubr else (build_number or "")
|
||||
except Exception:
|
||||
build_str = build_number or ""
|
||||
|
||||
parts = [os_name]
|
||||
if product_name and product_name.lower().startswith('windows '):
|
||||
try:
|
||||
tail = product_name.split(' ', 2)[2]
|
||||
if tail:
|
||||
parts.append(tail)
|
||||
except Exception:
|
||||
pass
|
||||
if version_label:
|
||||
parts.append(version_label)
|
||||
if build_str:
|
||||
parts.append(f"Build {build_str}")
|
||||
return " ".join([p for p in parts if p]).strip() or platform.platform()
|
||||
except Exception:
|
||||
return platform.platform()
|
||||
elif plat == 'darwin':
|
||||
try:
|
||||
out = subprocess.run(["sw_vers", "-productVersion"], capture_output=True, text=True, timeout=3)
|
||||
ver = (out.stdout or '').strip()
|
||||
return f"macOS {ver}" if ver else "macOS"
|
||||
except Exception:
|
||||
return "macOS"
|
||||
else:
|
||||
try:
|
||||
import distro # type: ignore
|
||||
name = distro.name(pretty=True) or distro.id()
|
||||
ver = distro.version()
|
||||
return f"{name} {ver}".strip()
|
||||
except Exception:
|
||||
return platform.platform()
|
||||
except Exception:
|
||||
return "Unknown"
|
||||
|
||||
|
||||
def collect_summary(CONFIG):
|
||||
try:
|
||||
hostname = socket.gethostname()
|
||||
return {
|
||||
'hostname': hostname,
|
||||
'os': CONFIG.data.get('agent_operating_system', detect_agent_os()),
|
||||
'username': os.environ.get('USERNAME') or os.environ.get('USER') or '',
|
||||
'domain': os.environ.get('USERDOMAIN') or '',
|
||||
'uptime_sec': int(time.time() - psutil.boot_time()) if psutil else None,
|
||||
}
|
||||
except Exception:
|
||||
return {'hostname': socket.gethostname()}
|
||||
|
||||
|
||||
def collect_software():
|
||||
# Placeholder: fuller inventory can be added later
|
||||
return []
|
||||
|
||||
|
||||
def collect_memory():
|
||||
entries = []
|
||||
try:
|
||||
plat = platform.system().lower()
|
||||
if plat == 'windows':
|
||||
try:
|
||||
ps_cmd = (
|
||||
"Get-CimInstance Win32_PhysicalMemory | "
|
||||
"Select-Object BankLabel,Speed,SerialNumber,Capacity | ConvertTo-Json"
|
||||
)
|
||||
out = subprocess.run(["powershell", "-NoProfile", "-Command", ps_cmd], capture_output=True, text=True, timeout=60)
|
||||
data = json.loads(out.stdout or "[]")
|
||||
if isinstance(data, dict):
|
||||
data = [data]
|
||||
for stick in data:
|
||||
entries.append({
|
||||
'slot': stick.get('BankLabel', 'unknown'),
|
||||
'speed': str(stick.get('Speed', 'unknown')),
|
||||
'serial': stick.get('SerialNumber', 'unknown'),
|
||||
'capacity': stick.get('Capacity', 'unknown'),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
if not entries and psutil:
|
||||
try:
|
||||
vm = psutil.virtual_memory()
|
||||
entries.append({'slot': 'physical', 'speed': 'unknown', 'serial': 'unknown', 'capacity': vm.total})
|
||||
except Exception:
|
||||
pass
|
||||
return entries
|
||||
|
||||
|
||||
def collect_storage():
|
||||
disks = []
|
||||
try:
|
||||
if psutil:
|
||||
for part in psutil.disk_partitions():
|
||||
try:
|
||||
usage = psutil.disk_usage(part.mountpoint)
|
||||
except Exception:
|
||||
continue
|
||||
disks.append({
|
||||
'drive': part.device,
|
||||
'disk_type': 'Removable' if isinstance(part.opts, str) and 'removable' in part.opts.lower() else 'Fixed Disk',
|
||||
'usage': usage.percent,
|
||||
'total': usage.total,
|
||||
'free': usage.free,
|
||||
'used': usage.used,
|
||||
})
|
||||
else:
|
||||
# Fallback basic detection on Windows via drive letters
|
||||
if IS_WINDOWS:
|
||||
for letter in string.ascii_uppercase:
|
||||
drive = f"{letter}:\\"
|
||||
if os.path.exists(drive):
|
||||
try:
|
||||
usage = shutil.disk_usage(drive)
|
||||
except Exception:
|
||||
continue
|
||||
disks.append({
|
||||
'drive': drive,
|
||||
'disk_type': 'Fixed Disk',
|
||||
'usage': (usage.used / usage.total * 100) if usage.total else 0,
|
||||
'total': usage.total,
|
||||
'free': usage.free,
|
||||
'used': usage.used,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
return disks
|
||||
|
||||
|
||||
def collect_network():
|
||||
adapters = []
|
||||
try:
|
||||
if IS_WINDOWS:
|
||||
try:
|
||||
ps_cmd = (
|
||||
"Get-NetAdapter | Where-Object { $_.Status -eq 'Up' } | "
|
||||
"ForEach-Object { $_ | Select-Object -Property InterfaceAlias, MacAddress } | ConvertTo-Json"
|
||||
)
|
||||
out = subprocess.run(["powershell", "-NoProfile", "-Command", ps_cmd], capture_output=True, text=True, timeout=60)
|
||||
data = json.loads(out.stdout or "[]")
|
||||
if isinstance(data, dict):
|
||||
data = [data]
|
||||
for a in data:
|
||||
adapters.append({'adapter': a.get('InterfaceAlias', 'unknown'), 'ips': [], 'mac': a.get('MacAddress', 'unknown')})
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
out = subprocess.run(["ip", "-o", "-4", "addr", "show"], capture_output=True, text=True, timeout=60)
|
||||
for line in out.stdout.splitlines():
|
||||
parts = line.split()
|
||||
if len(parts) >= 4:
|
||||
name = parts[1]
|
||||
ip = parts[3].split("/")[0]
|
||||
adapters.append({'adapter': name, 'ips': [ip], 'mac': 'unknown'})
|
||||
except Exception:
|
||||
pass
|
||||
return adapters
|
||||
|
||||
|
||||
class Role:
|
||||
def __init__(self, ctx):
|
||||
self.ctx = ctx
|
||||
try:
|
||||
# Set OS string once
|
||||
self.ctx.config.data['agent_operating_system'] = detect_agent_os()
|
||||
self.ctx.config._write()
|
||||
except Exception:
|
||||
pass
|
||||
# Start periodic reporter
|
||||
try:
|
||||
self.task = self.ctx.loop.create_task(self._report_loop())
|
||||
except Exception:
|
||||
self.task = None
|
||||
|
||||
def stop_all(self):
|
||||
try:
|
||||
if self.task:
|
||||
self.task.cancel()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _report_loop(self):
|
||||
while True:
|
||||
try:
|
||||
details = {
|
||||
'summary': collect_summary(self.ctx.config),
|
||||
'software': collect_software(),
|
||||
'memory': collect_memory(),
|
||||
'storage': collect_storage(),
|
||||
'network': collect_network(),
|
||||
}
|
||||
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,
|
||||
}
|
||||
if aiohttp is not None:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
await session.post(url, json=payload, timeout=10)
|
||||
except Exception:
|
||||
pass
|
||||
await asyncio.sleep(300)
|
||||
|
||||
122
Data/Agent/Roles/role_Macro.py
Normal file
122
Data/Agent/Roles/role_Macro.py
Normal file
@@ -0,0 +1,122 @@
|
||||
import os
|
||||
import asyncio
|
||||
import importlib.util
|
||||
|
||||
|
||||
ROLE_NAME = 'macro'
|
||||
ROLE_CONTEXTS = ['interactive']
|
||||
|
||||
|
||||
def _load_macro_engines():
|
||||
try:
|
||||
base = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir))
|
||||
path = os.path.join(base, 'Python_API_Endpoints', 'macro_engines.py')
|
||||
spec = importlib.util.spec_from_file_location('macro_engines', path)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
assert spec and spec.loader
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
except Exception:
|
||||
class _Dummy:
|
||||
def list_windows(self):
|
||||
return []
|
||||
def send_keypress_to_window(self, handle, key):
|
||||
return False, 'unavailable'
|
||||
def type_text_to_window(self, handle, text):
|
||||
return False, 'unavailable'
|
||||
return _Dummy()
|
||||
|
||||
|
||||
macro_engines = _load_macro_engines()
|
||||
|
||||
|
||||
class Role:
|
||||
def __init__(self, ctx):
|
||||
self.ctx = ctx
|
||||
self.tasks = {}
|
||||
|
||||
def stop_all(self):
|
||||
for t in list(self.tasks.values()):
|
||||
try:
|
||||
t.cancel()
|
||||
except Exception:
|
||||
pass
|
||||
self.tasks.clear()
|
||||
|
||||
def on_config(self, roles_cfg):
|
||||
macro_roles = [r for r in roles_cfg if (r.get('role') == 'macro')]
|
||||
new_ids = {r.get('node_id') for r in macro_roles if r.get('node_id')}
|
||||
old_ids = set(self.tasks.keys())
|
||||
removed = old_ids - new_ids
|
||||
for rid in removed:
|
||||
t = self.tasks.pop(rid, None)
|
||||
if t:
|
||||
try:
|
||||
t.cancel()
|
||||
except Exception:
|
||||
pass
|
||||
for rcfg in macro_roles:
|
||||
nid = rcfg.get('node_id')
|
||||
if nid and nid not in self.tasks:
|
||||
self.tasks[nid] = asyncio.create_task(self._macro_task(rcfg))
|
||||
|
||||
async def _macro_task(self, cfg):
|
||||
nid = cfg.get('node_id')
|
||||
last_trigger_value = 0
|
||||
has_run_once = False
|
||||
while True:
|
||||
window_handle = cfg.get('window_handle')
|
||||
macro_type = cfg.get('macro_type', 'keypress')
|
||||
operation_mode = cfg.get('operation_mode', 'Continuous')
|
||||
key = cfg.get('key')
|
||||
text = cfg.get('text')
|
||||
interval_ms = int(cfg.get('interval_ms', 1000))
|
||||
randomize = cfg.get('randomize_interval', False)
|
||||
random_min = int(cfg.get('random_min', 750))
|
||||
random_max = int(cfg.get('random_max', 950))
|
||||
active = cfg.get('active', True)
|
||||
trigger = int(cfg.get('trigger', 0))
|
||||
|
||||
async def emit_macro_status(success, message=""):
|
||||
try:
|
||||
await self.ctx.sio.emit('macro_status', {
|
||||
'agent_id': self.ctx.agent_id,
|
||||
'node_id': nid,
|
||||
'success': success,
|
||||
'message': message,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not (active is True or str(active).lower() == 'true'):
|
||||
await asyncio.sleep(0.2)
|
||||
continue
|
||||
try:
|
||||
send_macro = False
|
||||
if operation_mode == 'Run Once':
|
||||
if not has_run_once:
|
||||
send_macro = True
|
||||
has_run_once = True
|
||||
elif operation_mode == 'Continuous':
|
||||
send_macro = True
|
||||
elif operation_mode == 'Trigger-Continuous':
|
||||
send_macro = (trigger == 1)
|
||||
elif operation_mode == 'Trigger-Once':
|
||||
if trigger == 1 and last_trigger_value != 1:
|
||||
send_macro = True
|
||||
else:
|
||||
send_macro = False
|
||||
|
||||
if send_macro:
|
||||
ok = False
|
||||
if macro_type == 'keypress' and key:
|
||||
ok = bool(macro_engines.send_keypress_to_window(window_handle, key))
|
||||
elif macro_type == 'text' and text:
|
||||
ok = bool(macro_engines.type_text_to_window(window_handle, text))
|
||||
await emit_macro_status(ok, 'sent' if ok else 'failed')
|
||||
last_trigger_value = trigger
|
||||
except Exception as e:
|
||||
await emit_macro_status(False, str(e))
|
||||
# interval wait
|
||||
await asyncio.sleep(max(0.05, (interval_ms or 1000) / 1000.0))
|
||||
|
||||
322
Data/Agent/Roles/role_Screenshot.py
Normal file
322
Data/Agent/Roles/role_Screenshot.py
Normal file
@@ -0,0 +1,322 @@
|
||||
import os
|
||||
import asyncio
|
||||
import concurrent.futures
|
||||
from functools import partial
|
||||
from io import BytesIO
|
||||
import base64
|
||||
import traceback
|
||||
|
||||
from PyQt5 import QtCore, QtGui, QtWidgets
|
||||
from PIL import ImageGrab
|
||||
import importlib.util
|
||||
|
||||
|
||||
ROLE_NAME = 'screenshot'
|
||||
ROLE_CONTEXTS = ['interactive']
|
||||
|
||||
|
||||
# Load macro engines from the local Python_API_Endpoints directory for window listings
|
||||
def _load_macro_engines():
|
||||
try:
|
||||
base = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir))
|
||||
path = os.path.join(base, 'Python_API_Endpoints', 'macro_engines.py')
|
||||
spec = importlib.util.spec_from_file_location('macro_engines', path)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
assert spec and spec.loader
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
except Exception:
|
||||
class _Dummy:
|
||||
def list_windows(self):
|
||||
return []
|
||||
return _Dummy()
|
||||
|
||||
|
||||
macro_engines = _load_macro_engines()
|
||||
|
||||
|
||||
overlay_green_thickness = 4
|
||||
overlay_gray_thickness = 2
|
||||
handle_size = overlay_green_thickness * 2
|
||||
extra_top_padding = overlay_green_thickness * 2 + 4
|
||||
|
||||
|
||||
overlay_widgets = {}
|
||||
|
||||
|
||||
class ScreenshotRegion(QtWidgets.QWidget):
|
||||
def __init__(self, ctx, node_id, x=100, y=100, w=300, h=200, alias=None):
|
||||
super().__init__()
|
||||
self.ctx = ctx
|
||||
self.node_id = node_id
|
||||
self.alias = alias
|
||||
self.setGeometry(
|
||||
x - handle_size,
|
||||
y - handle_size - extra_top_padding,
|
||||
w + handle_size * 2,
|
||||
h + handle_size * 2 + extra_top_padding,
|
||||
)
|
||||
self.setWindowFlags(QtCore.Qt.FramelessWindowHint | QtCore.Qt.WindowStaysOnTopHint)
|
||||
self.setAttribute(QtCore.Qt.WA_TranslucentBackground)
|
||||
self.resize_dir = None
|
||||
self.drag_offset = None
|
||||
self._start_geom = None
|
||||
self._start_pos = None
|
||||
self.setMouseTracking(True)
|
||||
|
||||
def paintEvent(self, event):
|
||||
p = QtGui.QPainter(self)
|
||||
p.setRenderHint(QtGui.QPainter.Antialiasing)
|
||||
w = self.width()
|
||||
h = self.height()
|
||||
|
||||
p.setPen(QtGui.QPen(QtGui.QColor(130, 130, 130), overlay_gray_thickness))
|
||||
p.drawRect(handle_size, handle_size + extra_top_padding, w - handle_size * 2, h - handle_size * 2 - extra_top_padding)
|
||||
|
||||
p.setPen(QtCore.Qt.NoPen)
|
||||
p.setBrush(QtGui.QBrush(QtGui.QColor(0, 191, 255)))
|
||||
edge = overlay_green_thickness * 3
|
||||
|
||||
p.drawRect(0, extra_top_padding, edge, overlay_green_thickness)
|
||||
p.drawRect(0, extra_top_padding, overlay_green_thickness, edge)
|
||||
p.drawRect(w - edge, extra_top_padding, edge, overlay_green_thickness)
|
||||
p.drawRect(w - overlay_green_thickness, extra_top_padding, overlay_green_thickness, edge)
|
||||
p.drawRect(0, h - overlay_green_thickness, edge, overlay_green_thickness)
|
||||
p.drawRect(0, h - edge, overlay_green_thickness, edge)
|
||||
p.drawRect(w - edge, h - overlay_green_thickness, edge, overlay_green_thickness)
|
||||
p.drawRect(w - overlay_green_thickness, h - edge, overlay_green_thickness, edge)
|
||||
|
||||
long = overlay_green_thickness * 6
|
||||
p.drawRect((w - long) // 2, extra_top_padding, long, overlay_green_thickness)
|
||||
p.drawRect((w - long) // 2, h - overlay_green_thickness, long, overlay_green_thickness)
|
||||
p.drawRect(0, (h + extra_top_padding - long) // 2, overlay_green_thickness, long)
|
||||
p.drawRect(w - overlay_green_thickness, (h + extra_top_padding - long) // 2, overlay_green_thickness, long)
|
||||
|
||||
bar_width = overlay_green_thickness * 6
|
||||
bar_height = overlay_green_thickness
|
||||
bar_x = (w - bar_width) // 2
|
||||
bar_y = 6
|
||||
p.setBrush(QtGui.QColor(0, 191, 255))
|
||||
p.drawRect(bar_x, bar_y - bar_height - 10, bar_width, bar_height * 4)
|
||||
|
||||
def get_geometry(self):
|
||||
g = self.geometry()
|
||||
return (
|
||||
g.x() + handle_size,
|
||||
g.y() + handle_size + extra_top_padding,
|
||||
g.width() - handle_size * 2,
|
||||
g.height() - handle_size * 2 - extra_top_padding,
|
||||
)
|
||||
|
||||
def mousePressEvent(self, e):
|
||||
if e.button() == QtCore.Qt.LeftButton:
|
||||
pos = e.pos()
|
||||
bar_width = overlay_green_thickness * 6
|
||||
bar_height = overlay_green_thickness
|
||||
bar_x = (self.width() - bar_width) // 2
|
||||
bar_y = 2
|
||||
bar_rect = QtCore.QRect(bar_x, bar_y, bar_width, bar_height)
|
||||
|
||||
if bar_rect.contains(pos):
|
||||
self.drag_offset = e.globalPos() - self.frameGeometry().topLeft()
|
||||
return
|
||||
|
||||
m = handle_size
|
||||
dirs = []
|
||||
if pos.x() <= m:
|
||||
dirs.append('left')
|
||||
if pos.x() >= self.width() - m:
|
||||
dirs.append('right')
|
||||
if pos.y() <= m + extra_top_padding:
|
||||
dirs.append('top')
|
||||
if pos.y() >= self.height() - m:
|
||||
dirs.append('bottom')
|
||||
if dirs:
|
||||
self.resize_dir = '_'.join(dirs)
|
||||
self._start_geom = self.geometry()
|
||||
self._start_pos = e.globalPos()
|
||||
else:
|
||||
self.drag_offset = e.globalPos() - self.frameGeometry().topLeft()
|
||||
|
||||
def mouseMoveEvent(self, e):
|
||||
if self.resize_dir and self._start_geom and self._start_pos:
|
||||
dx = e.globalX() - self._start_pos.x()
|
||||
dy = e.globalY() - self._start_pos.y()
|
||||
geom = QtCore.QRect(self._start_geom)
|
||||
if 'left' in self.resize_dir:
|
||||
new_x = geom.x() + dx
|
||||
new_w = geom.width() - dx
|
||||
geom.setX(new_x)
|
||||
geom.setWidth(new_w)
|
||||
if 'right' in self.resize_dir:
|
||||
geom.setWidth(self._start_geom.width() + dx)
|
||||
if 'top' in self.resize_dir:
|
||||
new_y = geom.y() + dy
|
||||
new_h = geom.height() - dy
|
||||
geom.setY(new_y)
|
||||
geom.setHeight(new_h)
|
||||
if 'bottom' in self.resize_dir:
|
||||
geom.setHeight(self._start_geom.height() + dy)
|
||||
self.setGeometry(geom)
|
||||
elif self.drag_offset and e.buttons() & QtCore.Qt.LeftButton:
|
||||
self.move(e.globalPos() - self.drag_offset)
|
||||
|
||||
def mouseReleaseEvent(self, e):
|
||||
self.drag_offset = None
|
||||
self.resize_dir = None
|
||||
self._start_geom = None
|
||||
self._start_pos = None
|
||||
x, y, w, h = self.get_geometry()
|
||||
self.ctx.config.data['regions'][self.node_id] = {'x': x, 'y': y, 'w': w, 'h': h}
|
||||
try:
|
||||
self.ctx.config._write()
|
||||
except Exception:
|
||||
pass
|
||||
asyncio.create_task(self.ctx.sio.emit('agent_screenshot_task', {
|
||||
'agent_id': self.ctx.agent_id,
|
||||
'node_id': self.node_id,
|
||||
'image_base64': '',
|
||||
'x': x, 'y': y, 'w': w, 'h': h
|
||||
}))
|
||||
|
||||
|
||||
class Role:
|
||||
def __init__(self, ctx):
|
||||
self.ctx = ctx
|
||||
self.tasks = {}
|
||||
|
||||
def register_events(self):
|
||||
sio = self.ctx.sio
|
||||
|
||||
@sio.on('list_agent_windows')
|
||||
async def _handle_list_windows(payload):
|
||||
try:
|
||||
windows = macro_engines.list_windows()
|
||||
except Exception:
|
||||
windows = []
|
||||
await sio.emit('agent_window_list', {
|
||||
'agent_id': self.ctx.agent_id,
|
||||
'windows': windows,
|
||||
})
|
||||
|
||||
def _close_overlay(self, node_id: str):
|
||||
w = overlay_widgets.pop(node_id, None)
|
||||
if w:
|
||||
try:
|
||||
w.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def stop_all(self):
|
||||
for t in list(self.tasks.values()):
|
||||
try:
|
||||
t.cancel()
|
||||
except Exception:
|
||||
pass
|
||||
self.tasks.clear()
|
||||
# Close all widgets
|
||||
for nid in list(overlay_widgets.keys()):
|
||||
self._close_overlay(nid)
|
||||
|
||||
def on_config(self, roles_cfg):
|
||||
# Filter only screenshot roles
|
||||
screenshot_roles = [r for r in roles_cfg if (r.get('role') == 'screenshot')]
|
||||
|
||||
# Optional: forward interval to SYSTEM helper via hook
|
||||
try:
|
||||
if screenshot_roles and 'send_service_control' in self.ctx.hooks:
|
||||
interval_ms = int(screenshot_roles[0].get('interval', 1000))
|
||||
try:
|
||||
self.ctx.hooks['send_service_control']({'type': 'screenshot_config', 'interval_ms': interval_ms})
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Cancel tasks that are no longer present
|
||||
new_ids = {r.get('node_id') for r in screenshot_roles if r.get('node_id')}
|
||||
old_ids = set(self.tasks.keys())
|
||||
removed = old_ids - new_ids
|
||||
for rid in removed:
|
||||
t = self.tasks.pop(rid, None)
|
||||
if t:
|
||||
try:
|
||||
t.cancel()
|
||||
except Exception:
|
||||
pass
|
||||
# Remove stored region and overlay
|
||||
self.ctx.config.data.get('regions', {}).pop(rid, None)
|
||||
try:
|
||||
self._close_overlay(rid)
|
||||
except Exception:
|
||||
pass
|
||||
if removed:
|
||||
try:
|
||||
self.ctx.config._write()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Start tasks for all screenshot roles in config
|
||||
for rcfg in screenshot_roles:
|
||||
nid = rcfg.get('node_id')
|
||||
if not nid:
|
||||
continue
|
||||
if nid in self.tasks:
|
||||
continue
|
||||
task = asyncio.create_task(self._screenshot_task(rcfg))
|
||||
self.tasks[nid] = task
|
||||
|
||||
async def _screenshot_task(self, cfg):
|
||||
nid = cfg.get('node_id')
|
||||
alias = cfg.get('alias', '')
|
||||
reg = self.ctx.config.data.setdefault('regions', {})
|
||||
r = reg.get(nid)
|
||||
if r:
|
||||
region = (r['x'], r['y'], r['w'], r['h'])
|
||||
else:
|
||||
region = (
|
||||
cfg.get('x', 100),
|
||||
cfg.get('y', 100),
|
||||
cfg.get('w', 300),
|
||||
cfg.get('h', 200),
|
||||
)
|
||||
reg[nid] = {'x': region[0], 'y': region[1], 'w': region[2], 'h': region[3]}
|
||||
try:
|
||||
self.ctx.config._write()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if nid not in overlay_widgets:
|
||||
widget = ScreenshotRegion(self.ctx, nid, *region, alias=alias)
|
||||
overlay_widgets[nid] = widget
|
||||
widget.show()
|
||||
|
||||
await self.ctx.sio.emit('agent_screenshot_task', {
|
||||
'agent_id': self.ctx.agent_id,
|
||||
'node_id': nid,
|
||||
'image_base64': '',
|
||||
'x': region[0], 'y': region[1], 'w': region[2], 'h': region[3]
|
||||
})
|
||||
|
||||
interval = cfg.get('interval', 1000) / 1000.0
|
||||
loop = asyncio.get_event_loop()
|
||||
executor = concurrent.futures.ThreadPoolExecutor(max_workers=self.ctx.config.data.get('max_task_workers', 8))
|
||||
try:
|
||||
while True:
|
||||
x, y, w, h = overlay_widgets[nid].get_geometry()
|
||||
grab = partial(ImageGrab.grab, bbox=(x, y, x + w, y + h))
|
||||
img = await loop.run_in_executor(executor, grab)
|
||||
buf = BytesIO(); img.save(buf, format='PNG')
|
||||
encoded = base64.b64encode(buf.getvalue()).decode('utf-8')
|
||||
await self.ctx.sio.emit('agent_screenshot_task', {
|
||||
'agent_id': self.ctx.agent_id,
|
||||
'node_id': nid,
|
||||
'image_base64': encoded,
|
||||
'x': x, 'y': y, 'w': w, 'h': h
|
||||
})
|
||||
await asyncio.sleep(interval)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
|
||||
217
Data/Agent/Roles/role_ScriptExec_CURRENTUSER.py
Normal file
217
Data/Agent/Roles/role_ScriptExec_CURRENTUSER.py
Normal file
@@ -0,0 +1,217 @@
|
||||
import os
|
||||
import sys
|
||||
import asyncio
|
||||
import tempfile
|
||||
import uuid
|
||||
from PyQt5 import QtWidgets, QtGui
|
||||
|
||||
|
||||
ROLE_NAME = 'script_exec_currentuser'
|
||||
ROLE_CONTEXTS = ['interactive']
|
||||
|
||||
|
||||
IS_WINDOWS = os.name == 'nt'
|
||||
|
||||
|
||||
def _write_temp_script(content: str, suffix: str):
|
||||
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):
|
||||
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)
|
||||
)
|
||||
out_b, err_b = await proc.communicate()
|
||||
return proc.returncode, (out_b or b"").decode(errors='replace'), (err_b or b"").decode(errors='replace')
|
||||
except Exception as e:
|
||||
return -1, "", str(e)
|
||||
|
||||
|
||||
async def _run_powershell_via_user_task(content: str):
|
||||
if not IS_WINDOWS:
|
||||
return -999, '', 'Windows only'
|
||||
ps = os.path.expandvars(r"%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe")
|
||||
if not os.path.isfile(ps):
|
||||
ps = 'powershell.exe'
|
||||
path = None
|
||||
out_path = None
|
||||
import tempfile as _tf
|
||||
try:
|
||||
temp_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', 'Temp'))
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
fd, path = _tf.mkstemp(prefix='usr_task_', suffix='.ps1', dir=temp_dir, text=True)
|
||||
with os.fdopen(fd, 'w', encoding='utf-8', newline='\n') as f:
|
||||
f.write(content or '')
|
||||
out_path = os.path.join(temp_dir, f'out_{uuid.uuid4().hex}.txt')
|
||||
name = f"Borealis Agent - Task - {uuid.uuid4().hex} @ CurrentUser"
|
||||
task_ps = f"""
|
||||
$ErrorActionPreference='Continue'
|
||||
$task = "{name}"
|
||||
$ps = "{ps}"
|
||||
$scr = "{path}"
|
||||
$out = "{out_path}"
|
||||
try {{ Unregister-ScheduledTask -TaskName $task -Confirm:$false -ErrorAction SilentlyContinue }} catch {{}}
|
||||
$action = New-ScheduledTaskAction -Execute $ps -Argument ('-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File "' + $scr + '" *> "' + $out + '"')
|
||||
$settings = New-ScheduledTaskSettingsSet -DeleteExpiredTaskAfter (New-TimeSpan -Minutes 5) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
|
||||
$principal= New-ScheduledTaskPrincipal -UserId ([System.Security.Principal.WindowsIdentity]::GetCurrent().Name) -LogonType Interactive -RunLevel Limited
|
||||
Register-ScheduledTask -TaskName $task -Action $action -Settings $settings -Principal $principal -Force | Out-Null
|
||||
Start-ScheduledTask -TaskName $task | Out-Null
|
||||
Start-Sleep -Seconds 2
|
||||
Get-ScheduledTask -TaskName $task | Out-Null
|
||||
"""
|
||||
proc = await asyncio.create_subprocess_exec(ps, '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', task_ps,
|
||||
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
|
||||
out_b, err_b = await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
return -999, '', (err_b or out_b or b'').decode(errors='replace')
|
||||
# Wait a short time for output file; best-effort
|
||||
import time as _t
|
||||
deadline = _t.time() + 30
|
||||
out_data = ''
|
||||
while _t.time() < deadline:
|
||||
try:
|
||||
if os.path.isfile(out_path) and os.path.getsize(out_path) > 0:
|
||||
with open(out_path, 'r', encoding='utf-8', errors='replace') as f:
|
||||
out_data = f.read()
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
await asyncio.sleep(1)
|
||||
# Cleanup best-effort
|
||||
try:
|
||||
await asyncio.create_subprocess_exec('powershell.exe', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', f"try {{ Unregister-ScheduledTask -TaskName '{name}' -Confirm:$false }} catch {{}}")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if path and os.path.isfile(path):
|
||||
os.remove(path)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if out_path and os.path.isfile(out_path):
|
||||
os.remove(out_path)
|
||||
except Exception:
|
||||
pass
|
||||
return 0, out_data or '', ''
|
||||
except Exception as e:
|
||||
return -999, '', str(e)
|
||||
|
||||
|
||||
class Role:
|
||||
def __init__(self, ctx):
|
||||
self.ctx = ctx
|
||||
# Setup tray icon in interactive session
|
||||
try:
|
||||
self._setup_tray()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def register_events(self):
|
||||
sio = self.ctx.sio
|
||||
|
||||
@sio.on('quick_job_run')
|
||||
async def _on_quick_job_run(payload):
|
||||
try:
|
||||
import socket
|
||||
hostname = socket.gethostname()
|
||||
target = (payload.get('target_hostname') or '').strip().lower()
|
||||
if not target or target != hostname.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 run_mode == 'system':
|
||||
return
|
||||
if script_type != 'powershell':
|
||||
await sio.emit('quick_job_result', { 'job_id': job_id, 'status': 'Failed', 'stdout': '', 'stderr': f"Unsupported type: {script_type}" })
|
||||
return
|
||||
if run_mode == 'admin':
|
||||
rc, out, err = -1, '', 'Admin credentialed runs are disabled; use SYSTEM or Current User.'
|
||||
else:
|
||||
rc, out, err = await _run_powershell_via_user_task(content)
|
||||
if rc == -999:
|
||||
path = _write_temp_script(content, '.ps1')
|
||||
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
|
||||
|
||||
def _setup_tray(self):
|
||||
app = QtWidgets.QApplication.instance()
|
||||
if app is None:
|
||||
return
|
||||
icon = None
|
||||
try:
|
||||
icon_path = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir, 'Borealis.ico'))
|
||||
if os.path.isfile(icon_path):
|
||||
icon = QtGui.QIcon(icon_path)
|
||||
except Exception:
|
||||
pass
|
||||
if icon is None:
|
||||
icon = app.style().standardIcon(QtWidgets.QStyle.SP_ComputerIcon)
|
||||
self.tray = QtWidgets.QSystemTrayIcon(icon)
|
||||
self.tray.setToolTip('Borealis Agent')
|
||||
menu = QtWidgets.QMenu()
|
||||
act_restart = menu.addAction('Restart Agent')
|
||||
act_quit = menu.addAction('Quit Agent')
|
||||
act_restart.triggered.connect(self._restart_agent)
|
||||
act_quit.triggered.connect(self._quit_agent)
|
||||
self.tray.setContextMenu(menu)
|
||||
self.tray.show()
|
||||
|
||||
def _restart_agent(self):
|
||||
try:
|
||||
# __file__ => Agent/Borealis/Roles/...
|
||||
borealis_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir))
|
||||
venv_root = os.path.abspath(os.path.join(borealis_dir, os.pardir))
|
||||
venv_scripts = os.path.join(venv_root, 'Scripts')
|
||||
pyw = os.path.join(venv_scripts, 'pythonw.exe')
|
||||
exe = pyw if os.path.isfile(pyw) else sys.executable
|
||||
agent_script = os.path.join(borealis_dir, 'agent.py')
|
||||
import subprocess
|
||||
subprocess.Popen([exe, '-W', 'ignore::SyntaxWarning', agent_script], cwd=borealis_dir)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
QtWidgets.QApplication.instance().quit()
|
||||
except Exception:
|
||||
os._exit(0)
|
||||
|
||||
def _quit_agent(self):
|
||||
try:
|
||||
QtWidgets.QApplication.instance().quit()
|
||||
except Exception:
|
||||
os._exit(0)
|
||||
153
Data/Agent/Roles/role_ScriptExec_SYSTEM.py
Normal file
153
Data/Agent/Roles/role_ScriptExec_SYSTEM.py
Normal file
@@ -0,0 +1,153 @@
|
||||
import os
|
||||
import asyncio
|
||||
import tempfile
|
||||
import uuid
|
||||
import time
|
||||
import subprocess
|
||||
|
||||
|
||||
ROLE_NAME = 'script_exec_system'
|
||||
ROLE_CONTEXTS = ['system']
|
||||
|
||||
|
||||
def _project_root():
|
||||
return os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
|
||||
|
||||
|
||||
def _run_powershell_script_content(content: str):
|
||||
temp_dir = os.path.join(_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:
|
||||
flags = 0x08000000 if os.name == 'nt' else 0
|
||||
proc = subprocess.run(
|
||||
[ps, "-ExecutionPolicy", "Bypass", "-NoProfile", "-File", path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60*60,
|
||||
creationflags=flags,
|
||||
)
|
||||
return proc.returncode, proc.stdout or "", proc.stderr or ""
|
||||
except Exception as e:
|
||||
return -1, "", str(e)
|
||||
finally:
|
||||
try:
|
||||
if os.path.isfile(path):
|
||||
os.remove(path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _run_powershell_via_system_task(content: str):
|
||||
ps_exe = os.path.expandvars(r"%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe")
|
||||
if not os.path.isfile(ps_exe):
|
||||
ps_exe = 'powershell.exe'
|
||||
try:
|
||||
os.makedirs(os.path.join(_project_root(), 'Temp'), exist_ok=True)
|
||||
script_fd, script_path = tempfile.mkstemp(prefix='sys_task_', suffix='.ps1', dir=os.path.join(_project_root(), 'Temp'), text=True)
|
||||
with os.fdopen(script_fd, 'w', encoding='utf-8', newline='\n') as f:
|
||||
f.write(content or '')
|
||||
out_path = os.path.join(_project_root(), 'Temp', f'out_{uuid.uuid4().hex}.txt')
|
||||
task_name = f"Borealis Agent - Task - {uuid.uuid4().hex} @ SYSTEM"
|
||||
task_ps = f"""
|
||||
$ErrorActionPreference='Continue'
|
||||
$task = "{task_name}"
|
||||
$ps = "{ps_exe}"
|
||||
$scr = "{script_path}"
|
||||
$out = "{out_path}"
|
||||
try {{ Unregister-ScheduledTask -TaskName $task -Confirm:$false -ErrorAction SilentlyContinue }} catch {{}}
|
||||
$action = New-ScheduledTaskAction -Execute $ps -Argument ('-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File "' + $scr + '" *> "' + $out + '"')
|
||||
$settings = New-ScheduledTaskSettingsSet -DeleteExpiredTaskAfter (New-TimeSpan -Minutes 5) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
|
||||
$principal= New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest
|
||||
Register-ScheduledTask -TaskName $task -Action $action -Settings $settings -Principal $principal -Force | Out-Null
|
||||
Start-ScheduledTask -TaskName $task | Out-Null
|
||||
Start-Sleep -Seconds 2
|
||||
Get-ScheduledTask -TaskName $task | Out-Null
|
||||
"""
|
||||
proc = subprocess.run([ps_exe, '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', task_ps], capture_output=True, text=True)
|
||||
if proc.returncode != 0:
|
||||
return -999, '', (proc.stderr or proc.stdout or 'scheduled task creation failed')
|
||||
deadline = time.time() + 60
|
||||
out_data = ''
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
if os.path.isfile(out_path) and os.path.getsize(out_path) > 0:
|
||||
with open(out_path, 'r', encoding='utf-8', errors='replace') as f:
|
||||
out_data = f.read()
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(1)
|
||||
cleanup_ps = f"try {{ Unregister-ScheduledTask -TaskName '{task_name}' -Confirm:$false }} catch {{}}"
|
||||
subprocess.run([ps_exe, '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', cleanup_ps], capture_output=True, text=True)
|
||||
try:
|
||||
if os.path.isfile(script_path):
|
||||
os.remove(script_path)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if os.path.isfile(out_path):
|
||||
os.remove(out_path)
|
||||
except Exception:
|
||||
pass
|
||||
return 0, out_data or '', ''
|
||||
except Exception as e:
|
||||
return -999, '', str(e)
|
||||
|
||||
|
||||
class Role:
|
||||
def __init__(self, ctx):
|
||||
self.ctx = ctx
|
||||
|
||||
def register_events(self):
|
||||
sio = self.ctx.sio
|
||||
|
||||
@sio.on('quick_job_run')
|
||||
async def _on_quick_job_run(payload):
|
||||
try:
|
||||
import socket
|
||||
hostname = socket.gethostname()
|
||||
target = (payload.get('target_hostname') or '').strip().lower()
|
||||
if target and target != hostname.lower():
|
||||
return
|
||||
run_mode = (payload.get('run_mode') or 'current_user').lower()
|
||||
if run_mode != 'system':
|
||||
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_via_system_task(content)
|
||||
if rc == -999:
|
||||
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
|
||||
|
||||
@@ -11,8 +11,8 @@ PyQt5
|
||||
qasync
|
||||
|
||||
# Computer Vision & OCR Dependencies
|
||||
opencv-python # Computer vision processing
|
||||
Pillow # Image processing (Windows)
|
||||
opencv-python # Computer vision processing
|
||||
Pillow # Image processing (Windows)
|
||||
###mss # Fast cross-platform screen capture
|
||||
|
||||
# WebRTC Video Libraries
|
||||
|
||||
@@ -42,8 +42,7 @@ except Exception:
|
||||
from PIL import ImageGrab
|
||||
|
||||
# New modularized components
|
||||
import agent_info
|
||||
import agent_roles
|
||||
from role_manager import RoleManager
|
||||
|
||||
# //////////////////////////////////////////////////////////////////////////
|
||||
# CORE SECTION: CONFIG MANAGER
|
||||
@@ -351,7 +350,7 @@ def detect_agent_os():
|
||||
print(f"[WARN] OS detection failed: {e}")
|
||||
return "Unknown"
|
||||
|
||||
CONFIG.data['agent_operating_system'] = agent_info.detect_agent_os()
|
||||
CONFIG.data['agent_operating_system'] = detect_agent_os()
|
||||
CONFIG._write()
|
||||
|
||||
# //////////////////////////////////////////////////////////////////////////
|
||||
@@ -361,8 +360,8 @@ CONFIG._write()
|
||||
sio = socketio.AsyncClient(reconnection=True, reconnection_attempts=0, reconnection_delay=5)
|
||||
role_tasks = {}
|
||||
background_tasks = []
|
||||
roles_ctx = None
|
||||
AGENT_LOOP = None
|
||||
ROLE_MANAGER = None
|
||||
|
||||
# ---------------- Local IPC Bridge (Service -> Agent) ----------------
|
||||
def start_agent_bridge_pipe(loop_ref):
|
||||
@@ -563,13 +562,9 @@ async def _run_powershell_with_credentials(path: str, username: str, password: s
|
||||
|
||||
async def stop_all_roles():
|
||||
print("[DEBUG] Stopping all roles.")
|
||||
for task in list(role_tasks.values()):
|
||||
print(f"[DEBUG] Cancelling task for node: {task}")
|
||||
task.cancel()
|
||||
role_tasks.clear()
|
||||
# Close overlays managed in agent_roles module
|
||||
try:
|
||||
agent_roles.close_all_overlays()
|
||||
if ROLE_MANAGER is not None:
|
||||
ROLE_MANAGER.stop_all()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -587,7 +582,7 @@ async def send_heartbeat():
|
||||
payload = {
|
||||
"agent_id": AGENT_ID,
|
||||
"hostname": socket.gethostname(),
|
||||
"agent_operating_system": CONFIG.data.get("agent_operating_system", agent_info.detect_agent_os()),
|
||||
"agent_operating_system": CONFIG.data.get("agent_operating_system", detect_agent_os()),
|
||||
"last_seen": int(time.time())
|
||||
}
|
||||
await sio.emit("agent_heartbeat", payload)
|
||||
@@ -608,16 +603,15 @@ async def send_heartbeat():
|
||||
## Moved to agent_info module
|
||||
|
||||
def collect_summary():
|
||||
# Moved to agent_info.collect_summary
|
||||
return agent_info.collect_summary(CONFIG)
|
||||
# migrated to role_DeviceInventory
|
||||
return {}
|
||||
|
||||
def collect_software():
|
||||
# Moved to agent_info.collect_software
|
||||
return agent_info.collect_software()
|
||||
# migrated to role_DeviceInventory
|
||||
return []
|
||||
|
||||
def collect_memory():
|
||||
# Delegated to agent_info module
|
||||
return agent_info.collect_memory()
|
||||
# migrated to role_DeviceInventory
|
||||
entries = []
|
||||
plat = platform.system().lower()
|
||||
try:
|
||||
@@ -706,8 +700,7 @@ def collect_memory():
|
||||
return entries
|
||||
|
||||
def collect_storage():
|
||||
# Delegated to agent_info module
|
||||
return agent_info.collect_storage()
|
||||
# migrated to role_DeviceInventory
|
||||
disks = []
|
||||
plat = platform.system().lower()
|
||||
try:
|
||||
@@ -824,8 +817,7 @@ def collect_storage():
|
||||
return disks
|
||||
|
||||
def collect_network():
|
||||
# Delegated to agent_info module
|
||||
return agent_info.collect_network()
|
||||
# migrated to role_DeviceInventory
|
||||
adapters = []
|
||||
plat = platform.system().lower()
|
||||
try:
|
||||
@@ -906,7 +898,7 @@ async def connect():
|
||||
await sio.emit("agent_heartbeat", {
|
||||
"agent_id": AGENT_ID,
|
||||
"hostname": socket.gethostname(),
|
||||
"agent_operating_system": CONFIG.data.get("agent_operating_system", agent_info.detect_agent_os()),
|
||||
"agent_operating_system": CONFIG.data.get("agent_operating_system", detect_agent_os()),
|
||||
"last_seen": int(time.time())
|
||||
})
|
||||
except Exception as e:
|
||||
@@ -947,98 +939,23 @@ async def on_agent_config(cfg):
|
||||
|
||||
print(f"[CONFIG] Received New Agent Config with {len(roles)} Role(s).")
|
||||
|
||||
new_ids = {r.get('node_id') for r in roles if r.get('node_id')}
|
||||
old_ids = set(role_tasks.keys())
|
||||
removed = old_ids - new_ids
|
||||
|
||||
for rid in removed:
|
||||
print(f"[DEBUG] Removing node {rid} from regions/overlays.")
|
||||
CONFIG.data['regions'].pop(rid, None)
|
||||
try:
|
||||
agent_roles.close_overlay(rid)
|
||||
except Exception:
|
||||
pass
|
||||
if removed:
|
||||
CONFIG._write()
|
||||
|
||||
for task in list(role_tasks.values()):
|
||||
task.cancel()
|
||||
role_tasks.clear()
|
||||
|
||||
# Forward screenshot config to service helper (interval only)
|
||||
try:
|
||||
for role_cfg in roles:
|
||||
if role_cfg.get('role') == 'screenshot':
|
||||
interval_ms = int(role_cfg.get('interval', 1000))
|
||||
send_service_control({ 'type': 'screenshot_config', 'interval_ms': interval_ms })
|
||||
send_service_control({'type': 'screenshot_config', 'interval_ms': interval_ms})
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for role_cfg in roles:
|
||||
nid = role_cfg.get('node_id')
|
||||
role = role_cfg.get('role')
|
||||
if role == 'screenshot':
|
||||
print(f"[DEBUG] Starting screenshot task for {nid}")
|
||||
task = asyncio.create_task(agent_roles.screenshot_task(roles_ctx, role_cfg))
|
||||
role_tasks[nid] = task
|
||||
elif role == 'macro':
|
||||
print(f"[DEBUG] Starting macro task for {nid}")
|
||||
task = asyncio.create_task(agent_roles.macro_task(roles_ctx, role_cfg))
|
||||
role_tasks[nid] = task
|
||||
|
||||
@sio.on('quick_job_run')
|
||||
async def on_quick_job_run(payload):
|
||||
try:
|
||||
target = (payload.get('target_hostname') or '').strip().lower()
|
||||
if not target or target != socket.gethostname().lower():
|
||||
return
|
||||
job_id = payload.get('job_id')
|
||||
script_type = (payload.get('script_type') or '').lower()
|
||||
run_mode = (payload.get('run_mode') or 'current_user').lower()
|
||||
content = payload.get('script_content') or ''
|
||||
# Only handle non-SYSTEM runs here; SYSTEM runs are handled by the LocalSystem service agent
|
||||
if run_mode == 'system':
|
||||
# Ignore: handled by SYSTEM supervisor/agent
|
||||
return
|
||||
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 = 0; out = ''; err = ''
|
||||
if run_mode == 'admin':
|
||||
# Admin credentialed runs are disabled in current design
|
||||
rc, out, err = -1, '', 'Admin credentialed runs are disabled; use SYSTEM or Current User.'
|
||||
else:
|
||||
# Prefer ephemeral scheduled task in current user context
|
||||
rc, out, err = await _run_powershell_via_user_task(content)
|
||||
if rc == -999:
|
||||
# Fallback to direct execution
|
||||
path = _write_temp_script(content, '.ps1')
|
||||
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,
|
||||
})
|
||||
if ROLE_MANAGER is not None:
|
||||
ROLE_MANAGER.on_config(roles)
|
||||
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
|
||||
print(f"[WARN] role manager apply config failed: {e}")
|
||||
|
||||
@sio.on('list_agent_windows')
|
||||
async def handle_list_agent_windows(data):
|
||||
windows = agent_roles.get_window_list()
|
||||
await sio.emit('agent_window_list', {
|
||||
'agent_id': AGENT_ID,
|
||||
'windows': windows
|
||||
})
|
||||
## Script execution and list windows handlers are registered by roles
|
||||
|
||||
# ---------------- Config Watcher ----------------
|
||||
async def config_watcher():
|
||||
@@ -1159,17 +1076,31 @@ if __name__=='__main__':
|
||||
pass
|
||||
dummy_window=PersistentWindow(); dummy_window.show()
|
||||
# Initialize roles context for role tasks
|
||||
roles_ctx = agent_roles.RolesContext(sio=sio, agent_id=AGENT_ID, config=CONFIG)
|
||||
# Initialize role manager and hot-load roles from Roles/
|
||||
try:
|
||||
hooks = {'send_service_control': send_service_control}
|
||||
ROLE_MANAGER = RoleManager(
|
||||
base_dir=os.path.dirname(__file__),
|
||||
context='interactive',
|
||||
sio=sio,
|
||||
agent_id=AGENT_ID,
|
||||
config=CONFIG,
|
||||
loop=loop,
|
||||
hooks=hooks,
|
||||
)
|
||||
ROLE_MANAGER.load()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
background_tasks.append(loop.create_task(config_watcher()))
|
||||
background_tasks.append(loop.create_task(connect_loop()))
|
||||
background_tasks.append(loop.create_task(idle_task()))
|
||||
# Start periodic heartbeats
|
||||
background_tasks.append(loop.create_task(send_heartbeat()))
|
||||
background_tasks.append(loop.create_task(agent_info.send_agent_details(AGENT_ID, CONFIG)))
|
||||
loop.run_forever()
|
||||
except Exception as e:
|
||||
print(f"[FATAL] Event loop crashed: {e}")
|
||||
traceback.print_exc()
|
||||
finally:
|
||||
print("[FATAL] Agent exited unexpectedly.")
|
||||
|
||||
@@ -24,7 +24,7 @@ def project_paths():
|
||||
"borealis_dir": borealis_dir,
|
||||
"logs_dir": logs_dir,
|
||||
"temp_dir": temp_dir,
|
||||
"agent_script": os.path.join(borealis_dir, "tray_launcher.py"),
|
||||
"agent_script": os.path.join(borealis_dir, "agent.py"),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,845 +0,0 @@
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import time
|
||||
import socket
|
||||
import platform
|
||||
import subprocess
|
||||
import getpass
|
||||
import datetime
|
||||
import shutil
|
||||
import string
|
||||
|
||||
import requests
|
||||
try:
|
||||
import psutil # type: ignore
|
||||
except Exception:
|
||||
psutil = None # graceful degradation if unavailable
|
||||
import aiohttp
|
||||
import asyncio
|
||||
|
||||
# ---------------- Helpers for hidden subprocess on Windows ----------------
|
||||
IS_WINDOWS = os.name == 'nt'
|
||||
CREATE_NO_WINDOW = 0x08000000 if IS_WINDOWS else 0
|
||||
|
||||
|
||||
def _run_hidden(cmd_list, timeout=None):
|
||||
"""Run a subprocess hidden on Windows (no visible console window)."""
|
||||
kwargs = {"capture_output": True, "text": True}
|
||||
if timeout is not None:
|
||||
kwargs["timeout"] = timeout
|
||||
if IS_WINDOWS:
|
||||
kwargs["creationflags"] = CREATE_NO_WINDOW
|
||||
return subprocess.run(cmd_list, **kwargs)
|
||||
|
||||
|
||||
def _run_powershell_hidden(ps_cmd: str, timeout: int = 60):
|
||||
"""Run a powershell -NoProfile -Command string fully hidden on Windows."""
|
||||
ps = os.path.expandvars(r"%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe")
|
||||
if not os.path.isfile(ps):
|
||||
ps = "powershell.exe"
|
||||
return _run_hidden([ps, "-NoProfile", "-Command", ps_cmd], timeout=timeout)
|
||||
|
||||
|
||||
def detect_agent_os():
|
||||
"""
|
||||
Detects the full, user-friendly operating system name and version.
|
||||
Examples:
|
||||
- "Windows 11"
|
||||
- "Windows 10"
|
||||
- "Fedora Workstation 42"
|
||||
- "Ubuntu 22.04 LTS"
|
||||
- "macOS Sonoma"
|
||||
Falls back to a generic name if detection fails.
|
||||
"""
|
||||
try:
|
||||
plat = platform.system().lower()
|
||||
|
||||
if plat.startswith('win'):
|
||||
try:
|
||||
import winreg # Only available on Windows
|
||||
|
||||
reg_path = r"SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion"
|
||||
access = winreg.KEY_READ
|
||||
try:
|
||||
access |= winreg.KEY_WOW64_64KEY
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
key = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, reg_path, 0, access)
|
||||
except OSError:
|
||||
key = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, reg_path, 0, winreg.KEY_READ)
|
||||
|
||||
def _get(name, default=None):
|
||||
try:
|
||||
return winreg.QueryValueEx(key, name)[0]
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
product_name = _get("ProductName", "") # e.g., "Windows 11 Pro"
|
||||
edition_id = _get("EditionID", "") # e.g., "Professional"
|
||||
display_version = _get("DisplayVersion", "") # e.g., "24H2" / "22H2"
|
||||
release_id = _get("ReleaseId", "") # e.g., "2004" on older Windows 10
|
||||
build_number = _get("CurrentBuildNumber", "") or _get("CurrentBuild", "")
|
||||
ubr = _get("UBR", None) # Update Build Revision (int)
|
||||
|
||||
try:
|
||||
build_int = int(str(build_number).split(".")[0]) if build_number else 0
|
||||
except Exception:
|
||||
build_int = 0
|
||||
if build_int >= 22000:
|
||||
major_label = "11"
|
||||
elif build_int >= 10240:
|
||||
major_label = "10"
|
||||
else:
|
||||
major_label = platform.release()
|
||||
|
||||
edition = ""
|
||||
pn = product_name or ""
|
||||
if pn.lower().startswith("windows "):
|
||||
tokens = pn.split()
|
||||
if len(tokens) >= 3:
|
||||
edition = " ".join(tokens[2:])
|
||||
if not edition and edition_id:
|
||||
eid_map = {
|
||||
"Professional": "Pro",
|
||||
"ProfessionalN": "Pro N",
|
||||
"ProfessionalEducation": "Pro Education",
|
||||
"ProfessionalWorkstation": "Pro for Workstations",
|
||||
"Enterprise": "Enterprise",
|
||||
"EnterpriseN": "Enterprise N",
|
||||
"EnterpriseS": "Enterprise LTSC",
|
||||
"Education": "Education",
|
||||
"EducationN": "Education N",
|
||||
"Core": "Home",
|
||||
"CoreN": "Home N",
|
||||
"CoreSingleLanguage": "Home Single Language",
|
||||
"IoTEnterprise": "IoT Enterprise",
|
||||
}
|
||||
edition = eid_map.get(edition_id, edition_id)
|
||||
|
||||
os_name = f"Windows {major_label}"
|
||||
version_label = display_version or release_id or ""
|
||||
|
||||
if isinstance(ubr, int):
|
||||
build_str = f"{build_number}.{ubr}" if build_number else str(ubr)
|
||||
else:
|
||||
try:
|
||||
build_str = f"{build_number}.{int(ubr)}" if build_number and ubr is not None else build_number
|
||||
except Exception:
|
||||
build_str = build_number
|
||||
|
||||
parts = ["Microsoft", os_name]
|
||||
if edition:
|
||||
parts.append(edition)
|
||||
if version_label:
|
||||
parts.append(version_label)
|
||||
if build_str:
|
||||
parts.append(f"Build {build_str}")
|
||||
|
||||
return " ".join(p for p in parts if p).strip()
|
||||
|
||||
except Exception:
|
||||
return f"Windows {platform.release()}"
|
||||
|
||||
elif plat.startswith('linux'):
|
||||
try:
|
||||
import distro # External package, better for Linux OS detection
|
||||
name = distro.name(pretty=True) # e.g., "Fedora Workstation 42"
|
||||
if name:
|
||||
return name
|
||||
else:
|
||||
return f"{platform.system()} {platform.release()}"
|
||||
except ImportError:
|
||||
return f"{platform.system()} {platform.release()}"
|
||||
|
||||
elif plat.startswith('darwin'):
|
||||
version = platform.mac_ver()[0]
|
||||
macos_names = {
|
||||
"14": "Sonoma",
|
||||
"13": "Ventura",
|
||||
"12": "Monterey",
|
||||
"11": "Big Sur",
|
||||
"10.15": "Catalina"
|
||||
}
|
||||
pretty_name = macos_names.get(".".join(version.split(".")[:2]), "")
|
||||
return f"macOS {pretty_name or version}"
|
||||
|
||||
else:
|
||||
return f"Unknown OS ({platform.system()} {platform.release()})"
|
||||
|
||||
except Exception as e:
|
||||
print(f"[WARN] OS detection failed: {e}")
|
||||
return "Unknown"
|
||||
|
||||
|
||||
def _detect_virtual_machine() -> bool:
|
||||
"""Best-effort detection of whether this system is a virtual machine.
|
||||
|
||||
Uses platform-specific signals but avoids heavy dependencies.
|
||||
"""
|
||||
try:
|
||||
plat = platform.system().lower()
|
||||
|
||||
if plat == "linux":
|
||||
# Prefer systemd-detect-virt if available
|
||||
try:
|
||||
out = subprocess.run([
|
||||
"systemd-detect-virt", "--vm"
|
||||
], capture_output=True, text=True, timeout=3)
|
||||
if out.returncode == 0 and (out.stdout or "").strip():
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback to DMI sysfs strings
|
||||
for p in (
|
||||
"/sys/class/dmi/id/product_name",
|
||||
"/sys/class/dmi/id/sys_vendor",
|
||||
"/sys/class/dmi/id/board_vendor",
|
||||
):
|
||||
try:
|
||||
with open(p, "r", encoding="utf-8", errors="ignore") as fh:
|
||||
s = (fh.read() or "").lower()
|
||||
if any(k in s for k in (
|
||||
"kvm", "vmware", "virtualbox", "qemu", "xen", "hyper-v", "microsoft corporation")):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
elif plat == "windows":
|
||||
# Inspect model/manufacturer via CIM
|
||||
try:
|
||||
ps_cmd = (
|
||||
"$cs = Get-CimInstance Win32_ComputerSystem; "
|
||||
"$model = [string]$cs.Model; $manu = [string]$cs.Manufacturer; "
|
||||
"Write-Output ($model + '|' + $manu)"
|
||||
)
|
||||
out = _run_powershell_hidden(ps_cmd, timeout=6)
|
||||
s = (out.stdout or "").strip().lower()
|
||||
if any(k in s for k in ("virtual", "vmware", "virtualbox", "kvm", "qemu", "xen", "hyper-v")):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback: registry BIOS strings often include virtualization hints
|
||||
try:
|
||||
import winreg # type: ignore
|
||||
with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, r"HARDWARE\DESCRIPTION\System") as k:
|
||||
for name in ("SystemBiosVersion", "VideoBiosVersion"):
|
||||
try:
|
||||
val, _ = winreg.QueryValueEx(k, name)
|
||||
s = str(val).lower()
|
||||
if any(x in s for x in ("virtual", "vmware", "virtualbox", "qemu", "xen", "hyper-v")):
|
||||
return True
|
||||
except Exception:
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
elif plat == "darwin":
|
||||
# macOS guest detection is tricky; limited heuristic
|
||||
try:
|
||||
out = subprocess.run([
|
||||
"sysctl", "-n", "machdep.cpu.features"
|
||||
], capture_output=True, text=True, timeout=3)
|
||||
s = (out.stdout or "").lower()
|
||||
if "vmm" in s:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def _detect_device_type_non_vm() -> str:
|
||||
"""Classify non-VM device as Laptop, Desktop, or Server.
|
||||
|
||||
This is intentionally conservative; if unsure, returns 'Desktop'.
|
||||
"""
|
||||
try:
|
||||
plat = platform.system().lower()
|
||||
|
||||
if plat == "windows":
|
||||
# Prefer PCSystemTypeEx, then chassis types, then battery presence
|
||||
try:
|
||||
ps_cmd = (
|
||||
"$cs = Get-CimInstance Win32_ComputerSystem; "
|
||||
"$typeEx = [int]($cs.PCSystemTypeEx); "
|
||||
"$type = [int]($cs.PCSystemType); "
|
||||
"$ch = (Get-CimInstance Win32_SystemEnclosure).ChassisTypes; "
|
||||
"$hasBatt = @(Get-CimInstance Win32_Battery).Count -gt 0; "
|
||||
"Write-Output ($typeEx.ToString() + '|' + $type.ToString() + '|' + "
|
||||
"([string]::Join(',', $ch)) + '|' + $hasBatt)"
|
||||
)
|
||||
out = _run_powershell_hidden(ps_cmd, timeout=6)
|
||||
resp = (out.stdout or "").strip()
|
||||
parts = resp.split("|")
|
||||
type_ex = int(parts[0]) if len(parts) > 0 and parts[0].isdigit() else None
|
||||
type_pc = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else None
|
||||
chassis = []
|
||||
if len(parts) > 2 and parts[2].strip():
|
||||
for t in parts[2].split(','):
|
||||
t = t.strip()
|
||||
if t.isdigit():
|
||||
chassis.append(int(t))
|
||||
has_batt = False
|
||||
if len(parts) > 3:
|
||||
has_batt = parts[3].strip().lower() in ("true", "1")
|
||||
|
||||
# PCSystemTypeEx mapping per MS docs
|
||||
if type_ex in (4, 5, 7, 8):
|
||||
return "Server"
|
||||
if type_ex == 2:
|
||||
return "Laptop"
|
||||
if type_ex in (1, 3):
|
||||
return "Desktop"
|
||||
|
||||
# Fallback to PCSystemType
|
||||
if type_pc in (4, 5):
|
||||
return "Server"
|
||||
if type_pc == 2:
|
||||
return "Laptop"
|
||||
if type_pc in (1, 3):
|
||||
return "Desktop"
|
||||
|
||||
# ChassisType mapping (DMTF)
|
||||
laptop_types = {8, 9, 10, 14, 30, 31}
|
||||
server_types = {17, 23}
|
||||
desktop_types = {3, 4, 5, 6, 7, 15, 16, 24, 35}
|
||||
if any(ct in laptop_types for ct in chassis):
|
||||
return "Laptop"
|
||||
if any(ct in server_types for ct in chassis):
|
||||
return "Server"
|
||||
if any(ct in desktop_types for ct in chassis):
|
||||
return "Desktop"
|
||||
|
||||
if has_batt:
|
||||
return "Laptop"
|
||||
except Exception:
|
||||
pass
|
||||
return "Desktop"
|
||||
|
||||
if plat == "linux":
|
||||
# hostnamectl exposes chassis when available
|
||||
try:
|
||||
out = subprocess.run(["hostnamectl"], capture_output=True, text=True, timeout=3)
|
||||
for line in (out.stdout or "").splitlines():
|
||||
if ":" in line:
|
||||
k, v = line.split(":", 1)
|
||||
if k.strip().lower() == "chassis":
|
||||
val = v.strip().lower()
|
||||
if "laptop" in val:
|
||||
return "Laptop"
|
||||
if "desktop" in val:
|
||||
return "Desktop"
|
||||
if "server" in val:
|
||||
return "Server"
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# DMI chassis type numeric
|
||||
try:
|
||||
with open("/sys/class/dmi/id/chassis_type", "r", encoding="utf-8", errors="ignore") as fh:
|
||||
s = (fh.read() or "").strip()
|
||||
ct = int(s)
|
||||
laptop_types = {8, 9, 10, 14, 30, 31}
|
||||
server_types = {17, 23}
|
||||
desktop_types = {3, 4, 5, 6, 7, 15, 16, 24, 35}
|
||||
if ct in laptop_types:
|
||||
return "Laptop"
|
||||
if ct in server_types:
|
||||
return "Server"
|
||||
if ct in desktop_types:
|
||||
return "Desktop"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Battery presence heuristic
|
||||
try:
|
||||
if os.path.isdir("/sys/class/power_supply"):
|
||||
for name in os.listdir("/sys/class/power_supply"):
|
||||
if name.lower().startswith("bat"):
|
||||
return "Laptop"
|
||||
except Exception:
|
||||
pass
|
||||
return "Desktop"
|
||||
|
||||
if plat == "darwin":
|
||||
try:
|
||||
out = subprocess.run(["sysctl", "-n", "hw.model"], capture_output=True, text=True, timeout=3)
|
||||
model = (out.stdout or "").strip()
|
||||
if model:
|
||||
if model.lower().startswith("macbook"):
|
||||
return "Laptop"
|
||||
# iMac, Macmini, MacPro -> treat as Desktop
|
||||
return "Desktop"
|
||||
except Exception:
|
||||
pass
|
||||
return "Desktop"
|
||||
except Exception:
|
||||
pass
|
||||
return "Desktop"
|
||||
|
||||
|
||||
def detect_device_type() -> str:
|
||||
"""Return one of: 'Laptop', 'Desktop', 'Server', 'Virtual Machine'."""
|
||||
try:
|
||||
if _detect_virtual_machine():
|
||||
return "Virtual Machine"
|
||||
return _detect_device_type_non_vm()
|
||||
except Exception:
|
||||
return "Desktop"
|
||||
|
||||
|
||||
def _get_internal_ip():
|
||||
"""Best-effort detection of primary IPv4 address without external reachability.
|
||||
|
||||
Order of attempts:
|
||||
1) psutil.net_if_addrs() – first non-loopback, non-APIPA IPv4
|
||||
2) UDP connect trick to 8.8.8.8 (common technique)
|
||||
3) Windows: PowerShell Get-NetIPAddress
|
||||
4) Linux/macOS: `ip -o -4 addr show` or `hostname -I`
|
||||
"""
|
||||
# 1) psutil interfaces
|
||||
try:
|
||||
if psutil:
|
||||
for name, addrs in (psutil.net_if_addrs() or {}).items():
|
||||
for a in addrs:
|
||||
if getattr(a, "family", None) == socket.AF_INET:
|
||||
ip = a.address
|
||||
if (
|
||||
ip
|
||||
and not ip.startswith("127.")
|
||||
and not ip.startswith("169.254.")
|
||||
and ip != "0.0.0.0"
|
||||
):
|
||||
return ip
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2) UDP connect trick
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.connect(("8.8.8.8", 80))
|
||||
ip = s.getsockname()[0]
|
||||
s.close()
|
||||
if ip:
|
||||
return ip
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
plat = platform.system().lower()
|
||||
# 3) Windows PowerShell
|
||||
if plat == "windows":
|
||||
try:
|
||||
ps_cmd = (
|
||||
"Get-NetIPAddress -AddressFamily IPv4 | "
|
||||
"Where-Object { $_.IPAddress -and $_.IPAddress -notmatch '^169\\.254\\.' -and $_.IPAddress -notmatch '^127\\.' } | "
|
||||
"Sort-Object -Property PrefixLength | Select-Object -First 1 -ExpandProperty IPAddress"
|
||||
)
|
||||
out = _run_powershell_hidden(ps_cmd, timeout=20)
|
||||
val = (out.stdout or "").strip()
|
||||
if val:
|
||||
return val
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 4) Linux/macOS
|
||||
try:
|
||||
out = subprocess.run(["ip", "-o", "-4", "addr", "show"], capture_output=True, text=True, timeout=10)
|
||||
for line in out.stdout.splitlines():
|
||||
parts = line.split()
|
||||
if len(parts) >= 4:
|
||||
ip = parts[3].split("/")[0]
|
||||
if ip and not ip.startswith("127.") and not ip.startswith("169.254."):
|
||||
return ip
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
out = subprocess.run(["hostname", "-I"], capture_output=True, text=True, timeout=5)
|
||||
val = (out.stdout or "").strip().split()
|
||||
for ip in val:
|
||||
if ip and not ip.startswith("127.") and not ip.startswith("169.254."):
|
||||
return ip
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return "unknown"
|
||||
|
||||
|
||||
def collect_summary(config):
|
||||
try:
|
||||
username = getpass.getuser()
|
||||
domain = os.environ.get("USERDOMAIN") or socket.gethostname()
|
||||
last_user = f"{domain}\\{username}" if username else "unknown"
|
||||
except Exception:
|
||||
last_user = "unknown"
|
||||
|
||||
try:
|
||||
last_reboot = "unknown"
|
||||
# First, prefer psutil if available
|
||||
if psutil:
|
||||
try:
|
||||
last_reboot = time.strftime(
|
||||
"%Y-%m-%d %H:%M:%S",
|
||||
time.localtime(psutil.boot_time()),
|
||||
)
|
||||
except Exception:
|
||||
last_reboot = "unknown"
|
||||
if last_reboot == "unknown":
|
||||
plat = platform.system().lower()
|
||||
if plat == "windows":
|
||||
# Try WMIC, then robust PowerShell fallback regardless of WMIC presence
|
||||
raw = ""
|
||||
try:
|
||||
out = _run_hidden(["wmic", "os", "get", "lastbootuptime"], timeout=20)
|
||||
raw = "".join(out.stdout.splitlines()[1:]).strip()
|
||||
except Exception:
|
||||
raw = ""
|
||||
# If WMIC didn't yield a value, try CIM and format directly in PowerShell
|
||||
if not raw:
|
||||
try:
|
||||
ps_cmd = (
|
||||
"(Get-CimInstance Win32_OperatingSystem).LastBootUpTime | "
|
||||
"ForEach-Object { (Get-Date -Date $_ -Format 'yyyy-MM-dd HH:mm:ss') }"
|
||||
)
|
||||
out = _run_powershell_hidden(ps_cmd, timeout=20)
|
||||
raw = (out.stdout or "").strip()
|
||||
if raw:
|
||||
last_reboot = raw
|
||||
except Exception:
|
||||
raw = ""
|
||||
# Parse WMIC-style if we had it
|
||||
if last_reboot == "unknown" and raw:
|
||||
try:
|
||||
boot = datetime.datetime.strptime(raw.split(".")[0], "%Y%m%d%H%M%S")
|
||||
last_reboot = boot.strftime("%Y-%m-%d %H:%M:%S")
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
out = subprocess.run(["uptime", "-s"], capture_output=True, text=True, timeout=10)
|
||||
val = out.stdout.strip()
|
||||
if val:
|
||||
last_reboot = val
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
last_reboot = "unknown"
|
||||
|
||||
created = config.data.get("created")
|
||||
if not created:
|
||||
created = time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
config.data["created"] = created
|
||||
try:
|
||||
config._write()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# External IP detection with fallbacks
|
||||
external_ip = "unknown"
|
||||
for url in ("https://api.ipify.org", "https://api64.ipify.org", "https://ifconfig.me/ip"):
|
||||
try:
|
||||
external_ip = requests.get(url, timeout=5).text.strip()
|
||||
if external_ip:
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return {
|
||||
"hostname": socket.gethostname(),
|
||||
"operating_system": config.data.get("agent_operating_system", detect_agent_os()),
|
||||
"device_type": config.data.get("device_type", detect_device_type()),
|
||||
"last_user": last_user,
|
||||
"internal_ip": _get_internal_ip(),
|
||||
"external_ip": external_ip,
|
||||
"last_reboot": last_reboot,
|
||||
"created": created,
|
||||
}
|
||||
|
||||
|
||||
def collect_software():
|
||||
items = []
|
||||
plat = platform.system().lower()
|
||||
try:
|
||||
if plat == "windows":
|
||||
try:
|
||||
out = _run_hidden(["wmic", "product", "get", "name,version"], timeout=60)
|
||||
for line in out.stdout.splitlines():
|
||||
if line.strip() and not line.lower().startswith("name"):
|
||||
parts = line.strip().split(" ")
|
||||
name = parts[0].strip()
|
||||
version = parts[-1].strip() if len(parts) > 1 else ""
|
||||
if name:
|
||||
items.append({"name": name, "version": version})
|
||||
except FileNotFoundError:
|
||||
ps_cmd = (
|
||||
"Get-ItemProperty "
|
||||
"'HKLM:\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*',"
|
||||
"'HKLM:\\Software\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*' "
|
||||
"| Where-Object { $_.DisplayName } "
|
||||
"| Select-Object DisplayName,DisplayVersion "
|
||||
"| ConvertTo-Json"
|
||||
)
|
||||
out = _run_powershell_hidden(ps_cmd, timeout=60)
|
||||
data = json.loads(out.stdout or "[]")
|
||||
if isinstance(data, dict):
|
||||
data = [data]
|
||||
for pkg in data:
|
||||
name = pkg.get("DisplayName")
|
||||
if name:
|
||||
items.append({
|
||||
"name": name,
|
||||
"version": pkg.get("DisplayVersion", "")
|
||||
})
|
||||
elif plat == "linux":
|
||||
out = subprocess.run(["dpkg-query", "-W", "-f=${Package}\t${Version}\n"], capture_output=True, text=True)
|
||||
for line in out.stdout.splitlines():
|
||||
if "\t" in line:
|
||||
name, version = line.split("\t", 1)
|
||||
items.append({"name": name, "version": version})
|
||||
else:
|
||||
out = subprocess.run([sys.executable, "-m", "pip", "list", "--format", "json"], capture_output=True, text=True)
|
||||
data = json.loads(out.stdout or "[]")
|
||||
for pkg in data:
|
||||
items.append({"name": pkg.get("name"), "version": pkg.get("version")})
|
||||
except Exception as e:
|
||||
print(f"[WARN] collect_software failed: {e}")
|
||||
return items[:100]
|
||||
|
||||
|
||||
def collect_memory():
|
||||
entries = []
|
||||
plat = platform.system().lower()
|
||||
try:
|
||||
if plat == "windows":
|
||||
try:
|
||||
out = _run_hidden(["wmic", "memorychip", "get", "BankLabel,Speed,SerialNumber,Capacity"], timeout=60)
|
||||
lines = [l for l in out.stdout.splitlines() if l.strip() and "BankLabel" not in l]
|
||||
for line in lines:
|
||||
parts = [p for p in line.split() if p]
|
||||
if len(parts) >= 4:
|
||||
entries.append({
|
||||
"slot": parts[0],
|
||||
"speed": parts[1],
|
||||
"serial": parts[2],
|
||||
"capacity": parts[3],
|
||||
})
|
||||
except FileNotFoundError:
|
||||
ps_cmd = (
|
||||
"Get-CimInstance Win32_PhysicalMemory | "
|
||||
"Select-Object BankLabel,Speed,SerialNumber,Capacity | ConvertTo-Json"
|
||||
)
|
||||
out = _run_powershell_hidden(ps_cmd, timeout=60)
|
||||
data = json.loads(out.stdout or "[]")
|
||||
if isinstance(data, dict):
|
||||
data = [data]
|
||||
for stick in data:
|
||||
entries.append({
|
||||
"slot": stick.get("BankLabel", "unknown"),
|
||||
"speed": str(stick.get("Speed", "unknown")),
|
||||
"serial": stick.get("SerialNumber", "unknown"),
|
||||
"capacity": stick.get("Capacity", "unknown"),
|
||||
})
|
||||
elif plat == "linux":
|
||||
out = subprocess.run(["dmidecode", "-t", "17"], capture_output=True, text=True)
|
||||
slot = speed = serial = capacity = None
|
||||
for line in out.stdout.splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("Locator:"):
|
||||
slot = line.split(":", 1)[1].strip()
|
||||
elif line.startswith("Speed:"):
|
||||
speed = line.split(":", 1)[1].strip()
|
||||
elif line.startswith("Serial Number:"):
|
||||
serial = line.split(":", 1)[1].strip()
|
||||
elif line.startswith("Size:"):
|
||||
capacity = line.split(":", 1)[1].strip()
|
||||
elif not line and slot:
|
||||
entries.append({
|
||||
"slot": slot,
|
||||
"speed": speed or "unknown",
|
||||
"serial": serial or "unknown",
|
||||
"capacity": capacity or "unknown",
|
||||
})
|
||||
slot = speed = serial = capacity = None
|
||||
if slot:
|
||||
entries.append({
|
||||
"slot": slot,
|
||||
"speed": speed or "unknown",
|
||||
"serial": serial or "unknown",
|
||||
"capacity": capacity or "unknown",
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"[WARN] collect_memory failed: {e}")
|
||||
|
||||
if not entries:
|
||||
try:
|
||||
if psutil:
|
||||
vm = psutil.virtual_memory()
|
||||
entries.append({
|
||||
"slot": "physical",
|
||||
"speed": "unknown",
|
||||
"serial": "unknown",
|
||||
"capacity": vm.total,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
return entries
|
||||
|
||||
|
||||
def collect_storage():
|
||||
disks = []
|
||||
plat = platform.system().lower()
|
||||
try:
|
||||
if psutil:
|
||||
for part in psutil.disk_partitions():
|
||||
try:
|
||||
usage = psutil.disk_usage(part.mountpoint)
|
||||
except Exception:
|
||||
continue
|
||||
disks.append({
|
||||
"drive": part.device,
|
||||
"disk_type": "Removable" if "removable" in part.opts.lower() else "Fixed Disk",
|
||||
"usage": usage.percent,
|
||||
"total": usage.total,
|
||||
"free": usage.free,
|
||||
"used": usage.used,
|
||||
})
|
||||
elif plat == "windows":
|
||||
found = False
|
||||
for letter in string.ascii_uppercase:
|
||||
drive = f"{letter}:\\"
|
||||
if os.path.exists(drive):
|
||||
try:
|
||||
usage = shutil.disk_usage(drive)
|
||||
except Exception:
|
||||
continue
|
||||
disks.append({
|
||||
"drive": drive,
|
||||
"disk_type": "Fixed Disk",
|
||||
"usage": (usage.used / usage.total * 100) if usage.total else 0,
|
||||
"total": usage.total,
|
||||
"free": usage.free,
|
||||
"used": usage.used,
|
||||
})
|
||||
found = True
|
||||
if not found:
|
||||
try:
|
||||
out = _run_hidden(["wmic", "logicaldisk", "get", "DeviceID,Size,FreeSpace"], timeout=60)
|
||||
lines = [l for l in out.stdout.splitlines() if l.strip()][1:]
|
||||
for line in lines:
|
||||
parts = line.split()
|
||||
if len(parts) >= 3:
|
||||
drive, free, size = parts[0], parts[1], parts[2]
|
||||
try:
|
||||
total = float(size)
|
||||
free_bytes = float(free)
|
||||
used = total - free_bytes
|
||||
usage = (used / total * 100) if total else 0
|
||||
disks.append({
|
||||
"drive": drive,
|
||||
"disk_type": "Fixed Disk",
|
||||
"usage": usage,
|
||||
"total": total,
|
||||
"free": free_bytes,
|
||||
"used": used,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
out = subprocess.run(["df", "-hP"], capture_output=True, text=True)
|
||||
for line in out.stdout.splitlines()[1:]:
|
||||
parts = line.split()
|
||||
if len(parts) >= 6:
|
||||
try:
|
||||
usage_str = parts[4].rstrip('%')
|
||||
usage = float(usage_str)
|
||||
except Exception:
|
||||
usage = 0
|
||||
total = parts[1]
|
||||
free_bytes = parts[3]
|
||||
used = parts[2]
|
||||
disks.append({
|
||||
"drive": parts[0],
|
||||
"disk_type": "Mounted",
|
||||
"usage": usage,
|
||||
"total": total,
|
||||
"free": free_bytes,
|
||||
"used": used,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f"[WARN] collect_storage failed: {e}")
|
||||
return disks
|
||||
|
||||
|
||||
def collect_network():
|
||||
adapters = []
|
||||
plat = platform.system().lower()
|
||||
try:
|
||||
if psutil:
|
||||
for name, addrs in psutil.net_if_addrs().items():
|
||||
ips = [a.address for a in addrs if getattr(a, "family", None) == socket.AF_INET]
|
||||
mac = next((a.address for a in addrs if getattr(a, "family", None) == getattr(psutil, "AF_LINK", object)), "unknown")
|
||||
adapters.append({"adapter": name, "ips": ips, "mac": mac})
|
||||
elif plat == "windows":
|
||||
ps_cmd = (
|
||||
"Get-NetIPConfiguration | "
|
||||
"Select-Object InterfaceAlias,@{Name='IPv4';Expression={$_.IPv4Address.IPAddress}},"
|
||||
"@{Name='MAC';Expression={$_.NetAdapter.MacAddress}} | ConvertTo-Json"
|
||||
)
|
||||
out = _run_powershell_hidden(ps_cmd, timeout=60)
|
||||
data = json.loads(out.stdout or "[]")
|
||||
if isinstance(data, dict):
|
||||
data = [data]
|
||||
for a in data:
|
||||
ip = a.get("IPv4")
|
||||
adapters.append({
|
||||
"adapter": a.get("InterfaceAlias", "unknown"),
|
||||
"ips": [ip] if ip else [],
|
||||
"mac": a.get("MAC", "unknown"),
|
||||
})
|
||||
else:
|
||||
out = subprocess.run(["ip", "-o", "-4", "addr", "show"], capture_output=True, text=True, timeout=60)
|
||||
for line in out.stdout.splitlines():
|
||||
parts = line.split()
|
||||
if len(parts) >= 4:
|
||||
name = parts[1]
|
||||
ip = parts[3].split("/")[0]
|
||||
adapters.append({"adapter": name, "ips": [ip], "mac": "unknown"})
|
||||
except Exception as e:
|
||||
print(f"[WARN] collect_network failed: {e}")
|
||||
return adapters
|
||||
|
||||
|
||||
async def send_agent_details(agent_id, config):
|
||||
"""Collect detailed agent data and send to server periodically."""
|
||||
while True:
|
||||
try:
|
||||
details = {
|
||||
"summary": collect_summary(config),
|
||||
"software": collect_software(),
|
||||
"memory": collect_memory(),
|
||||
"storage": collect_storage(),
|
||||
"network": collect_network(),
|
||||
}
|
||||
url = config.data.get("borealis_server_url", "http://localhost:5000") + "/api/agent/details"
|
||||
payload = {
|
||||
"agent_id": agent_id,
|
||||
"hostname": details.get("summary", {}).get("hostname", socket.gethostname()),
|
||||
"details": details,
|
||||
}
|
||||
async with aiohttp.ClientSession() as session:
|
||||
await session.post(url, json=payload, timeout=10)
|
||||
except Exception as e:
|
||||
print(f"[WARN] Failed to send agent details: {e}")
|
||||
# Report every ~2 minutes
|
||||
await asyncio.sleep(120)
|
||||
@@ -1,352 +0,0 @@
|
||||
import os
|
||||
import asyncio
|
||||
import concurrent.futures
|
||||
from functools import partial
|
||||
from io import BytesIO
|
||||
import base64
|
||||
import traceback
|
||||
import random
|
||||
import importlib.util
|
||||
|
||||
from PyQt5 import QtCore, QtGui, QtWidgets
|
||||
from PIL import ImageGrab
|
||||
|
||||
|
||||
class RolesContext:
|
||||
def __init__(self, sio, agent_id, config):
|
||||
self.sio = sio
|
||||
self.agent_id = agent_id
|
||||
self.config = config
|
||||
|
||||
|
||||
# Load macro engines from the local Python_API_Endpoints directory
|
||||
MACRO_ENGINE_PATH = os.path.join(os.path.dirname(__file__), "Python_API_Endpoints", "macro_engines.py")
|
||||
spec = importlib.util.spec_from_file_location("macro_engines", MACRO_ENGINE_PATH)
|
||||
macro_engines = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(macro_engines)
|
||||
|
||||
|
||||
# Overlay visuals
|
||||
overlay_green_thickness = 4
|
||||
overlay_gray_thickness = 2
|
||||
handle_size = overlay_green_thickness * 2
|
||||
extra_top_padding = overlay_green_thickness * 2 + 4
|
||||
|
||||
|
||||
# Track active screenshot overlay widgets per node_id
|
||||
overlay_widgets: dict[str, QtWidgets.QWidget] = {}
|
||||
|
||||
|
||||
def get_window_list():
|
||||
"""Return a list of windows from macro engines."""
|
||||
try:
|
||||
return macro_engines.list_windows()
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def close_overlay(node_id: str):
|
||||
w = overlay_widgets.pop(node_id, None)
|
||||
if w:
|
||||
try:
|
||||
w.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def close_all_overlays():
|
||||
for node_id, widget in list(overlay_widgets.items()):
|
||||
try:
|
||||
widget.close()
|
||||
except Exception:
|
||||
pass
|
||||
overlay_widgets.clear()
|
||||
|
||||
|
||||
class ScreenshotRegion(QtWidgets.QWidget):
|
||||
def __init__(self, ctx: RolesContext, node_id, x=100, y=100, w=300, h=200, alias=None):
|
||||
super().__init__()
|
||||
self.ctx = ctx
|
||||
self.node_id = node_id
|
||||
self.alias = alias
|
||||
self.setGeometry(
|
||||
x - handle_size,
|
||||
y - handle_size - extra_top_padding,
|
||||
w + handle_size * 2,
|
||||
h + handle_size * 2 + extra_top_padding,
|
||||
)
|
||||
self.setWindowFlags(QtCore.Qt.FramelessWindowHint | QtCore.Qt.WindowStaysOnTopHint)
|
||||
self.setAttribute(QtCore.Qt.WA_TranslucentBackground)
|
||||
self.resize_dir = None
|
||||
self.drag_offset = None
|
||||
self._start_geom = None
|
||||
self._start_pos = None
|
||||
self.setMouseTracking(True)
|
||||
|
||||
def paintEvent(self, event):
|
||||
p = QtGui.QPainter(self)
|
||||
p.setRenderHint(QtGui.QPainter.Antialiasing)
|
||||
w = self.width()
|
||||
h = self.height()
|
||||
|
||||
# draw gray capture box
|
||||
p.setPen(QtGui.QPen(QtGui.QColor(130, 130, 130), overlay_gray_thickness))
|
||||
p.drawRect(handle_size, handle_size + extra_top_padding, w - handle_size * 2, h - handle_size * 2 - extra_top_padding)
|
||||
|
||||
p.setPen(QtCore.Qt.NoPen)
|
||||
p.setBrush(QtGui.QBrush(QtGui.QColor(0, 191, 255)))
|
||||
edge = overlay_green_thickness * 3
|
||||
|
||||
# corner handles
|
||||
p.drawRect(0, extra_top_padding, edge, overlay_green_thickness)
|
||||
p.drawRect(0, extra_top_padding, overlay_green_thickness, edge)
|
||||
p.drawRect(w - edge, extra_top_padding, edge, overlay_green_thickness)
|
||||
p.drawRect(w - overlay_green_thickness, extra_top_padding, overlay_green_thickness, edge)
|
||||
p.drawRect(0, h - overlay_green_thickness, edge, overlay_green_thickness)
|
||||
p.drawRect(0, h - edge, overlay_green_thickness, edge)
|
||||
p.drawRect(w - edge, h - overlay_green_thickness, edge, overlay_green_thickness)
|
||||
p.drawRect(w - overlay_green_thickness, h - edge, overlay_green_thickness, edge)
|
||||
|
||||
# side handles
|
||||
long = overlay_green_thickness * 6
|
||||
p.drawRect((w - long) // 2, extra_top_padding, long, overlay_green_thickness)
|
||||
p.drawRect((w - long) // 2, h - overlay_green_thickness, long, overlay_green_thickness)
|
||||
p.drawRect(0, (h + extra_top_padding - long) // 2, overlay_green_thickness, long)
|
||||
p.drawRect(w - overlay_green_thickness, (h + extra_top_padding - long) // 2, overlay_green_thickness, long)
|
||||
|
||||
# grabber bar
|
||||
bar_width = overlay_green_thickness * 6
|
||||
bar_height = overlay_green_thickness
|
||||
bar_x = (w - bar_width) // 2
|
||||
bar_y = 6
|
||||
p.setBrush(QtGui.QColor(0, 191, 255))
|
||||
p.drawRect(bar_x, bar_y - bar_height - 10, bar_width, bar_height * 4)
|
||||
|
||||
def get_geometry(self):
|
||||
g = self.geometry()
|
||||
return (
|
||||
g.x() + handle_size,
|
||||
g.y() + handle_size + extra_top_padding,
|
||||
g.width() - handle_size * 2,
|
||||
g.height() - handle_size * 2 - extra_top_padding,
|
||||
)
|
||||
|
||||
def mousePressEvent(self, e):
|
||||
if e.button() == QtCore.Qt.LeftButton:
|
||||
pos = e.pos()
|
||||
bar_width = overlay_green_thickness * 6
|
||||
bar_height = overlay_green_thickness
|
||||
bar_x = (self.width() - bar_width) // 2
|
||||
bar_y = 2
|
||||
bar_rect = QtCore.QRect(bar_x, bar_y, bar_width, bar_height)
|
||||
|
||||
if bar_rect.contains(pos):
|
||||
self.drag_offset = e.globalPos() - self.frameGeometry().topLeft()
|
||||
return
|
||||
|
||||
m = handle_size
|
||||
dirs = []
|
||||
if pos.x() <= m:
|
||||
dirs.append('left')
|
||||
if pos.x() >= self.width() - m:
|
||||
dirs.append('right')
|
||||
if pos.y() <= m + extra_top_padding:
|
||||
dirs.append('top')
|
||||
if pos.y() >= self.height() - m:
|
||||
dirs.append('bottom')
|
||||
if dirs:
|
||||
self.resize_dir = '_'.join(dirs)
|
||||
self._start_geom = self.geometry()
|
||||
self._start_pos = e.globalPos()
|
||||
else:
|
||||
self.drag_offset = e.globalPos() - self.frameGeometry().topLeft()
|
||||
|
||||
def mouseMoveEvent(self, e):
|
||||
if self.resize_dir and self._start_geom and self._start_pos:
|
||||
dx = e.globalX() - self._start_pos.x()
|
||||
dy = e.globalY() - self._start_pos.y()
|
||||
geom = QtCore.QRect(self._start_geom)
|
||||
if 'left' in self.resize_dir:
|
||||
new_x = geom.x() + dx
|
||||
new_w = geom.width() - dx
|
||||
geom.setX(new_x)
|
||||
geom.setWidth(new_w)
|
||||
if 'right' in self.resize_dir:
|
||||
geom.setWidth(self._start_geom.width() + dx)
|
||||
if 'top' in self.resize_dir:
|
||||
new_y = geom.y() + dy
|
||||
new_h = geom.height() - dy
|
||||
geom.setY(new_y)
|
||||
geom.setHeight(new_h)
|
||||
if 'bottom' in self.resize_dir:
|
||||
geom.setHeight(self._start_geom.height() + dy)
|
||||
self.setGeometry(geom)
|
||||
elif self.drag_offset and e.buttons() & QtCore.Qt.LeftButton:
|
||||
self.move(e.globalPos() - self.drag_offset)
|
||||
|
||||
def mouseReleaseEvent(self, e):
|
||||
self.drag_offset = None
|
||||
self.resize_dir = None
|
||||
self._start_geom = None
|
||||
self._start_pos = None
|
||||
x, y, w, h = self.get_geometry()
|
||||
self.ctx.config.data['regions'][self.node_id] = {'x': x, 'y': y, 'w': w, 'h': h}
|
||||
try:
|
||||
self.ctx.config._write()
|
||||
except Exception:
|
||||
pass
|
||||
# Emit a zero-image update so the server knows new geometry immediately
|
||||
asyncio.create_task(self.ctx.sio.emit('agent_screenshot_task', {
|
||||
'agent_id': self.ctx.agent_id,
|
||||
'node_id': self.node_id,
|
||||
'image_base64': '',
|
||||
'x': x, 'y': y, 'w': w, 'h': h
|
||||
}))
|
||||
|
||||
|
||||
async def screenshot_task(ctx: RolesContext, cfg):
|
||||
nid = cfg.get('node_id')
|
||||
alias = cfg.get('alias', '')
|
||||
r = ctx.config.data['regions'].get(nid)
|
||||
if r:
|
||||
region = (r['x'], r['y'], r['w'], r['h'])
|
||||
else:
|
||||
region = (
|
||||
cfg.get('x', 100),
|
||||
cfg.get('y', 100),
|
||||
cfg.get('w', 300),
|
||||
cfg.get('h', 200),
|
||||
)
|
||||
ctx.config.data['regions'][nid] = {'x': region[0], 'y': region[1], 'w': region[2], 'h': region[3]}
|
||||
try:
|
||||
ctx.config._write()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if nid not in overlay_widgets:
|
||||
widget = ScreenshotRegion(ctx, nid, *region, alias=alias)
|
||||
overlay_widgets[nid] = widget
|
||||
widget.show()
|
||||
|
||||
await ctx.sio.emit('agent_screenshot_task', {
|
||||
'agent_id': ctx.agent_id,
|
||||
'node_id': nid,
|
||||
'image_base64': '',
|
||||
'x': region[0], 'y': region[1], 'w': region[2], 'h': region[3]
|
||||
})
|
||||
|
||||
interval = cfg.get('interval', 1000) / 1000.0
|
||||
loop = asyncio.get_event_loop()
|
||||
executor = concurrent.futures.ThreadPoolExecutor(max_workers=ctx.config.data.get('max_task_workers', 8))
|
||||
try:
|
||||
while True:
|
||||
x, y, w, h = overlay_widgets[nid].get_geometry()
|
||||
grab = partial(ImageGrab.grab, bbox=(x, y, x + w, y + h))
|
||||
img = await loop.run_in_executor(executor, grab)
|
||||
buf = BytesIO(); img.save(buf, format='PNG')
|
||||
encoded = base64.b64encode(buf.getvalue()).decode('utf-8')
|
||||
await ctx.sio.emit('agent_screenshot_task', {
|
||||
'agent_id': ctx.agent_id,
|
||||
'node_id': nid,
|
||||
'image_base64': encoded,
|
||||
'x': x, 'y': y, 'w': w, 'h': h
|
||||
})
|
||||
await asyncio.sleep(interval)
|
||||
except asyncio.CancelledError:
|
||||
print(f"[TASK] Screenshot role {nid} cancelled.")
|
||||
except Exception as e:
|
||||
print(f"[ERROR] Screenshot task {nid} failed: {e}")
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
async def macro_task(ctx: RolesContext, cfg):
|
||||
"""Improved macro task supporting operation modes, live config, and feedback."""
|
||||
nid = cfg.get('node_id')
|
||||
|
||||
last_trigger_value = 0
|
||||
has_run_once = False
|
||||
|
||||
while True:
|
||||
window_handle = cfg.get('window_handle')
|
||||
macro_type = cfg.get('macro_type', 'keypress')
|
||||
operation_mode = cfg.get('operation_mode', 'Continuous')
|
||||
key = cfg.get('key')
|
||||
text = cfg.get('text')
|
||||
interval_ms = int(cfg.get('interval_ms', 1000))
|
||||
randomize = cfg.get('randomize_interval', False)
|
||||
random_min = int(cfg.get('random_min', 750))
|
||||
random_max = int(cfg.get('random_max', 950))
|
||||
active = cfg.get('active', True)
|
||||
trigger = int(cfg.get('trigger', 0))
|
||||
|
||||
async def emit_macro_status(success, message=""):
|
||||
await ctx.sio.emit('macro_status', {
|
||||
"agent_id": ctx.agent_id,
|
||||
"node_id": nid,
|
||||
"success": success,
|
||||
"message": message,
|
||||
"timestamp": int(asyncio.get_event_loop().time() * 1000)
|
||||
})
|
||||
|
||||
if not (active is True or str(active).lower() == "true"):
|
||||
await asyncio.sleep(0.2)
|
||||
continue
|
||||
|
||||
try:
|
||||
send_macro = False
|
||||
|
||||
if operation_mode == "Run Once":
|
||||
if not has_run_once:
|
||||
send_macro = True
|
||||
has_run_once = True
|
||||
elif operation_mode == "Continuous":
|
||||
send_macro = True
|
||||
elif operation_mode == "Trigger-Continuous":
|
||||
send_macro = (trigger == 1)
|
||||
elif operation_mode == "Trigger-Once":
|
||||
if last_trigger_value == 0 and trigger == 1:
|
||||
send_macro = True
|
||||
last_trigger_value = trigger
|
||||
else:
|
||||
send_macro = True
|
||||
|
||||
if send_macro:
|
||||
if macro_type == 'keypress' and key:
|
||||
result = macro_engines.send_keypress_to_window(window_handle, key)
|
||||
elif macro_type == 'typed_text' and text:
|
||||
result = macro_engines.type_text_to_window(window_handle, text)
|
||||
else:
|
||||
await emit_macro_status(False, "Invalid macro type or missing key/text")
|
||||
await asyncio.sleep(0.2)
|
||||
continue
|
||||
|
||||
if isinstance(result, tuple):
|
||||
success, err = result
|
||||
else:
|
||||
success, err = bool(result), ""
|
||||
|
||||
if success:
|
||||
await emit_macro_status(True, f"Macro sent: {macro_type}")
|
||||
else:
|
||||
await emit_macro_status(False, err or "Unknown macro engine failure")
|
||||
else:
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
if send_macro:
|
||||
if randomize:
|
||||
ms = random.randint(random_min, random_max)
|
||||
else:
|
||||
ms = interval_ms
|
||||
await asyncio.sleep(ms / 1000.0)
|
||||
else:
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
print(f"[TASK] Macro role {nid} cancelled.")
|
||||
break
|
||||
except Exception as e:
|
||||
print(f"[ERROR] Macro task {nid} failed: {e}")
|
||||
traceback.print_exc()
|
||||
await emit_macro_status(False, str(e))
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
@@ -1,321 +0,0 @@
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import subprocess
|
||||
import threading
|
||||
import datetime
|
||||
import json
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
# Optional pywin32 imports for per-session launching
|
||||
try:
|
||||
import win32ts
|
||||
import win32con
|
||||
import win32process
|
||||
import win32security
|
||||
import win32profile
|
||||
import win32api
|
||||
import pywintypes
|
||||
except Exception:
|
||||
win32ts = None
|
||||
|
||||
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir, os.pardir))
|
||||
AGENT_DIR = os.path.join(ROOT, 'Agent')
|
||||
BOREALIS_DIR = os.path.join(AGENT_DIR, 'Borealis')
|
||||
LOG_DIR = os.path.join(ROOT, 'Logs', 'Agent')
|
||||
os.makedirs(LOG_DIR, exist_ok=True)
|
||||
LOG_FILE = os.path.join(LOG_DIR, 'Supervisor.log')
|
||||
PID_FILE = os.path.join(LOG_DIR, 'script_agent.pid')
|
||||
|
||||
# Internal state for process + backoff
|
||||
_script_proc = None
|
||||
_spawn_backoff = 5 # seconds (exponential backoff start)
|
||||
_max_backoff = 300 # cap at 5 minutes
|
||||
_next_spawn_time = 0.0
|
||||
_last_disable_log = 0.0
|
||||
_last_fail_log = 0.0
|
||||
|
||||
|
||||
def log(msg: str):
|
||||
try:
|
||||
# simple size-based rotation (~1MB)
|
||||
try:
|
||||
if os.path.isfile(LOG_FILE) and os.path.getsize(LOG_FILE) > 1_000_000:
|
||||
bak = LOG_FILE + '.1'
|
||||
try:
|
||||
if os.path.isfile(bak):
|
||||
os.remove(bak)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
os.replace(LOG_FILE, bak)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
ts = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
with open(LOG_FILE, 'a', encoding='utf-8') as f:
|
||||
f.write(f"[{ts}] {msg}\n")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def venv_python():
|
||||
try:
|
||||
exe_dir = os.path.join(AGENT_DIR, 'Scripts')
|
||||
py = os.path.join(exe_dir, 'python.exe')
|
||||
if os.path.isfile(py):
|
||||
return py
|
||||
except Exception:
|
||||
pass
|
||||
return sys.executable
|
||||
|
||||
|
||||
def venv_pythonw():
|
||||
try:
|
||||
exe_dir = os.path.join(AGENT_DIR, 'Scripts')
|
||||
pyw = os.path.join(exe_dir, 'pythonw.exe')
|
||||
if os.path.isfile(pyw):
|
||||
return pyw
|
||||
except Exception:
|
||||
pass
|
||||
return venv_python()
|
||||
|
||||
|
||||
def _settings_path():
|
||||
return os.path.join(ROOT, 'agent_settings.json')
|
||||
|
||||
|
||||
def load_settings():
|
||||
cfg = {}
|
||||
try:
|
||||
path = _settings_path()
|
||||
if os.path.isfile(path):
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
cfg = json.load(f)
|
||||
except Exception:
|
||||
cfg = {}
|
||||
return cfg or {}
|
||||
|
||||
|
||||
def _psutil_process_exists(pid: int) -> bool:
|
||||
try:
|
||||
import psutil # type: ignore
|
||||
if pid <= 0:
|
||||
return False
|
||||
p = psutil.Process(pid)
|
||||
return p.is_running() and (p.status() != psutil.STATUS_ZOMBIE)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _win_process_exists(pid: int) -> bool:
|
||||
try:
|
||||
if pid <= 0:
|
||||
return False
|
||||
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
||||
kernel32 = ctypes.WinDLL('kernel32', use_last_error=True)
|
||||
OpenProcess = kernel32.OpenProcess
|
||||
OpenProcess.restype = wintypes.HANDLE
|
||||
OpenProcess.argtypes = (wintypes.DWORD, wintypes.BOOL, wintypes.DWORD)
|
||||
CloseHandle = kernel32.CloseHandle
|
||||
CloseHandle.argtypes = (wintypes.HANDLE,)
|
||||
h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
|
||||
if h:
|
||||
try:
|
||||
CloseHandle(h)
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def process_exists(pid: int) -> bool:
|
||||
# Prefer psutil if available; else Win32 API
|
||||
return _psutil_process_exists(pid) or _win_process_exists(pid)
|
||||
|
||||
|
||||
def _read_pid_file() -> int:
|
||||
try:
|
||||
if os.path.isfile(PID_FILE):
|
||||
with open(PID_FILE, 'r', encoding='utf-8') as f:
|
||||
s = f.read().strip()
|
||||
return int(s)
|
||||
except Exception:
|
||||
pass
|
||||
return 0
|
||||
|
||||
|
||||
def _write_pid_file(pid: int):
|
||||
try:
|
||||
with open(PID_FILE, 'w', encoding='utf-8') as f:
|
||||
f.write(str(pid))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _clear_pid_file():
|
||||
try:
|
||||
if os.path.isfile(PID_FILE):
|
||||
os.remove(PID_FILE)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def ensure_script_agent():
|
||||
"""Ensure LocalSystem script_agent.py is running; restart if not, with backoff and PID tracking."""
|
||||
global _script_proc, _spawn_backoff, _next_spawn_time, _last_disable_log, _last_fail_log
|
||||
|
||||
# Allow disabling via config
|
||||
try:
|
||||
cfg = load_settings()
|
||||
if not cfg.get('enable_system_script_agent', True):
|
||||
now = time.time()
|
||||
if now - _last_disable_log > 60:
|
||||
log('System script agent disabled by config (enable_system_script_agent=false)')
|
||||
_last_disable_log = now
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# If we have a running child process, keep it
|
||||
try:
|
||||
if _script_proc is not None:
|
||||
if _script_proc.poll() is None:
|
||||
return
|
||||
else:
|
||||
# Child exited; clear PID file for safety
|
||||
_clear_pid_file()
|
||||
_script_proc = None
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# If PID file points to a living process, don't spawn
|
||||
try:
|
||||
pid = _read_pid_file()
|
||||
if pid and process_exists(pid):
|
||||
return
|
||||
elif pid and not process_exists(pid):
|
||||
_clear_pid_file()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Honor backoff window
|
||||
if time.time() < _next_spawn_time:
|
||||
return
|
||||
|
||||
py = venv_python()
|
||||
script = os.path.join(ROOT, 'Data', 'Agent', 'script_agent.py')
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
[py, '-W', 'ignore::SyntaxWarning', script],
|
||||
creationflags=(0x08000000 if os.name == 'nt' else 0),
|
||||
)
|
||||
_script_proc = proc
|
||||
_write_pid_file(proc.pid)
|
||||
log(f'Launched script_agent.py (pid {proc.pid})')
|
||||
# reset backoff on success
|
||||
_spawn_backoff = 5
|
||||
_next_spawn_time = 0.0
|
||||
except Exception as e:
|
||||
msg = f'Failed to launch script_agent.py: {e}'
|
||||
now = time.time()
|
||||
# rate-limit identical failure logs to once per 10s
|
||||
if now - _last_fail_log > 10:
|
||||
log(msg)
|
||||
_last_fail_log = now
|
||||
# exponential backoff
|
||||
_spawn_backoff = min(_spawn_backoff * 2, _max_backoff)
|
||||
_next_spawn_time = time.time() + _spawn_backoff
|
||||
|
||||
|
||||
def _enable_privileges():
|
||||
try:
|
||||
hProc = win32api.GetCurrentProcess()
|
||||
hTok = win32security.OpenProcessToken(hProc, win32con.TOKEN_ADJUST_PRIVILEGES | win32con.TOKEN_QUERY)
|
||||
for name in [
|
||||
win32security.SE_ASSIGNPRIMARYTOKEN_NAME,
|
||||
win32security.SE_INCREASE_QUOTA_NAME,
|
||||
win32security.SE_TCB_NAME,
|
||||
win32security.SE_BACKUP_NAME,
|
||||
win32security.SE_RESTORE_NAME,
|
||||
]:
|
||||
try:
|
||||
luid = win32security.LookupPrivilegeValue(None, name)
|
||||
win32security.AdjustTokenPrivileges(hTok, False, [(luid, win32con.SE_PRIVILEGE_ENABLED)])
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def active_sessions():
|
||||
ids = []
|
||||
try:
|
||||
if win32ts is None:
|
||||
return ids
|
||||
for s in win32ts.WTSEnumerateSessions(None, 1, 0):
|
||||
sid, _, state = s
|
||||
if state == win32ts.WTSActive:
|
||||
ids.append(sid)
|
||||
except Exception:
|
||||
pass
|
||||
return ids
|
||||
|
||||
|
||||
def launch_helper_in_session(session_id):
|
||||
try:
|
||||
if win32ts is None:
|
||||
return False
|
||||
_enable_privileges()
|
||||
hUser = win32ts.WTSQueryUserToken(session_id)
|
||||
primary = win32security.DuplicateTokenEx(
|
||||
hUser,
|
||||
win32con.MAXIMUM_ALLOWED,
|
||||
win32security.SECURITY_ATTRIBUTES(),
|
||||
win32security.SecurityImpersonation,
|
||||
win32con.TOKEN_PRIMARY,
|
||||
)
|
||||
env = win32profile.CreateEnvironmentBlock(primary, True)
|
||||
si = win32process.STARTUPINFO()
|
||||
si.lpDesktop = 'winsta0\\default'
|
||||
cmd = f'"{venv_pythonw()}" -W ignore::SyntaxWarning "{os.path.join(BOREALIS_DIR, "borealis-agent.py")}"'
|
||||
flags = getattr(win32con, 'CREATE_UNICODE_ENVIRONMENT', 0x00000400)
|
||||
win32process.CreateProcessAsUser(primary, None, cmd, None, None, False, flags, env, BOREALIS_DIR, si)
|
||||
log(f'Started user helper in session {session_id}')
|
||||
return True
|
||||
except Exception as e:
|
||||
log(f'Failed to start helper in session {session_id}: {e}')
|
||||
return False
|
||||
|
||||
|
||||
def manage_user_helpers_loop():
|
||||
known = set()
|
||||
while True:
|
||||
try:
|
||||
cur = set(active_sessions())
|
||||
for sid in cur:
|
||||
if sid not in known:
|
||||
launch_helper_in_session(sid)
|
||||
known = cur
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(3)
|
||||
|
||||
|
||||
def main():
|
||||
log('Supervisor starting')
|
||||
t = threading.Thread(target=manage_user_helpers_loop, daemon=True)
|
||||
t.start()
|
||||
while True:
|
||||
ensure_script_agent()
|
||||
time.sleep(5)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
95
Data/Agent/role_manager.py
Normal file
95
Data/Agent/role_manager.py
Normal file
@@ -0,0 +1,95 @@
|
||||
import os
|
||||
import importlib.util
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
|
||||
class RoleManager:
|
||||
"""
|
||||
Discovers and loads role modules from Data/Agent/Roles.
|
||||
Each role module should expose:
|
||||
- ROLE_NAME: str
|
||||
- ROLE_CONTEXTS: List[str] (e.g., ["interactive"], ["system"], or ["interactive","system"])
|
||||
- class Role(ctx): with methods:
|
||||
- register_events(): optional, bind socket events
|
||||
- on_config(roles: List[dict]): optional, apply server config
|
||||
- stop_all(): optional, cancel tasks/cleanup
|
||||
|
||||
The ctx passed to each Role is a simple object storing common references.
|
||||
"""
|
||||
|
||||
class Ctx:
|
||||
def __init__(self, sio, agent_id, config, loop, hooks: Optional[dict] = None):
|
||||
self.sio = sio
|
||||
self.agent_id = agent_id
|
||||
self.config = config
|
||||
self.loop = loop
|
||||
self.hooks = hooks or {}
|
||||
|
||||
def __init__(self, base_dir: str, context: str, sio, agent_id: str, config, loop, hooks: Optional[dict] = None):
|
||||
self.base_dir = base_dir
|
||||
self.context = context # "interactive" or "system"
|
||||
self.sio = sio
|
||||
self.agent_id = agent_id
|
||||
self.config = config
|
||||
self.loop = loop
|
||||
self.hooks = hooks or {}
|
||||
self.roles: Dict[str, object] = {}
|
||||
|
||||
def _iter_role_files(self) -> List[str]:
|
||||
roles_dir = os.path.join(self.base_dir, 'Roles')
|
||||
if not os.path.isdir(roles_dir):
|
||||
return []
|
||||
files = []
|
||||
for fn in os.listdir(roles_dir):
|
||||
if fn.lower().startswith('role_') and fn.lower().endswith('.py'):
|
||||
files.append(os.path.join(roles_dir, fn))
|
||||
return sorted(files)
|
||||
|
||||
def load(self):
|
||||
for path in self._iter_role_files():
|
||||
try:
|
||||
spec = importlib.util.spec_from_file_location(os.path.splitext(os.path.basename(path))[0], path)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
assert spec and spec.loader
|
||||
spec.loader.exec_module(mod)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
role_name = getattr(mod, 'ROLE_NAME', None)
|
||||
role_contexts = getattr(mod, 'ROLE_CONTEXTS', ['interactive', 'system'])
|
||||
RoleClass = getattr(mod, 'Role', None)
|
||||
|
||||
if not role_name or not RoleClass:
|
||||
continue
|
||||
if self.context not in (role_contexts or []):
|
||||
continue
|
||||
|
||||
try:
|
||||
ctx = RoleManager.Ctx(self.sio, self.agent_id, self.config, self.loop, hooks=self.hooks)
|
||||
role_obj = RoleClass(ctx)
|
||||
# Optional event registration
|
||||
if hasattr(role_obj, 'register_events'):
|
||||
try:
|
||||
role_obj.register_events()
|
||||
except Exception:
|
||||
pass
|
||||
self.roles[role_name] = role_obj
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
def on_config(self, roles_cfg: List[dict]):
|
||||
for role in list(self.roles.values()):
|
||||
try:
|
||||
if hasattr(role, 'on_config'):
|
||||
role.on_config(roles_cfg)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def stop_all(self):
|
||||
for role in list(self.roles.values()):
|
||||
try:
|
||||
if hasattr(role, 'stop_all'):
|
||||
role.stop_all()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -1,260 +0,0 @@
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import socket
|
||||
import asyncio
|
||||
import json
|
||||
import subprocess
|
||||
import tempfile
|
||||
from typing import Optional
|
||||
|
||||
import socketio
|
||||
import platform
|
||||
import time
|
||||
import uuid
|
||||
import tempfile
|
||||
import contextlib
|
||||
|
||||
|
||||
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:
|
||||
flags = 0x08000000 if os.name == 'nt' else 0 # CREATE_NO_WINDOW
|
||||
proc = subprocess.run(
|
||||
[ps, "-ExecutionPolicy", "Bypass", "-NoProfile", "-File", path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60*60,
|
||||
creationflags=flags,
|
||||
)
|
||||
return proc.returncode, proc.stdout or "", proc.stderr or ""
|
||||
except Exception as e:
|
||||
return -1, "", str(e)
|
||||
finally:
|
||||
# Best-effort cleanup of the ephemeral script
|
||||
try:
|
||||
if os.path.isfile(path):
|
||||
os.remove(path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
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
|
||||
run_mode = (payload.get('run_mode') or 'current_user').lower()
|
||||
# Only the SYSTEM service handles system-mode jobs; ignore others
|
||||
if run_mode != 'system':
|
||||
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
|
||||
# Preferred: run via ephemeral scheduled task under SYSTEM for isolation
|
||||
rc, out, err = run_powershell_via_system_task(content)
|
||||
if rc == -999:
|
||||
# Fallback to direct execution if task creation not available
|
||||
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")
|
||||
|
||||
async def heartbeat_loop():
|
||||
# Minimal heartbeat so device appears online even without a user helper
|
||||
while True:
|
||||
try:
|
||||
await sio.emit("agent_heartbeat", {
|
||||
"agent_id": f"{hostname}-script",
|
||||
"hostname": hostname,
|
||||
"agent_operating_system": f"{platform.system()} {platform.release()} (Service)",
|
||||
"last_seen": int(time.time())
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
await asyncio.sleep(30)
|
||||
|
||||
url = get_server_url()
|
||||
while True:
|
||||
try:
|
||||
await sio.connect(url, transports=['websocket'])
|
||||
# Heartbeat while connected
|
||||
hb = asyncio.create_task(heartbeat_loop())
|
||||
try:
|
||||
await sio.wait()
|
||||
finally:
|
||||
try:
|
||||
hb.cancel()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f"[ScriptAgent] reconnect in 5s: {e}")
|
||||
await asyncio.sleep(5)
|
||||
|
||||
|
||||
def run_powershell_via_system_task(content: str):
|
||||
"""Create an ephemeral scheduled task under SYSTEM to run the script.
|
||||
Returns (rc, stdout, stderr). If the environment lacks PowerShell ScheduledTasks module, returns (-999, '', 'unavailable').
|
||||
"""
|
||||
ps_exe = os.path.expandvars(r"%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe")
|
||||
if not os.path.isfile(ps_exe):
|
||||
ps_exe = 'powershell.exe'
|
||||
try:
|
||||
os.makedirs(os.path.join(get_project_root(), 'Temp'), exist_ok=True)
|
||||
# Write the target script
|
||||
script_fd, script_path = tempfile.mkstemp(prefix='sys_task_', suffix='.ps1', dir=os.path.join(get_project_root(), 'Temp'), text=True)
|
||||
with os.fdopen(script_fd, 'w', encoding='utf-8', newline='\n') as f:
|
||||
f.write(content or '')
|
||||
# Output capture path
|
||||
out_path = os.path.join(get_project_root(), 'Temp', f'out_{uuid.uuid4().hex}.txt')
|
||||
task_name = f"Borealis Agent - Task - {uuid.uuid4().hex} @ SYSTEM"
|
||||
# Build PS to create/run task with DeleteExpiredTaskAfter
|
||||
task_ps = f"""
|
||||
$ErrorActionPreference='Continue'
|
||||
$task = "{task_name}"
|
||||
$ps = "{ps_exe}"
|
||||
$scr = "{script_path}"
|
||||
$out = "{out_path}"
|
||||
try {{ Unregister-ScheduledTask -TaskName $task -Confirm:$false -ErrorAction SilentlyContinue }} catch {{}}
|
||||
$action = New-ScheduledTaskAction -Execute $ps -Argument ('-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File "' + $scr + '" *> "' + $out + '"')
|
||||
$settings = New-ScheduledTaskSettingsSet -DeleteExpiredTaskAfter (New-TimeSpan -Minutes 5) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
|
||||
$principal= New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest
|
||||
Register-ScheduledTask -TaskName $task -Action $action -Settings $settings -Principal $principal -Force | Out-Null
|
||||
Start-ScheduledTask -TaskName $task | Out-Null
|
||||
Start-Sleep -Seconds 2
|
||||
Get-ScheduledTask -TaskName $task | Out-Null
|
||||
"""
|
||||
# Run task creation
|
||||
proc = subprocess.run([ps_exe, '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', task_ps], capture_output=True, text=True)
|
||||
if proc.returncode != 0:
|
||||
return -999, '', (proc.stderr or proc.stdout or 'scheduled task creation failed')
|
||||
# Wait up to 60s for output to be written
|
||||
deadline = time.time() + 60
|
||||
out_data = ''
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
if os.path.isfile(out_path) and os.path.getsize(out_path) > 0:
|
||||
with open(out_path, 'r', encoding='utf-8', errors='replace') as f:
|
||||
out_data = f.read()
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(1)
|
||||
# Cleanup task (best-effort)
|
||||
cleanup_ps = f"try {{ Unregister-ScheduledTask -TaskName '{task_name}' -Confirm:$false }} catch {{}}"
|
||||
subprocess.run([ps_exe, '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', cleanup_ps], capture_output=True, text=True)
|
||||
# Best-effort removal of temp script and output files
|
||||
try:
|
||||
if os.path.isfile(script_path):
|
||||
os.remove(script_path)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if os.path.isfile(out_path):
|
||||
os.remove(out_path)
|
||||
except Exception:
|
||||
pass
|
||||
return 0, out_data or '', ''
|
||||
except Exception as e:
|
||||
return -999, '', str(e)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Ensure only a single instance of the script agent runs (Windows-only lock)
|
||||
def _acquire_singleton_lock() -> bool:
|
||||
try:
|
||||
lock_dir = os.path.join(get_project_root(), 'Logs', 'Agent')
|
||||
os.makedirs(lock_dir, exist_ok=True)
|
||||
lock_path = os.path.join(lock_dir, 'script_agent.lock')
|
||||
# Keep handle open for process lifetime
|
||||
fh = open(lock_path, 'a')
|
||||
try:
|
||||
import msvcrt # type: ignore
|
||||
# Lock 1 byte non-blocking; released on handle close/process exit
|
||||
msvcrt.locking(fh.fileno(), msvcrt.LK_NBLCK, 1)
|
||||
globals()['_LOCK_FH'] = fh
|
||||
return True
|
||||
except Exception:
|
||||
try:
|
||||
fh.close()
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
except Exception:
|
||||
# If we cannot establish a lock, continue (do not prevent agent)
|
||||
return True
|
||||
|
||||
if not _acquire_singleton_lock():
|
||||
print('[ScriptAgent] Another instance is running; exiting.')
|
||||
sys.exit(0)
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -1,117 +0,0 @@
|
||||
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.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_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)
|
||||
|
||||
# Service controls removed in task-centric architecture
|
||||
|
||||
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())
|
||||
Reference in New Issue
Block a user