mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-10-27 07:41:58 -06:00
Milestone towards Ansible Inventory Implementation
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
- hosts: all
|
- hosts: all
|
||||||
gather_facts: false
|
gather_facts: false
|
||||||
tasks:
|
tasks:
|
||||||
- name: Collect summary via PowerShell
|
- name: Collect summary via PowerShell (hostname, OS, uptime, last reboot)
|
||||||
ansible.builtin.shell: |
|
ansible.builtin.shell: |
|
||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
$hostname = $env:COMPUTERNAME
|
$hostname = $env:COMPUTERNAME
|
||||||
@@ -14,8 +14,16 @@
|
|||||||
$os = ($product + ' ' + $display).Trim()
|
$os = ($product + ' ' + $display).Trim()
|
||||||
$boot = (Get-CimInstance Win32_OperatingSystem).LastBootUpTime
|
$boot = (Get-CimInstance Win32_OperatingSystem).LastBootUpTime
|
||||||
$uptime = [int]((Get-Date) - $boot).TotalSeconds
|
$uptime = [int]((Get-Date) - $boot).TotalSeconds
|
||||||
$epoch = [int](((Get-Date $boot).ToUniversalTime() - [datetime]'1970-01-01').TotalSeconds)
|
# Produce Last Reboot as UTC string to match UI expectations (YYYY-MM-DD HH:MM:SS)
|
||||||
$out = [pscustomobject]@{ hostname=$hostname; os=$os; username=$username; domain=$domain; uptime_sec=$uptime; last_reboot=$epoch }
|
$bootUtc = (Get-Date $boot).ToUniversalTime().ToString('yyyy-MM-dd HH:mm:ss')
|
||||||
|
$out = [pscustomobject]@{
|
||||||
|
hostname=$hostname;
|
||||||
|
operating_system=$os;
|
||||||
|
username=$username;
|
||||||
|
domain=$domain;
|
||||||
|
uptime_sec=$uptime;
|
||||||
|
last_reboot=$bootUtc
|
||||||
|
}
|
||||||
$out | ConvertTo-Json -Depth 4
|
$out | ConvertTo-Json -Depth 4
|
||||||
register: summary_raw
|
register: summary_raw
|
||||||
changed_when: false
|
changed_when: false
|
||||||
@@ -108,13 +116,105 @@
|
|||||||
storage: "{{ _storage }}"
|
storage: "{{ _storage }}"
|
||||||
network: "{{ _network }}"
|
network: "{{ _network }}"
|
||||||
|
|
||||||
- name: Derive internal IP
|
- name: Derive internal IP from adapter list
|
||||||
ansible.builtin.set_fact:
|
ansible.builtin.set_fact:
|
||||||
device_details: "{{ device_details | combine({'summary': (device_details.summary | combine({'internal_ip': ( (_network | map(attribute='ips') | list | first | default([])) | first | default('') ) })) }) }}"
|
device_details: "{{ device_details | combine({'summary': (device_details.summary | combine({'internal_ip': (_network | map(attribute='ips') | list | flatten | select('match','^(10\\.|172\\.(1[6-9]|2[0-9]|3[0-1])\\.|192\\.168\\.)') | list | first | default('')) })) }) }}"
|
||||||
|
|
||||||
|
- name: Detect device type (Server/Workstation/Virtual Machine)
|
||||||
|
ansible.builtin.shell: |
|
||||||
|
$ErrorActionPreference = 'SilentlyContinue'
|
||||||
|
function _getCim($cls){ try { return Get-CimInstance $cls -ErrorAction Stop } catch { try { return Get-WmiObject -Class $cls -ErrorAction Stop } catch { return $null } } }
|
||||||
|
$os = _getCim 'Win32_OperatingSystem'
|
||||||
|
$cs = _getCim 'Win32_ComputerSystem'
|
||||||
|
$caption = ""; if ($os) { $caption = [string]$os.Caption }
|
||||||
|
$model = ""; if ($cs) { $model = [string]$cs.Model }
|
||||||
|
$manu = ""; if ($cs) { $manu = [string]$cs.Manufacturer }
|
||||||
|
$virt = $false
|
||||||
|
if ($model -match 'Virtual' -or $manu -match 'Microsoft Corporation' -and $model -match 'Virtual Machine' -or $manu -match 'VMware' -or $manu -match 'innotek' -or $manu -match 'VirtualBox' -or $manu -match 'QEMU' -or $manu -match 'Xen' -or $manu -match 'Parallels') { $virt = $true }
|
||||||
|
if ($virt) { 'Virtual Machine' }
|
||||||
|
elseif ($caption -match 'Server') { 'Server' }
|
||||||
|
else { 'Workstation' }
|
||||||
|
register: device_type_raw
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Attach device type to summary
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
device_details: "{{ device_details | combine({'summary': (device_details.summary | combine({'device_type': (device_type_raw.stdout | default('') | trim) })) }) }}"
|
||||||
|
|
||||||
|
- name: Collect external IP (best-effort)
|
||||||
|
ansible.builtin.shell: |
|
||||||
|
$ErrorActionPreference = 'SilentlyContinue'
|
||||||
|
$ip = ''
|
||||||
|
try { $ip = (Invoke-RestMethod -Uri 'https://api.ipify.org?format=json' -TimeoutSec 3).ip } catch {}
|
||||||
|
if (-not $ip) { try { $ip = (Invoke-WebRequest -Uri 'https://checkip.amazonaws.com' -TimeoutSec 3).Content.Trim() } catch {} }
|
||||||
|
if (-not $ip) { try { $ip = (Invoke-WebRequest -Uri 'https://ifconfig.me/ip' -TimeoutSec 3).Content.Trim() } catch {} }
|
||||||
|
$ip
|
||||||
|
register: external_ip_raw
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Attach external IP to summary
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
device_details: "{{ device_details | combine({'summary': (device_details.summary | combine({'external_ip': (external_ip_raw.stdout | default('') | trim) })) }) }}"
|
||||||
|
|
||||||
|
- name: Collect currently logged-in users (interactive + RDP)
|
||||||
|
ansible.builtin.shell: |
|
||||||
|
$ErrorActionPreference = 'SilentlyContinue'
|
||||||
|
function Get-InteractiveUsers {
|
||||||
|
$users = @()
|
||||||
|
try {
|
||||||
|
$ls = Get-CimInstance Win32_LogonSession | Where-Object { $_.LogonType -in 2,10 }
|
||||||
|
foreach ($sess in $ls) {
|
||||||
|
$accs = Get-CimAssociatedInstance -InputObject $sess -Association Win32_LoggedOnUser -ResultClassName Win32_Account
|
||||||
|
foreach ($a in $accs) {
|
||||||
|
if (-not $a -or -not $a.Name) { continue }
|
||||||
|
$nm = [string]$a.Name
|
||||||
|
$dm = [string]$a.Domain
|
||||||
|
if ($nm -match '\$$') { continue }
|
||||||
|
if ($dm -eq 'NT AUTHORITY' -or $dm -eq 'NT SERVICE') { continue }
|
||||||
|
if ($nm -like 'DWM-*' -or $nm -like 'UMFD-*') { continue }
|
||||||
|
if ($dm) { $users += ("{0}\\{1}" -f $dm,$nm) } else { $users += $nm }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
$users | Sort-Object -Unique
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-QuserUsers {
|
||||||
|
$list=@()
|
||||||
|
try {
|
||||||
|
$q = (quser 2>$null) -split "`r?`n"
|
||||||
|
foreach ($line in $q) {
|
||||||
|
if (-not $line) { continue }
|
||||||
|
if ($line -match '^USERNAME') { continue }
|
||||||
|
$s = ($line -replace '^>','').Trim()
|
||||||
|
if (-not $s) { continue }
|
||||||
|
$parts = $s -split '\s+'
|
||||||
|
if ($parts.Length -lt 1) { continue }
|
||||||
|
$u = $parts[0]
|
||||||
|
if (-not $u) { continue }
|
||||||
|
if ($u -match '\$$') { continue }
|
||||||
|
if ($u -like 'DWM-*' -or $u -like 'UMFD-*') { continue }
|
||||||
|
$list += $u
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
$list | Sort-Object -Unique
|
||||||
|
}
|
||||||
|
|
||||||
|
$u1 = Get-InteractiveUsers
|
||||||
|
$u2 = Get-QuserUsers
|
||||||
|
$combined = @()
|
||||||
|
foreach ($u in $u1) { if ($combined -notcontains $u) { $combined += $u } }
|
||||||
|
foreach ($u in $u2) { if ($combined -notcontains $u) { $combined += $u } }
|
||||||
|
if ($combined.Count -eq 0) { 'No Users Logged In' } else { $combined -join ', ' }
|
||||||
|
register: last_user_raw
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Attach last_user string to summary
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
device_details: "{{ device_details | combine({'summary': (device_details.summary | combine({'last_user': (last_user_raw.stdout | default('') | trim) })) }) }}"
|
||||||
|
|
||||||
- name: Write device details JSON
|
- name: Write device details JSON
|
||||||
ansible.builtin.copy:
|
ansible.builtin.copy:
|
||||||
content: "{{ device_details | to_nice_json }}"
|
content: "{{ device_details | to_nice_json }}"
|
||||||
dest: "{{ output_file }}"
|
dest: "{{ output_file }}"
|
||||||
run_once: true
|
run_once: true
|
||||||
|
|
||||||
|
|||||||
@@ -495,6 +495,151 @@ else { 'Workstation' }
|
|||||||
return ''
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_last_user_string() -> str:
|
||||||
|
if not IS_WINDOWS:
|
||||||
|
return ''
|
||||||
|
try:
|
||||||
|
ps = r"""
|
||||||
|
$ErrorActionPreference = 'SilentlyContinue'
|
||||||
|
function Get-InteractiveUsers {
|
||||||
|
$users = @()
|
||||||
|
try {
|
||||||
|
$ls = Get-CimInstance Win32_LogonSession | Where-Object { $_.LogonType -in 2,10 }
|
||||||
|
foreach ($sess in $ls) {
|
||||||
|
$accs = Get-CimAssociatedInstance -InputObject $sess -Association Win32_LoggedOnUser -ResultClassName Win32_Account
|
||||||
|
foreach ($a in $accs) {
|
||||||
|
if (-not $a -or -not $a.Name) { continue }
|
||||||
|
$name = [string]$a.Name
|
||||||
|
$domain = [string]$a.Domain
|
||||||
|
if ($name -match '\$$') { continue }
|
||||||
|
if ($domain -eq 'NT AUTHORITY' -or $domain -eq 'NT SERVICE') { continue }
|
||||||
|
if ($name -like 'DWM-*' -or $name -like 'UMFD-*') { continue }
|
||||||
|
if ($domain) { $users += ("{0}\{1}" -f $domain,$name) } else { $users += $name }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
$users | Sort-Object -Unique
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-QuserUsers {
|
||||||
|
$list=@()
|
||||||
|
try {
|
||||||
|
$q = (quser 2>$null) -split '\r?\n'
|
||||||
|
foreach ($line in $q) {
|
||||||
|
if (-not $line) { continue }
|
||||||
|
if ($line -match '^USERNAME') { continue }
|
||||||
|
$s = ($line -replace '^>','').Trim()
|
||||||
|
if (-not $s) { continue }
|
||||||
|
$parts = $s -split '\s+'
|
||||||
|
if ($parts.Length -lt 1) { continue }
|
||||||
|
$u = $parts[0]
|
||||||
|
if (-not $u) { continue }
|
||||||
|
if ($u -match '\$$') { continue }
|
||||||
|
if ($u -like 'DWM-*' -or $u -like 'UMFD-*') { continue }
|
||||||
|
$list += $u
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
$list | Sort-Object -Unique
|
||||||
|
}
|
||||||
|
|
||||||
|
$u1 = Get-InteractiveUsers
|
||||||
|
$u2 = Get-QuserUsers
|
||||||
|
$combined = @()
|
||||||
|
foreach ($u in $u1) { if ($combined -notcontains $u) { $combined += $u } }
|
||||||
|
foreach ($u in $u2) { if ($combined -notcontains $u) { $combined += $u } }
|
||||||
|
if ($combined.Count -eq 0) { 'No Users Logged In' } else { $combined -join ', ' }
|
||||||
|
"""
|
||||||
|
out = subprocess.run(["powershell", "-NoProfile", "-Command", ps], capture_output=True, text=True, timeout=25)
|
||||||
|
s = (out.stdout or '').strip()
|
||||||
|
# PowerShell may emit newlines; take first non-empty line result
|
||||||
|
if s:
|
||||||
|
for line in s.splitlines():
|
||||||
|
t = line.strip()
|
||||||
|
if t:
|
||||||
|
return t
|
||||||
|
return ''
|
||||||
|
except Exception:
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
def _build_details_fallback() -> dict:
|
||||||
|
# Construct a details object similar to Ansible playbook output
|
||||||
|
try:
|
||||||
|
summary = collect_summary(type('C', (), {'data': {}, '_write': lambda s: None})())
|
||||||
|
except Exception:
|
||||||
|
summary = {'hostname': socket.gethostname()}
|
||||||
|
# Normalize OS field
|
||||||
|
if summary.get('os') and not summary.get('operating_system'):
|
||||||
|
summary['operating_system'] = summary.get('os')
|
||||||
|
# Last reboot in UTC string
|
||||||
|
try:
|
||||||
|
if psutil and hasattr(psutil, 'boot_time'):
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
summary['last_reboot'] = datetime.fromtimestamp(psutil.boot_time(), timezone.utc).strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Device type
|
||||||
|
try:
|
||||||
|
dt = detect_device_type()
|
||||||
|
if dt:
|
||||||
|
summary['device_type'] = dt
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Network
|
||||||
|
network = collect_network()
|
||||||
|
try:
|
||||||
|
# Derive internal_ip from first private IPv4
|
||||||
|
def is_private(ip: str) -> bool:
|
||||||
|
return ip.startswith('10.') or ip.startswith('192.168.') or (ip.startswith('172.') and any(ip.startswith(f'172.{n}.') for n in list(range(16,32))))
|
||||||
|
ips = []
|
||||||
|
for a in network:
|
||||||
|
for ip in (a.get('ips') or []):
|
||||||
|
if ip and is_private(ip):
|
||||||
|
ips.append(ip)
|
||||||
|
summary['internal_ip'] = ips[0] if ips else (network[0]['ips'][0] if network and network[0].get('ips') else '')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# External IP best-effort
|
||||||
|
try:
|
||||||
|
ext = ''
|
||||||
|
for url in ('https://api.ipify.org', 'https://checkip.amazonaws.com'):
|
||||||
|
try:
|
||||||
|
import urllib.request
|
||||||
|
with urllib.request.urlopen(url, timeout=3) as resp:
|
||||||
|
txt = (resp.read() or b'').decode('utf-8', errors='ignore').strip()
|
||||||
|
if txt and '\n' in txt:
|
||||||
|
txt = txt.split('\n', 1)[0].strip()
|
||||||
|
if '{' in txt:
|
||||||
|
try:
|
||||||
|
obj = json.loads(txt); txt = (obj.get('ip') or '').strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if txt:
|
||||||
|
ext = txt; break
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if ext:
|
||||||
|
summary['external_ip'] = ext
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Last user(s)
|
||||||
|
try:
|
||||||
|
last_user = _collect_last_user_string()
|
||||||
|
if last_user:
|
||||||
|
summary['last_user'] = last_user
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
details = {
|
||||||
|
'summary': summary,
|
||||||
|
'software': collect_software(),
|
||||||
|
'memory': collect_memory(),
|
||||||
|
'storage': collect_storage(),
|
||||||
|
'network': network,
|
||||||
|
}
|
||||||
|
return details
|
||||||
|
|
||||||
|
|
||||||
class Role:
|
class Role:
|
||||||
def __init__(self, ctx):
|
def __init__(self, ctx):
|
||||||
self.ctx = ctx
|
self.ctx = ctx
|
||||||
@@ -537,6 +682,14 @@ class Role:
|
|||||||
need_refresh = (not self._last_details) or ((now - self._ansible_ts) > refresh_sec)
|
need_refresh = (not self._last_details) or ((now - self._ansible_ts) > refresh_sec)
|
||||||
if need_refresh:
|
if need_refresh:
|
||||||
details = _run_ansible_audit(self.ctx)
|
details = _run_ansible_audit(self.ctx)
|
||||||
|
if not details:
|
||||||
|
# Fallback collector when Ansible is unavailable
|
||||||
|
details = _build_details_fallback()
|
||||||
|
# Best-effort fill of missing/renamed fields so UI is happy
|
||||||
|
try:
|
||||||
|
details = self._normalize_details(details)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
if details:
|
if details:
|
||||||
self._last_details = details
|
self._last_details = details
|
||||||
self._ansible_ts = now
|
self._ansible_ts = now
|
||||||
@@ -555,3 +708,78 @@ class Role:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
await asyncio.sleep(interval_sec)
|
await asyncio.sleep(interval_sec)
|
||||||
|
|
||||||
|
def _normalize_details(self, details: dict) -> dict:
|
||||||
|
if not isinstance(details, dict):
|
||||||
|
return {}
|
||||||
|
details.setdefault('summary', {})
|
||||||
|
summary = details['summary']
|
||||||
|
# Map legacy 'os' to 'operating_system'
|
||||||
|
try:
|
||||||
|
if not summary.get('operating_system') and summary.get('os'):
|
||||||
|
summary['operating_system'] = summary.get('os')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Device type fallback
|
||||||
|
try:
|
||||||
|
dt = (summary.get('device_type') or '').strip()
|
||||||
|
if not dt:
|
||||||
|
dt = detect_device_type() or ''
|
||||||
|
if dt:
|
||||||
|
summary['device_type'] = dt
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Internal IP fallback from network list
|
||||||
|
try:
|
||||||
|
if not summary.get('internal_ip'):
|
||||||
|
net = details.get('network') or []
|
||||||
|
ipv4s = []
|
||||||
|
for a in net:
|
||||||
|
for ip in (a.get('ips') or []):
|
||||||
|
try:
|
||||||
|
if ip and isinstance(ip, str) and ip.count('.') == 3 and not ip.startswith('169.254.') and ip != '127.0.0.1':
|
||||||
|
ipv4s.append(ip)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
summary['internal_ip'] = ipv4s[0] if ipv4s else ''
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# External IP best-effort (cache ~15 min) if still missing
|
||||||
|
try:
|
||||||
|
now = time.time()
|
||||||
|
ext = (summary.get('external_ip') or '').strip()
|
||||||
|
if not ext and (now - self._ext_ip_ts > 900):
|
||||||
|
# lightweight fetch without blocking forever
|
||||||
|
import urllib.request # lazy import
|
||||||
|
for url in (
|
||||||
|
'https://api.ipify.org',
|
||||||
|
'https://checkip.amazonaws.com',
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(url, timeout=3) as resp:
|
||||||
|
txt = (resp.read() or b'').decode('utf-8', errors='ignore').strip()
|
||||||
|
if txt and '\n' in txt:
|
||||||
|
txt = txt.split('\n', 1)[0].strip()
|
||||||
|
# api.ipify.org returns plain IP by default; if JSON was served, handle a small case
|
||||||
|
if '{' in txt:
|
||||||
|
try:
|
||||||
|
obj = json.loads(txt)
|
||||||
|
txt = (obj.get('ip') or '').strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if txt:
|
||||||
|
self._ext_ip = txt
|
||||||
|
self._ext_ip_ts = now
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if not ext and self._ext_ip:
|
||||||
|
summary['external_ip'] = self._ext_ip
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
details['summary'] = summary
|
||||||
|
return details
|
||||||
|
|||||||
Reference in New Issue
Block a user