diff --git a/app.py b/app.py index 187d584..00c017e 100644 --- a/app.py +++ b/app.py @@ -1258,6 +1258,62 @@ async def system_sysctl_audit(host: str, _=Depends(_verify)): raise HTTPException(502, "sysctl audit returned invalid JSON") from exc return {"host": host, **result} + +def _host_forensics_command(since_hours: int) -> str: + script = f'''import glob, json, os, subprocess + +def run(command): + proc = subprocess.run(command, shell=True, text=True, capture_output=True, timeout=30) + return {{"rc": proc.returncode, "stdout": proc.stdout.strip()[-12000:], "stderr": proc.stderr.strip()[-1000:]}} + +checks = {{ + "hostname": run("hostnamectl --static 2>/dev/null || hostname"), + "uptime": run("uptime"), + "disk": run("df -hT / /var/lib/docker 2>/dev/null || df -hT /"), + "failed_units": run("systemctl --failed --no-legend --no-pager"), + "docker_binary": run("command -v docker || true"), + "docker_packages": run("dpkg-query -W -f='${{Package}}|${{Status}}|${{Version}}\\n' 'docker*' 'containerd*' 2>/dev/null || true"), + "docker_units": run("systemctl is-active docker containerd 2>/dev/null; systemctl is-enabled docker containerd 2>/dev/null"), + "docker_containers": run("docker ps -a --format '{{{{.Names}}}}|{{{{.Image}}}}|{{{{.Status}}}}' 2>/dev/null || true"), + "docker_images": run("docker image ls --format '{{{{.Repository}}}}:{{{{.Tag}}}}|{{{{.ID}}}}|{{{{.Size}}}}' 2>/dev/null || true"), + "docker_volumes": run("docker volume ls --format '{{{{.Name}}}}' 2>/dev/null || true"), + "docker_disk_usage": run("docker system df 2>/dev/null || true"), + "iptables_docker_refs": run("iptables-save 2>/dev/null | grep -ci docker || true"), + "nft_docker_refs": run("nft list ruleset 2>/dev/null | grep -ci docker || true"), + "forward_policy": run("iptables -S FORWARD 2>/dev/null | head -40"), + "lvm": run("lvs -o lv_name,lv_size,data_percent,metadata_percent --units g --noheadings 2>/dev/null || true"), + "qemu_configs": run("ls -l /etc/pve/nodes/$(hostname)/qemu-server 2>/dev/null || true"), + "recent_system_files": run("find /etc/systemd/system /etc/docker /etc/network -type f -mmin -{since_hours * 60} -printf '%TY-%Tm-%Td %TH:%TM:%TS %p\\n' 2>/dev/null | sort"), + "recent_iso_builder_files": run("find /app-config/ansible/iso-builder -type f -mmin -{since_hours * 60} -printf '%TY-%Tm-%Td %TH:%TM:%TS %p\\n' 2>/dev/null | sort"), + "ansible_git_status": run("git -C /app-config/ansible status --short 2>/dev/null || true"), + "iso_builder_hashes": run("sha256sum /app-config/ansible/iso-builder/* 2>/dev/null || true"), +}} +print(json.dumps(checks)) +''' + encoded = base64.b64encode(script.encode()).decode() + return f'python3 -c "import base64;exec(base64.b64decode(\'{encoded}\'))"' + + +@app.get("/system/forensics/{host}") +async def system_forensics(host: str, since_hours: int = Query(48, ge=1, le=168), _=Depends(_verify)): + """Read-only host residue audit for failed deployments and package/network drift.""" + if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,62}", host): + raise HTTPException(400, "Invalid host name") + inventory = await asyncio.to_thread(_find_inventory_host, host) + if not inventory: + raise HTTPException(404, f"Host {host} not found") + target = f'{inventory["user"]}@{inventory["ip"]}' + rc, out, err = await asyncio.to_thread(_ssh, target, _host_forensics_command(since_hours), 60) + if rc != 0: + raise HTTPException(502, (err or out).strip()[-500:] or "host forensics failed") + try: + result = json.loads(out) + except json.JSONDecodeError as exc: + raise HTTPException(502, "host forensics returned invalid JSON") from exc + _audit(f"/system/forensics/{host}", "GET", 200, f"since_hours={since_hours}") + return {"host": host, "since_hours": since_hours, "checks": result} + + def _pve_auth(): pv = _parse_kv("proxmox") return f"PVEAPIToken={pv.get('tokenid','')}={pv.get('secret','')}"