From c87eca3802fb0192969d5e1fa6f959ea568a867a Mon Sep 17 00:00:00 2001 From: Nicole Rappe Date: Sun, 5 Oct 2025 01:40:17 -0600 Subject: [PATCH] Add agent version hash tracking to device list --- Data/Agent/Roles/role_DeviceAudit.py | 34 ++++++++ Data/Server/WebUI/src/Devices/Device_List.jsx | 77 +++++++++++++++++-- Data/Server/server.py | 50 ++++++++++-- 3 files changed, 147 insertions(+), 14 deletions(-) diff --git a/Data/Agent/Roles/role_DeviceAudit.py b/Data/Agent/Roles/role_DeviceAudit.py index 841fbe5..b3b8a4b 100644 --- a/Data/Agent/Roles/role_DeviceAudit.py +++ b/Data/Agent/Roles/role_DeviceAudit.py @@ -134,6 +134,32 @@ def _project_root(): return os.getcwd() +_AGENT_HASH_CACHE = {"path": None, "mtime": None, "value": None} + + +def _read_agent_hash(): + try: + root = _project_root() + path = os.path.join(root, 'github_repo_hash.txt') + cache = _AGENT_HASH_CACHE + if not os.path.isfile(path): + cache.update({"path": path, "mtime": None, "value": None}) + return None + mtime = os.path.getmtime(path) + if cache.get("path") == path and cache.get("mtime") == mtime: + return cache.get("value") + with open(path, 'r', encoding='utf-8') as fh: + value = fh.read().strip() + cache.update({"path": path, "mtime": mtime, "value": value or None}) + return cache.get("value") + except Exception: + try: + _AGENT_HASH_CACHE.update({"value": None}) + except Exception: + pass + return None + + # Removed Ansible-based audit path; Python collectors provide details directly. @@ -828,6 +854,12 @@ class Role: # Always post the latest available details (possibly cached) details_to_send = self._last_details or {'summary': collect_summary(self.ctx.config)} + agent_hash_value = _read_agent_hash() + if agent_hash_value: + try: + details_to_send.setdefault('summary', {})['agent_hash'] = agent_hash_value + except Exception: + pass get_url = (self.ctx.hooks.get('get_server_url') if isinstance(self.ctx.hooks, dict) else None) or (lambda: 'http://localhost:5000') url = (get_url() or '').rstrip('/') + '/api/agent/details' payload = { @@ -835,6 +867,8 @@ class Role: 'hostname': details_to_send.get('summary', {}).get('hostname', socket.gethostname()), 'details': details_to_send, } + if agent_hash_value: + payload['agent_hash'] = agent_hash_value if aiohttp is not None: async with aiohttp.ClientSession() as session: await session.post(url, json=payload, timeout=10) diff --git a/Data/Server/WebUI/src/Devices/Device_List.jsx b/Data/Server/WebUI/src/Devices/Device_List.jsx index ba9d4a7..79f6fa9 100644 --- a/Data/Server/WebUI/src/Devices/Device_List.jsx +++ b/Data/Server/WebUI/src/Devices/Device_List.jsx @@ -77,6 +77,7 @@ export default function DeviceList({ onSelectDevice }) { const COL_LABELS = useMemo( () => ({ status: "Status", + agentVersion: "Agent Version", site: "Site", hostname: "Hostname", description: "Description", @@ -95,6 +96,7 @@ export default function DeviceList({ onSelectDevice }) { const defaultColumns = useMemo( () => [ { id: "status", label: COL_LABELS.status }, + { id: "agentVersion", label: COL_LABELS.agentVersion }, { id: "site", label: COL_LABELS.site }, { id: "hostname", label: COL_LABELS.hostname }, { id: "description", label: COL_LABELS.description }, @@ -120,13 +122,52 @@ export default function DeviceList({ onSelectDevice }) { const [assignSiteId, setAssignSiteId] = useState(null); const [assignTargets, setAssignTargets] = useState([]); // hostnames - const fetchAgents = useCallback(async () => { + const [repoHash, setRepoHash] = useState(null); + + const fetchLatestRepoHash = useCallback(async () => { + try { + const resp = await fetch( + "https://api.github.com/repos/bunny-lab-io/Borealis/branches/main", + { + headers: { + Accept: "application/vnd.github+json", + }, + } + ); + if (!resp.ok) throw new Error(`GitHub status ${resp.status}`); + const json = await resp.json(); + const sha = (json?.commit?.sha || "").trim(); + setRepoHash((prev) => sha || prev || null); + return sha || null; + } catch (err) { + console.warn("Failed to fetch repository hash", err); + setRepoHash((prev) => prev || null); + return null; + } + }, []); + + const computeAgentVersion = useCallback((agentHashValue, repoHashValue) => { + const agentHash = (agentHashValue || "").trim(); + const repo = (repoHashValue || "").trim(); + if (!repo) return agentHash ? "Unknown" : "Unknown"; + if (!agentHash) return "Needs Updated"; + return agentHash === repo ? "Up-to-Date" : "Needs Updated"; + }, []); + + const fetchAgents = useCallback(async (options = {}) => { + const { refreshRepo = false } = options || {}; + let repoSha = repoHash; + if (refreshRepo || !repoSha) { + const fetched = await fetchLatestRepoHash(); + if (fetched) repoSha = fetched; + } try { const res = await fetch("/api/agents"); const data = await res.json(); const arr = Object.entries(data || {}).map(([id, a]) => { const hostname = a.hostname || id || "unknown"; const details = detailsByHost[hostname] || {}; + const agentHash = (a.agent_hash || "").trim(); return { id, hostname, @@ -142,6 +183,8 @@ export default function DeviceList({ onSelectDevice }) { externalIp: details.externalIp || "", lastReboot: details.lastReboot || "", description: details.description || "", + agentHash, + agentVersion: computeAgentVersion(agentHash, repoSha), }; }); setRows(arr); @@ -229,7 +272,7 @@ export default function DeviceList({ onSelectDevice }) { console.warn("Failed to load agents:", e); setRows([]); } - }, [detailsByHost]); + }, [detailsByHost, repoHash, fetchLatestRepoHash, computeAgentVersion]); const fetchViews = useCallback(async () => { try { @@ -244,7 +287,7 @@ export default function DeviceList({ onSelectDevice }) { useEffect(() => { // Initial load only; removed auto-refresh interval - fetchAgents(); + fetchAgents({ refreshRepo: true }); }, [fetchAgents]); useEffect(() => { @@ -271,7 +314,20 @@ export default function DeviceList({ onSelectDevice }) { setFilters((prev) => ({ ...prev, ...obj })); // Optionally ensure Site column exists when site filter is present if (obj.site) { - setColumns((prev) => (prev.some((c) => c.id === 'site') ? prev : [{ id: 'status', label: COL_LABELS.status }, { id: 'site', label: COL_LABELS.site }, ...prev.filter((c) => c.id !== 'status') ])); + setColumns((prev) => { + if (prev.some((c) => c.id === 'site')) return prev; + const hasAgentVersion = prev.some((c) => c.id === 'agentVersion'); + const remainder = prev.filter((c) => !['status', 'agentVersion'].includes(c.id)); + const base = [ + { id: 'status', label: COL_LABELS.status }, + ...(hasAgentVersion ? [{ id: 'agentVersion', label: COL_LABELS.agentVersion }] : []), + { id: 'site', label: COL_LABELS.site }, + ]; + if (!hasAgentVersion) { + return base.concat(prev.filter((c) => c.id !== 'status')); + } + return [...base, ...remainder]; + }); } } localStorage.removeItem('device_list_initial_filters'); @@ -283,7 +339,9 @@ export default function DeviceList({ onSelectDevice }) { const hasSite = prev.some((c) => c.id === 'site'); if (hasSite) return prev; const next = [...prev]; - next.splice(1, 0, { id: 'site', label: COL_LABELS.site }); + const agentIndex = next.findIndex((c) => c.id === 'agentVersion'); + const insertAt = agentIndex >= 0 ? agentIndex + 1 : 1; + next.splice(insertAt, 0, { id: 'site', label: COL_LABELS.site }); return next; }); setFilters((f) => ({ ...f, site })); @@ -333,6 +391,8 @@ export default function DeviceList({ onSelectDevice }) { return row.type || ""; case "os": return row.os || ""; + case "agentVersion": + return row.agentVersion || ""; case "internalIp": return row.internalIp || ""; case "externalIp": @@ -554,7 +614,7 @@ export default function DeviceList({ onSelectDevice }) { fetchAgents({ refreshRepo: true })} sx={{ color: "#bbb", mr: 1 }} > @@ -660,6 +720,8 @@ export default function DeviceList({ onSelectDevice }) { ); + case "agentVersion": + return {r.agentVersion || ""}; case "site": return {r.site || "Not Configured"}; case "hostname": @@ -822,7 +884,8 @@ export default function DeviceList({ onSelectDevice }) { PaperProps={{ sx: { bgcolor: "#1e1e1e", color: '#fff', p: 1 } }} > - {[ + {[ + { id: 'agentVersion', label: 'Agent Version' }, { id: 'site', label: 'Site' }, { id: 'hostname', label: 'Hostname' }, { id: 'os', label: 'Operating System' }, diff --git a/Data/Server/server.py b/Data/Server/server.py index cb4a10f..e39b6ad 100644 --- a/Data/Server/server.py +++ b/Data/Server/server.py @@ -1776,7 +1776,7 @@ def init_db(): # Device details table cur.execute( - "CREATE TABLE IF NOT EXISTS device_details (hostname TEXT PRIMARY KEY, description TEXT, details TEXT, created_at INTEGER)" + "CREATE TABLE IF NOT EXISTS device_details (hostname TEXT PRIMARY KEY, description TEXT, details TEXT, created_at INTEGER, agent_hash TEXT)" ) # Backfill missing created_at column on existing installs try: @@ -1784,6 +1784,8 @@ def init_db(): cols = [r[1] for r in cur.fetchall()] if 'created_at' not in cols: cur.execute("ALTER TABLE device_details ADD COLUMN created_at INTEGER") + if 'agent_hash' not in cols: + cur.execute("ALTER TABLE device_details ADD COLUMN agent_hash TEXT") except Exception: pass @@ -2607,14 +2609,19 @@ def load_agents_from_db(): try: conn = _db_conn() cur = conn.cursor() - cur.execute("SELECT hostname, details FROM device_details") - for hostname, details_json in cur.fetchall(): + cur.execute("SELECT hostname, details, agent_hash FROM device_details") + for hostname, details_json, agent_hash in cur.fetchall(): try: details = json.loads(details_json or "{}") except Exception: details = {} summary = details.get("summary", {}) agent_id = summary.get("agent_id") or hostname + stored_hash = None + try: + stored_hash = (agent_hash or summary.get("agent_hash") or "").strip() + except Exception: + stored_hash = summary.get("agent_hash") or "" registered_agents[agent_id] = { "agent_id": agent_id, "hostname": summary.get("hostname") or hostname, @@ -2625,6 +2632,8 @@ def load_agents_from_db(): "last_seen": summary.get("last_seen") or 0, "status": "Offline", } + if stored_hash: + registered_agents[agent_id]["agent_hash"] = stored_hash conn.close() except Exception as e: print(f"[WARN] Failed to load agents from DB: {e}") @@ -2690,6 +2699,11 @@ def save_agent_details(): hostname = data.get("hostname") details = data.get("details") agent_id = data.get("agent_id") + agent_hash = data.get("agent_hash") + if isinstance(agent_hash, str): + agent_hash = agent_hash.strip() or None + else: + agent_hash = None if not hostname and isinstance(details, dict): hostname = (details.get("summary") or {}).get("hostname") if not hostname or not isinstance(details, dict): @@ -2726,6 +2740,11 @@ def save_agent_details(): pass if hostname and not incoming_summary.get("hostname"): incoming_summary["hostname"] = hostname + if agent_hash: + try: + incoming_summary["agent_hash"] = agent_hash + except Exception: + pass # Preserve last_seen if incoming omitted it if not incoming_summary.get("last_seen"): @@ -2781,17 +2800,34 @@ def save_agent_details(): # Upsert row without destroying created_at; keep previous created_at if exists cur.execute( """ - INSERT INTO device_details(hostname, description, details, created_at) - VALUES (?,?,?,?) + INSERT INTO device_details(hostname, description, details, created_at, agent_hash) + VALUES (?,?,?,?,?) ON CONFLICT(hostname) DO UPDATE SET description=excluded.description, details=excluded.details, - created_at=COALESCE(device_details.created_at, excluded.created_at) + created_at=COALESCE(device_details.created_at, excluded.created_at), + agent_hash=COALESCE(NULLIF(excluded.agent_hash, ''), device_details.agent_hash) """, - (hostname, description, json.dumps(merged), created_at), + (hostname, description, json.dumps(merged), created_at, agent_hash or None), ) conn.commit() conn.close() + + normalized_hash = None + try: + normalized_hash = (agent_hash or (merged.get("summary") or {}).get("agent_hash") or "").strip() + except Exception: + normalized_hash = agent_hash + if normalized_hash: + if agent_id and agent_id in registered_agents: + registered_agents[agent_id]["agent_hash"] = normalized_hash + # Also update any entries keyed by hostname (duplicate agents) + try: + for aid, rec in registered_agents.items(): + if rec.get("hostname") == hostname and normalized_hash: + rec["agent_hash"] = normalized_hash + except Exception: + pass return jsonify({"status": "ok"}) except Exception as e: return jsonify({"error": str(e)}), 500