diff --git a/Data/Agent/Roles/Borealis.WinRM.Localhost.psm1 b/Data/Agent/Roles/Borealis.WinRM.Localhost.psm1 new file mode 100644 index 0000000..4e29833 --- /dev/null +++ b/Data/Agent/Roles/Borealis.WinRM.Localhost.psm1 @@ -0,0 +1,99 @@ +function Ensure-LocalhostWinRMHttps { + [CmdletBinding()] + param( + [string]$DnsName = $env:COMPUTERNAME + ) + + try { + Set-Service WinRM -StartupType Automatic -ErrorAction Stop + Start-Service WinRM -ErrorAction Stop + } catch {} + + # Find or create a cert + try { + $cert = Get-ChildItem Cert:\LocalMachine\My | + Where-Object { $_.Subject -match "CN=$DnsName" -and $_.NotAfter -gt (Get-Date).AddMonths(1) } | + Sort-Object NotAfter -Descending | Select-Object -First 1 + } catch { $cert = $null } + if (-not $cert) { + try { + $cert = New-SelfSignedCertificate -DnsName $DnsName -CertStoreLocation Cert:\LocalMachine\My -KeyLength 2048 -HashAlgorithm SHA256 -NotAfter (Get-Date).AddYears(3) + } catch { $cert = $null } + } + $thumb = if ($cert) { $cert.Thumbprint } else { '' } + + # Create listener only if not present + try { + $listener = Get-WSManInstance -ResourceURI winrm/config/listener -Enumerate -ErrorAction SilentlyContinue | + Where-Object { $_.Transport -eq 'HTTPS' -and $_.Address -eq '127.0.0.1' -and $_.Port -eq '5986' } + } catch { $listener = $null } + if (-not $listener -and $thumb) { + $cmd = "winrm create winrm/config/Listener?Address=127.0.0.1+Transport=HTTPS @{Hostname=`"$DnsName`"; CertificateThumbprint=`"$thumb`"; Port=`"5986`"}" + cmd /c $cmd | Out-Null + } + + # Harden auth and encryption + try { winrm set winrm/config/service/auth @{Basic="false"; Kerberos="true"; Negotiate="true"; CredSSP="false"} | Out-Null } catch {} + try { winrm set winrm/config/service @{AllowUnencrypted="false"} | Out-Null } catch {} +} + +function Ensure-BorealisServiceUser { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$UserName, + [Parameter(Mandatory)][string]$PlaintextPassword + ) + $localName = $UserName -replace '^\.\\','' + $secure = ConvertTo-SecureString $PlaintextPassword -AsPlainText -Force + $u = Get-LocalUser -Name $localName -ErrorAction SilentlyContinue + if (-not $u) { + try { + New-LocalUser -Name $localName -Password $secure -PasswordNeverExpires:$true -AccountNeverExpires:$true | Out-Null + } catch {} + } else { + try { Set-LocalUser -Name $localName -Password $secure } catch {} + try { Enable-LocalUser -Name $localName } catch {} + } + try { + $admins = Get-LocalGroupMember -Group "Administrators" -ErrorAction SilentlyContinue + if (-not ($admins | Where-Object { $_.Name -match "\\$localName$" })) { + Add-LocalGroupMember -Group "Administrators" -Member $localName -ErrorAction SilentlyContinue + } + } catch {} +} + +function Write-LocalInventory { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)][string]$UserName, + [Parameter(Mandatory)][string]$Password, + [ValidateSet('ntlm','negotiate','basic')][string]$Transport = 'ntlm' + ) +@" +[local] +localhost + +[local:vars] +ansible_connection=winrm +ansible_host=127.0.0.1 +ansible_port=5986 +ansible_winrm_scheme=https +ansible_winrm_transport=$Transport +ansible_user=$UserName +ansible_password=$Password +ansible_winrm_server_cert_validation=ignore +"@ | Set-Content -Path $Path -Encoding UTF8 + + # Lock down ACL to SYSTEM when running as SYSTEM + try { + $acl = New-Object System.Security.AccessControl.FileSecurity + $sid = New-Object System.Security.Principal.SecurityIdentifier("S-1-5-18") + $rule = New-Object System.Security.AccessControl.FileSystemAccessRule($sid,"FullControl","ContainerInherit,ObjectInherit","None","Allow") + $acl.SetOwner($sid); $acl.SetAccessRuleProtection($true,$false); $acl.AddAccessRule($rule) + Set-Acl -Path $Path -AclObject $acl + } catch {} +} + +Export-ModuleMember -Function Ensure-LocalhostWinRMHttps,Ensure-BorealisServiceUser,Write-LocalInventory + diff --git a/Data/Agent/Roles/role_PlaybookExec_SYSTEM.py b/Data/Agent/Roles/role_PlaybookExec_SYSTEM.py index 5d141b4..8cd6ce5 100644 --- a/Data/Agent/Roles/role_PlaybookExec_SYSTEM.py +++ b/Data/Agent/Roles/role_PlaybookExec_SYSTEM.py @@ -8,6 +8,11 @@ import json import socket import subprocess +try: + import winrm # type: ignore +except Exception: + winrm = None + ROLE_NAME = 'playbook_exec_system' ROLE_CONTEXTS = ['system'] @@ -85,6 +90,7 @@ class Role: def __init__(self, ctx): self.ctx = ctx self._runs = {} # run_id -> { proc, task, cancel } + self._svc_creds = None # cache per-process: {username, password} def _log_local(self, msg: str, error: bool = False): try: @@ -106,6 +112,131 @@ class Role: pass return 'http://localhost:5000' + async def _fetch_service_creds(self) -> dict: + if self._svc_creds and isinstance(self._svc_creds, dict): + return self._svc_creds + try: + import aiohttp + url = self._server_base().rstrip('/') + '/api/agent/checkin' + payload = { + 'agent_id': self.ctx.agent_id, + 'hostname': socket.gethostname(), + 'username': '.\\svcBorealisAnsibleRunner', + } + timeout = aiohttp.ClientTimeout(total=15) + async with aiohttp.ClientSession(timeout=timeout) as sess: + async with sess.post(url, json=payload) as resp: + js = await resp.json() + u = (js or {}).get('username') or '.\\svcBorealisAnsibleRunner' + p = (js or {}).get('password') or '' + self._svc_creds = {'username': u, 'password': p} + return self._svc_creds + except Exception: + return {'username': '.\\svcBorealisAnsibleRunner', 'password': ''} + + def _normalize_playbook_content(self, content: str) -> str: + try: + # Heuristic fixes to honor our WinRM localhost inventory: + # - Replace hosts: localhost -> hosts: local (group name used by inventory) + # - Remove explicit "connection: local" if present + lines = (content or '').splitlines() + out = [] + for ln in lines: + s = ln.strip().lower() + if s.startswith('connection:') and 'local' in s: + continue + if s.startswith('hosts:') and ('localhost' in s or '127.0.0.1' in s): + indent = ln.split('h')[0] + out.append(f"{indent}hosts: local") + continue + out.append(ln) + return '\n'.join(out) + ('\n' if not content.endswith('\n') else '') + except Exception: + return content + + async def _rotate_service_creds(self) -> dict: + try: + import aiohttp + url = self._server_base().rstrip('/') + '/api/agent/service-account/rotate' + payload = { + 'agent_id': self.ctx.agent_id, + 'reason': 'bad_credentials', + } + timeout = aiohttp.ClientTimeout(total=15) + async with aiohttp.ClientSession(timeout=timeout) as sess: + async with sess.post(url, json=payload) as resp: + js = await resp.json() + u = (js or {}).get('username') or '.\\svcBorealisAnsibleRunner' + p = (js or {}).get('password') or '' + self._svc_creds = {'username': u, 'password': p} + return self._svc_creds + except Exception: + return await self._fetch_service_creds() + + def _ps_module_path(self) -> str: + # Place PS module under Roles so it's deployed with the agent + try: + here = os.path.abspath(os.path.dirname(__file__)) + p = os.path.join(here, 'Borealis.WinRM.Localhost.psm1') + return p + except Exception: + return '' + + def _ensure_winrm_and_user(self, username: str, password: str): + if os.name != 'nt': + return + mod = self._ps_module_path() + if not os.path.isfile(mod): + # best effort with inline commands + try: + subprocess.run(['powershell', '-NoProfile', '-Command', 'Set-Service WinRM -StartupType Automatic; Start-Service WinRM'], timeout=30) + except Exception: + pass + return + ps = f""" +Import-Module -Name '{mod}' -Force +Ensure-LocalhostWinRMHttps +Ensure-BorealisServiceUser -UserName '{username}' -PlaintextPassword '{password}' +""" + try: + subprocess.run(['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', ps], timeout=90) + except Exception: + pass + + def _write_winrm_inventory(self, base_dir: str, username: str, password: str) -> str: + inv_dir = os.path.join(base_dir, 'inventory') + os.makedirs(inv_dir, exist_ok=True) + hosts = os.path.join(inv_dir, 'hosts') + try: + content = ( + "[local]\n" \ + "localhost\n\n" \ + "[local:vars]\n" \ + "ansible_connection=winrm\n" \ + "ansible_host=127.0.0.1\n" \ + "ansible_port=5986\n" \ + "ansible_winrm_scheme=https\n" \ + "ansible_winrm_transport=ntlm\n" \ + f"ansible_user={username}\n" \ + f"ansible_password={password}\n" \ + "ansible_winrm_server_cert_validation=ignore\n" + ) + with open(hosts, 'w', encoding='utf-8', newline='\n') as fh: + fh.write(content) + except Exception: + pass + return hosts + + def _winrm_preflight(self, username: str, password: str) -> bool: + if os.name != 'nt' or winrm is None: + return True + try: + s = winrm.Session('https://127.0.0.1:5986', auth=(username, password), transport='ntlm', server_cert_validation='ignore') + r = s.run_cmd('whoami') + return r.status_code == 0 + except Exception: + return False + async def _post_recap(self, payload: dict): try: import aiohttp @@ -138,9 +269,24 @@ class Role: play_rel = 'playbook.yml' play_abs = os.path.join(project, play_rel) with open(play_abs, 'w', encoding='utf-8', newline='\n') as fh: - fh.write(playbook_content or '') - with open(os.path.join(inventory_dir, 'hosts'), 'w', encoding='utf-8', newline='\n') as fh: - fh.write('localhost,\n') + fh.write(self._normalize_playbook_content(playbook_content or '')) + # WinRM service account credentials + creds = await self._fetch_service_creds() + user = creds.get('username') or '.\\svcBorealisAnsibleRunner' + pwd = creds.get('password') or '' + # Converge endpoint state (listener + user) + self._ensure_winrm_and_user(user, pwd) + # Preflight auth and auto-rotate if needed + pre_ok = self._winrm_preflight(user, pwd) + if not pre_ok: + # rotate and retry once + creds = await self._rotate_service_creds() + user = creds.get('username') or user + pwd = creds.get('password') or '' + self._ensure_winrm_and_user(user, pwd) + # Write inventory for winrm localhost + inv_file = self._write_winrm_inventory(pd, user, pwd) + # Set connection via envvars with open(os.path.join(env_dir, 'envvars'), 'w', encoding='utf-8', newline='\n') as fh: json.dump({ 'ANSIBLE_FORCE_COLOR': '0', 'ANSIBLE_STDOUT_CALLBACK': 'default' }, fh) @@ -188,17 +334,25 @@ class Role: except Exception: return False + auth_failed = False try: - ansible_runner.interface.run( + r = ansible_runner.interface.run( private_data_dir=pd, playbook=play_rel, - inventory=os.path.join(inventory_dir, 'hosts'), + inventory=inv_file, quiet=True, event_handler=_on_event, cancel_callback=_cancel_cb, extravars={} ) status = 'Cancelled' if _cancel_cb() else 'Success' + try: + # Some auth failures bubble up in events only; inspect last few lines + tail = '\n'.join(lines[-50:]).lower() + if ('access is denied' in tail) or ('unauthorized' in tail) or ('cannot process the request' in tail): + auth_failed = True + except Exception: + pass except Exception: status = 'Failed' @@ -231,6 +385,18 @@ class Role: 'recap_json': recap_json, 'finished_ts': int(time.time()), }) + # If authentication failed on first pass, rotate password and try once more + if auth_failed: + try: + newc = await self._rotate_service_creds() + user2 = newc.get('username') or user + pwd2 = newc.get('password') or '' + self._ensure_winrm_and_user(user2, pwd2) + # Recurse once with updated creds + await self._run_playbook_runner(run_id, playbook_content, playbook_name=playbook_name, activity_job_id=activity_job_id, connection=connection) + return True + except Exception: + pass return True async def _run_playbook(self, run_id: str, playbook_content: str, playbook_name: str = '', activity_job_id=None, connection: str = 'local'): @@ -239,7 +405,7 @@ class Role: os.makedirs(tmp_dir, exist_ok=True) fd, path = tempfile.mkstemp(prefix='pb_', suffix='.yml', dir=tmp_dir, text=True) with os.fdopen(fd, 'w', encoding='utf-8', newline='\n') as fh: - fh.write(playbook_content or '') + fh.write(self._normalize_playbook_content(playbook_content or '')) hostname = socket.gethostname() agent_id = self.ctx.agent_id @@ -256,19 +422,33 @@ class Role: 'started_ts': started, }) + # Prefer WinRM localhost via inventory when on Windows; otherwise fallback to provided connection + inv_file_cli = None conn = (connection or 'local').strip().lower() - if conn not in ('local', 'winrm', 'psrp'): - conn = 'local' - # Best-effort: if playbook uses ansible.windows, prefer psrp when connection left as local - if conn == 'local': + if os.name == 'nt': try: - if 'ansible.windows' in (playbook_content or ''): - conn = 'psrp' + creds = await self._fetch_service_creds() + user = creds.get('username') or '.\\svcBorealisAnsibleRunner' + pwd = creds.get('password') or '' + self._ensure_winrm_and_user(user, pwd) + if not self._winrm_preflight(user, pwd): + creds = await self._rotate_service_creds() + user = creds.get('username') or user + pwd = creds.get('password') or '' + self._ensure_winrm_and_user(user, pwd) + # Create temp inventory adjacent to playbook + inv_file_cli = self._write_winrm_inventory(os.path.dirname(path), user, pwd) except Exception: - pass - - cmd = [_ansible_playbook_cmd(), path, '-i', 'localhost,', '-c', conn] - self._log_local(f"Launching ansible-playbook: conn={conn} cmd={' '.join(cmd)}") + inv_file_cli = None + # Build CLI; if inv_file_cli present, omit -c and use '-i invfile' + if inv_file_cli and os.path.isfile(inv_file_cli): + cmd = [_ansible_playbook_cmd(), path, '-i', inv_file_cli] + self._log_local(f"Launching ansible-playbook with WinRM inventory: {' '.join(cmd)}") + else: + if conn not in ('local', 'winrm', 'psrp'): + conn = 'local' + cmd = [_ansible_playbook_cmd(), path, '-i', 'localhost,', '-c', conn] + self._log_local(f"Launching ansible-playbook: conn={conn} cmd={' '.join(cmd)}") # Ensure clean, plain output and correct interpreter for localhost env = os.environ.copy() env.setdefault('ANSIBLE_FORCE_COLOR', '0') @@ -301,9 +481,9 @@ class Role: except Exception: self._log_local("Collection install failed (continuing)") - # Prefer ansible-runner when available and enabled + # Prefer ansible-runner when available (default on). Set BOREALIS_USE_ANSIBLE_RUNNER=0 to disable. try: - if os.environ.get('BOREALIS_USE_ANSIBLE_RUNNER', '0').lower() not in ('0', 'false', 'no'): + if os.environ.get('BOREALIS_USE_ANSIBLE_RUNNER', '1').lower() not in ('0', 'false', 'no'): used = await self._run_playbook_runner(run_id, playbook_content, playbook_name=playbook_name, activity_job_id=activity_job_id, connection=connection) if used: return diff --git a/Data/Agent/agent.py b/Data/Agent/agent.py index 4abd3db..3226a04 100644 --- a/Data/Agent/agent.py +++ b/Data/Agent/agent.py @@ -1142,6 +1142,20 @@ async def connect(): await sio.emit('request_config', {"agent_id": AGENT_ID}) # Inventory details posting is managed by the DeviceAudit role (SYSTEM). No one-shot post here. + # Fire-and-forget service account check-in so server can provision WinRM credentials + try: + async def _svc_checkin_once(): + try: + url = get_server_url().rstrip('/') + "/api/agent/checkin" + payload = {"agent_id": AGENT_ID, "hostname": socket.gethostname(), "username": ".\\svcBorealisAnsibleRunner"} + timeout = aiohttp.ClientTimeout(total=10) + async with aiohttp.ClientSession(timeout=timeout) as session: + await session.post(url, json=payload) + except Exception: + pass + asyncio.create_task(_svc_checkin_once()) + except Exception: + pass @sio.event async def disconnect(): diff --git a/Data/Server/job_scheduler.py b/Data/Server/job_scheduler.py index eaf9a6c..1518687 100644 --- a/Data/Server/job_scheduler.py +++ b/Data/Server/job_scheduler.py @@ -216,7 +216,7 @@ class JobScheduler: "activity_job_id": act_id, "scheduled_job_id": int(scheduled_job_id), "scheduled_run_id": int(scheduled_run_id), - "connection": "local", + "connection": "winrm", } try: self.socketio.emit("ansible_playbook_run", payload) diff --git a/Data/Server/server-requirements.txt b/Data/Server/server-requirements.txt index a27f6d7..5e3dcc7 100644 --- a/Data/Server/server-requirements.txt +++ b/Data/Server/server-requirements.txt @@ -10,6 +10,7 @@ requests flask_socketio flask-cors eventlet +cryptography # GUI-related dependencies (Qt for GUI components) Qt.py @@ -26,4 +27,4 @@ Pillow # Image processing (Windows) # WebRTC Video Libraries ###aiortc # Python library for WebRTC in async environments -###av # Required by aiortc for video/audio codecs \ No newline at end of file +###av # Required by aiortc for video/audio codecs diff --git a/qj_old.txt b/qj_old.txt deleted file mode 100644 index b8f8160..0000000 --- a/qj_old.txt +++ /dev/null @@ -1,226 +0,0 @@ -import React, { useEffect, useMemo, useState, useCallback } from "react"; -import { - Dialog, - DialogTitle, - DialogContent, - DialogActions, - Button, - Box, - Typography, - Paper, - FormControlLabel, - Checkbox -} from "@mui/material"; -import { Folder as FolderIcon, Description as DescriptionIcon } from "@mui/icons-material"; -import { SimpleTreeView, TreeItem } from "@mui/x-tree-view"; - -function buildTree(items, folders, rootLabel = "Scripts") { - const map = {}; - const rootNode = { - id: "root", - label: rootLabel, - path: "", - isFolder: true, - children: [] - }; - map[rootNode.id] = rootNode; - - (folders || []).forEach((f) => { - const parts = (f || "").split("/"); - let children = rootNode.children; - let parentPath = ""; - parts.forEach((part) => { - const path = parentPath ? `${parentPath}/${part}` : part; - let node = children.find((n) => n.id === path); - if (!node) { - node = { id: path, label: part, path, isFolder: true, children: [] }; - children.push(node); - map[path] = node; - } - children = node.children; - parentPath = path; - }); - }); - - (items || []).forEach((s) => { - const parts = (s.rel_path || "").split("/"); - let children = rootNode.children; - let parentPath = ""; - parts.forEach((part, idx) => { - const path = parentPath ? `${parentPath}/${part}` : part; - const isFile = idx === parts.length - 1; - let node = children.find((n) => n.id === path); - if (!node) { - node = { - id: path, - label: isFile ? s.file_name : part, - path, - isFolder: !isFile, - fileName: s.file_name, - script: isFile ? s : null, - children: [] - }; - children.push(node); - map[path] = node; - } - if (!isFile) { - children = node.children; - parentPath = path; - } - }); - }); - - return { root: [rootNode], map }; -} - -export default function QuickJob({ open, onClose, hostnames = [] }) { - const [tree, setTree] = useState([]); - const [nodeMap, setNodeMap] = useState({}); - const [selectedPath, setSelectedPath] = useState(""); - const [running, setRunning] = useState(false); - const [error, setError] = useState(""); - const [runAsCurrentUser, setRunAsCurrentUser] = useState(false); - const [mode, setMode] = useState("scripts"); // 'scripts' | 'ansible' - - const loadTree = useCallback(async () => { - try { - const island = mode === 'ansible' ? 'ansible' : 'scripts'; - const resp = await fetch(`/api/assembly/list?island=${island}`); - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); - const data = await resp.json(); - const { root, map } = buildTree(data.items || [], data.folders || [], mode === 'ansible' ? 'Ansible Playbooks' : 'Scripts'); - setTree(root); - setNodeMap(map); - } catch (err) { - console.error("Failed to load scripts:", err); - setTree([]); - setNodeMap({}); - } - }, [mode]); - - useEffect(() => { - if (open) { - setSelectedPath(""); - setError(""); - loadTree(); - } - }, [open, loadTree]); - - const renderNodes = (nodes = []) => - nodes.map((n) => ( - - {n.isFolder ? ( - - ) : ( - - )} - {n.label} - - } - > - {n.children && n.children.length ? renderNodes(n.children) : null} - - )); - - const onItemSelect = (_e, itemId) => { - const node = nodeMap[itemId]; - if (node && !node.isFolder) { - setSelectedPath(node.path); - setError(""); - } - }; - - const onRun = async () => { - if (!selectedPath) { - setError(mode === 'ansible' ? "Please choose a playbook to run." : "Please choose a script to run."); - return; - } - setRunning(true); - setError(""); - try { - let resp; - if (mode === 'ansible') { - const playbook_path = selectedPath; // relative to ansible island - resp = await fetch("/api/ansible/quick_run", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ playbook_path, hostnames }) - }); - } else { - // quick_run expects a path relative to Assemblies root with 'Scripts/' prefix - const script_path = selectedPath.startsWith('Scripts/') ? selectedPath : `Scripts/${selectedPath}`; - resp = await fetch("/api/scripts/quick_run", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ script_path, hostnames, run_mode: runAsCurrentUser ? "current_user" : "system" }) - }); - } - const data = await resp.json(); - if (!resp.ok) throw new Error(data.error || `HTTP ${resp.status}`); - onClose && onClose(); - } catch (err) { - setError(String(err.message || err)); - } finally { - setRunning(false); - } - }; - - return ( - - Quick Job - - - - - - - Select a {mode === 'ansible' ? 'playbook' : 'script'} to run on {hostnames.length} device{hostnames.length !== 1 ? "s" : ""}. - - - - - {tree.length ? renderNodes(tree) : ( - - {mode === 'ansible' ? 'No playbooks found.' : 'No scripts found.'} - - )} - - - - Selection - - {selectedPath || (mode === 'ansible' ? 'No playbook selected' : 'No script selected')} - - - setRunAsCurrentUser(e.target.checked)} />} - label={Run as currently logged-in user} - /> - - Unchecked = Run-As BUILTIN\SYSTEM - - - {error && ( - {error} - )} - - - - - - - - - ); -} - diff --git a/tmp_parse.py b/tmp_parse.py deleted file mode 100644 index 88d0a26..0000000 --- a/tmp_parse.py +++ /dev/null @@ -1,4 +0,0 @@ -import ast, sys -with open('Data/Server/server.py','r',encoding='utf-8') as f: - ast.parse(f.read()) -print('OK')