mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-10-27 01:41:58 -06:00
Misc Adjustments
This commit is contained in:
99
Data/Agent/Roles/Borealis.WinRM.Localhost.psm1
Normal file
99
Data/Agent/Roles/Borealis.WinRM.Localhost.psm1
Normal file
@@ -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
|
||||||
|
|
||||||
@@ -8,6 +8,11 @@ import json
|
|||||||
import socket
|
import socket
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
|
try:
|
||||||
|
import winrm # type: ignore
|
||||||
|
except Exception:
|
||||||
|
winrm = None
|
||||||
|
|
||||||
|
|
||||||
ROLE_NAME = 'playbook_exec_system'
|
ROLE_NAME = 'playbook_exec_system'
|
||||||
ROLE_CONTEXTS = ['system']
|
ROLE_CONTEXTS = ['system']
|
||||||
@@ -85,6 +90,7 @@ class Role:
|
|||||||
def __init__(self, ctx):
|
def __init__(self, ctx):
|
||||||
self.ctx = ctx
|
self.ctx = ctx
|
||||||
self._runs = {} # run_id -> { proc, task, cancel }
|
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):
|
def _log_local(self, msg: str, error: bool = False):
|
||||||
try:
|
try:
|
||||||
@@ -106,6 +112,131 @@ class Role:
|
|||||||
pass
|
pass
|
||||||
return 'http://localhost:5000'
|
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):
|
async def _post_recap(self, payload: dict):
|
||||||
try:
|
try:
|
||||||
import aiohttp
|
import aiohttp
|
||||||
@@ -138,9 +269,24 @@ class Role:
|
|||||||
play_rel = 'playbook.yml'
|
play_rel = 'playbook.yml'
|
||||||
play_abs = os.path.join(project, play_rel)
|
play_abs = os.path.join(project, play_rel)
|
||||||
with open(play_abs, 'w', encoding='utf-8', newline='\n') as fh:
|
with open(play_abs, 'w', encoding='utf-8', newline='\n') as fh:
|
||||||
fh.write(playbook_content or '')
|
fh.write(self._normalize_playbook_content(playbook_content or ''))
|
||||||
with open(os.path.join(inventory_dir, 'hosts'), 'w', encoding='utf-8', newline='\n') as fh:
|
# WinRM service account credentials
|
||||||
fh.write('localhost,\n')
|
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
|
# Set connection via envvars
|
||||||
with open(os.path.join(env_dir, 'envvars'), 'w', encoding='utf-8', newline='\n') as fh:
|
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)
|
json.dump({ 'ANSIBLE_FORCE_COLOR': '0', 'ANSIBLE_STDOUT_CALLBACK': 'default' }, fh)
|
||||||
@@ -188,17 +334,25 @@ class Role:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
auth_failed = False
|
||||||
try:
|
try:
|
||||||
ansible_runner.interface.run(
|
r = ansible_runner.interface.run(
|
||||||
private_data_dir=pd,
|
private_data_dir=pd,
|
||||||
playbook=play_rel,
|
playbook=play_rel,
|
||||||
inventory=os.path.join(inventory_dir, 'hosts'),
|
inventory=inv_file,
|
||||||
quiet=True,
|
quiet=True,
|
||||||
event_handler=_on_event,
|
event_handler=_on_event,
|
||||||
cancel_callback=_cancel_cb,
|
cancel_callback=_cancel_cb,
|
||||||
extravars={}
|
extravars={}
|
||||||
)
|
)
|
||||||
status = 'Cancelled' if _cancel_cb() else 'Success'
|
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:
|
except Exception:
|
||||||
status = 'Failed'
|
status = 'Failed'
|
||||||
|
|
||||||
@@ -231,6 +385,18 @@ class Role:
|
|||||||
'recap_json': recap_json,
|
'recap_json': recap_json,
|
||||||
'finished_ts': int(time.time()),
|
'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
|
return True
|
||||||
|
|
||||||
async def _run_playbook(self, run_id: str, playbook_content: str, playbook_name: str = '', activity_job_id=None, connection: str = 'local'):
|
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)
|
os.makedirs(tmp_dir, exist_ok=True)
|
||||||
fd, path = tempfile.mkstemp(prefix='pb_', suffix='.yml', dir=tmp_dir, text=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:
|
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()
|
hostname = socket.gethostname()
|
||||||
agent_id = self.ctx.agent_id
|
agent_id = self.ctx.agent_id
|
||||||
@@ -256,17 +422,31 @@ class Role:
|
|||||||
'started_ts': started,
|
'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()
|
conn = (connection or 'local').strip().lower()
|
||||||
|
if os.name == 'nt':
|
||||||
|
try:
|
||||||
|
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:
|
||||||
|
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'):
|
if conn not in ('local', 'winrm', 'psrp'):
|
||||||
conn = 'local'
|
conn = 'local'
|
||||||
# Best-effort: if playbook uses ansible.windows, prefer psrp when connection left as local
|
|
||||||
if conn == 'local':
|
|
||||||
try:
|
|
||||||
if 'ansible.windows' in (playbook_content or ''):
|
|
||||||
conn = 'psrp'
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
cmd = [_ansible_playbook_cmd(), path, '-i', 'localhost,', '-c', conn]
|
cmd = [_ansible_playbook_cmd(), path, '-i', 'localhost,', '-c', conn]
|
||||||
self._log_local(f"Launching ansible-playbook: conn={conn} cmd={' '.join(cmd)}")
|
self._log_local(f"Launching ansible-playbook: conn={conn} cmd={' '.join(cmd)}")
|
||||||
# Ensure clean, plain output and correct interpreter for localhost
|
# Ensure clean, plain output and correct interpreter for localhost
|
||||||
@@ -301,9 +481,9 @@ class Role:
|
|||||||
except Exception:
|
except Exception:
|
||||||
self._log_local("Collection install failed (continuing)")
|
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:
|
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)
|
used = await self._run_playbook_runner(run_id, playbook_content, playbook_name=playbook_name, activity_job_id=activity_job_id, connection=connection)
|
||||||
if used:
|
if used:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1142,6 +1142,20 @@ async def connect():
|
|||||||
|
|
||||||
await sio.emit('request_config', {"agent_id": AGENT_ID})
|
await sio.emit('request_config', {"agent_id": AGENT_ID})
|
||||||
# Inventory details posting is managed by the DeviceAudit role (SYSTEM). No one-shot post here.
|
# 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
|
@sio.event
|
||||||
async def disconnect():
|
async def disconnect():
|
||||||
|
|||||||
@@ -216,7 +216,7 @@ class JobScheduler:
|
|||||||
"activity_job_id": act_id,
|
"activity_job_id": act_id,
|
||||||
"scheduled_job_id": int(scheduled_job_id),
|
"scheduled_job_id": int(scheduled_job_id),
|
||||||
"scheduled_run_id": int(scheduled_run_id),
|
"scheduled_run_id": int(scheduled_run_id),
|
||||||
"connection": "local",
|
"connection": "winrm",
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
self.socketio.emit("ansible_playbook_run", payload)
|
self.socketio.emit("ansible_playbook_run", payload)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ requests
|
|||||||
flask_socketio
|
flask_socketio
|
||||||
flask-cors
|
flask-cors
|
||||||
eventlet
|
eventlet
|
||||||
|
cryptography
|
||||||
|
|
||||||
# GUI-related dependencies (Qt for GUI components)
|
# GUI-related dependencies (Qt for GUI components)
|
||||||
Qt.py
|
Qt.py
|
||||||
|
|||||||
226
qj_old.txt
226
qj_old.txt
@@ -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) => (
|
|
||||||
<TreeItem
|
|
||||||
key={n.id}
|
|
||||||
itemId={n.id}
|
|
||||||
label={
|
|
||||||
<Box sx={{ display: "flex", alignItems: "center" }}>
|
|
||||||
{n.isFolder ? (
|
|
||||||
<FolderIcon fontSize="small" sx={{ mr: 1, color: "#ccc" }} />
|
|
||||||
) : (
|
|
||||||
<DescriptionIcon fontSize="small" sx={{ mr: 1, color: "#ccc" }} />
|
|
||||||
)}
|
|
||||||
<Typography variant="body2" sx={{ color: "#e6edf3" }}>{n.label}</Typography>
|
|
||||||
</Box>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{n.children && n.children.length ? renderNodes(n.children) : null}
|
|
||||||
</TreeItem>
|
|
||||||
));
|
|
||||||
|
|
||||||
const onItemSelect = (_e, itemId) => {
|
|
||||||
const node = nodeMap[itemId];
|
|
||||||
if (node && !node.isFolder) {
|
|
||||||
setSelectedPath(node.path);
|
|
||||||
setError("");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onRun = async () => {
|
|
||||||
if (!selectedPath) {
|
|
||||||
setError(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 (
|
|
||||||
<Dialog open={open} onClose={running ? undefined : onClose} fullWidth maxWidth="md"
|
|
||||||
PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}
|
|
||||||
>
|
|
||||||
<DialogTitle>Quick Job</DialogTitle>
|
|
||||||
<DialogContent>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
|
||||||
<Button size="small" variant={mode === 'scripts' ? 'outlined' : 'text'} onClick={() => setMode('scripts')} sx={{ textTransform: 'none', color: '#58a6ff', borderColor: '#58a6ff' }}>Scripts</Button>
|
|
||||||
<Button size="small" variant={mode === 'ansible' ? 'outlined' : 'text'} onClick={() => setMode('ansible')} sx={{ textTransform: 'none', color: '#58a6ff', borderColor: '#58a6ff' }}>Ansible</Button>
|
|
||||||
</Box>
|
|
||||||
<Typography variant="body2" sx={{ color: "#aaa", mb: 1 }}>
|
|
||||||
Select a {mode === 'ansible' ? 'playbook' : 'script'} to run on {hostnames.length} device{hostnames.length !== 1 ? "s" : ""}.
|
|
||||||
</Typography>
|
|
||||||
<Box sx={{ display: "flex", gap: 2 }}>
|
|
||||||
<Paper sx={{ flex: 1, p: 1, bgcolor: "#1e1e1e", maxHeight: 400, overflow: "auto" }}>
|
|
||||||
<SimpleTreeView sx={{ color: "#e6edf3" }} onItemSelectionToggle={onItemSelect}>
|
|
||||||
{tree.length ? renderNodes(tree) : (
|
|
||||||
<Typography variant="body2" sx={{ color: "#888", p: 1 }}>
|
|
||||||
{mode === 'ansible' ? 'No playbooks found.' : 'No scripts found.'}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</SimpleTreeView>
|
|
||||||
</Paper>
|
|
||||||
<Box sx={{ width: 320 }}>
|
|
||||||
<Typography variant="subtitle2" sx={{ color: "#ccc", mb: 1 }}>Selection</Typography>
|
|
||||||
<Typography variant="body2" sx={{ color: selectedPath ? "#e6edf3" : "#888" }}>
|
|
||||||
{selectedPath || (mode === 'ansible' ? 'No playbook selected' : 'No script selected')}
|
|
||||||
</Typography>
|
|
||||||
<Box sx={{ mt: 2 }}>
|
|
||||||
<FormControlLabel
|
|
||||||
control={<Checkbox size="small" checked={runAsCurrentUser} onChange={(e) => setRunAsCurrentUser(e.target.checked)} />}
|
|
||||||
label={<Typography variant="body2">Run as currently logged-in user</Typography>}
|
|
||||||
/>
|
|
||||||
<Typography variant="caption" sx={{ color: "#888" }}>
|
|
||||||
Unchecked = Run-As BUILTIN\SYSTEM
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
{error && (
|
|
||||||
<Typography variant="body2" sx={{ color: "#ff4f4f", mt: 1 }}>{error}</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button onClick={onClose} disabled={running} sx={{ color: "#58a6ff" }}>Cancel</Button>
|
|
||||||
<Button onClick={onRun} disabled={running || !selectedPath}
|
|
||||||
sx={{ color: running || !selectedPath ? "#666" : "#58a6ff" }}
|
|
||||||
>
|
|
||||||
Run
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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')
|
|
||||||
Reference in New Issue
Block a user