#!/usr/bin/env python3 """Read-only Linux inventory. No root, network, secrets, or configuration changes.""" import datetime import hashlib import json import os import pathlib import platform import re import shutil import stat import subprocess import sys UNITS = ( "nginx.service", "apache2.service", "httpd.service", "php8.3-fpm.service", "php8.4-fpm.service", "php-fpm.service", "mariadb.service", "mysql.service", "postgresql.service", "postfix.service", "exim.service", "exim4.service", "dovecot.service", "pdns.service", "named.service", "bind9.service", "ssh.service", "sshd.service", "cron.service", "crond.service", "ufw.service", "firewalld.service", "nftables.service", "fail2ban.service", "sshguard.service", "unattended-upgrades.service", ) SOFTWARE_PREFIXES = ( "apache2", "nginx", "php", "mariadb-server", "mysql-server", "postgresql", "postfix", "exim", "dovecot", "bind9", "openssh-server", "cron", "clamav", "clamav-freshclam", "spamassassin", "spamc", "spamd", "roundcube-core", "roundcube-sqlite3", "radicale", "python3-bcrypt", ) def read(path): try: return pathlib.Path(path).read_text().strip() except (OSError, UnicodeError): return "" def secure_profile_marker(path, expected, strict=False): try: descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) except OSError: return False with os.fdopen(descriptor, 'rb') as handle: info = os.fstat(handle.fileno()) if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1 or info.st_uid != 0 or info.st_size > 64: return False if strict and (os.geteuid() != 0 or info.st_gid != 0 or stat.S_IMODE(info.st_mode) != 0o600): return False if not strict and info.st_mode & 0o022: return False raw = handle.read(65) encoded = expected.encode() return raw in (encoded, encoded + b'\n') def run(args, timeout=3): try: result = subprocess.run(args, capture_output=True, text=True, timeout=timeout, check=False) return result.stdout.strip() if result.returncode == 0 else None except (OSError, subprocess.TimeoutExpired): return None def split_endpoint(value): """Return an ss local endpoint as a normalized address and port.""" value = value.strip() if value.startswith("[") and "]:" in value: address, port = value[1:].rsplit("]:", 1) else: address, separator, port = value.rpartition(":") if not separator: return None address = address.split("%", 1)[0] if address in ("0.0.0.0", "::"): address = "*" try: port_number = int(port) except ValueError: return None if not 1 <= port_number <= 65535: return None return address, port_number def collect(): if platform.system() != "Linux": raise RuntimeError("Execute o coletor em Linux. Ele não modifica o sistema.") machine_id = read("/etc/machine-id") if not machine_id: raise RuntimeError("Identificador /etc/machine-id indisponível; coleta interrompida.") os_release = dict(line.split("=", 1) for line in read("/etc/os-release").splitlines() if "=" in line) memory = {} for line in read("/proc/meminfo").splitlines(): parts = line.split() if len(parts) >= 2 and parts[1].isdigit(): memory[parts[0].rstrip(":")] = int(parts[1]) * 1024 try: disk = shutil.disk_usage("/") total, free = disk.total, disk.free except OSError: total, free = None, None try: uptime = float(read("/proc/uptime").split()[0]) except (ValueError, IndexError): uptime = None try: load = read("/proc/loadavg").split() load_1, load_5, load_15 = map(float, load[:3]) running, process_total = map(int, load[3].split("/", 1)) except (ValueError, IndexError): load_1 = load_5 = load_15 = running = process_total = None services = [] for unit in UNITS: output = run(["systemctl", "show", unit, "--property=LoadState", "--property=ActiveState", "--no-pager"]) values = dict(line.split("=", 1) for line in (output or "").splitlines() if "=" in line) state = "not-found" if values.get("LoadState") == "not-found" else values.get("ActiveState", "unknown") services.append({"unit": unit, "state": state}) virtualization = run(["systemd-detect-virt"]) interfaces = [] try: address_data = json.loads(run(["ip", "-j", "address", "show"]) or "[]") for item in address_data[:64]: addresses = [] for address in item.get("addr_info", [])[:32]: if address.get("family") in ("inet", "inet6") and address.get("local"): addresses.append({"address": address["local"], "prefix": address.get("prefixlen"), "scope": address.get("scope", "unknown")}) interfaces.append({"name": item.get("ifname", ""), "state": str(item.get("operstate", "UNKNOWN")).lower(), "mtu": item.get("mtu"), "mac": item.get("address") or None, "addresses": addresses}) except (ValueError, TypeError): interfaces = [] default_route = None try: routes = json.loads(run(["ip", "-j", "route", "show", "default"]) or "[]") if routes: default_route = {"interface": routes[0].get("dev"), "gateway": routes[0].get("gateway")} except (ValueError, TypeError): pass resolvers = [] for line in read("/etc/resolv.conf").splitlines(): parts = line.split() if len(parts) == 2 and parts[0] == "nameserver" and parts[1] not in resolvers: resolvers.append(parts[1]) filesystems = [] seen_filesystems = set() virtual_types = {"tmpfs", "devtmpfs", "overlay", "proc", "sysfs", "devpts", "cgroup", "cgroup2", "debugfs", "tracefs", "configfs", "fusectl", "mqueue", "hugetlbfs", "rootfs", "9p"} try: mount_data = json.loads(run(["findmnt", "-J", "-b", "-o", "TARGET,SOURCE,FSTYPE,SIZE,USED,AVAIL,USE%"]) or "{}") pending = list(mount_data.get("filesystems", [])) while pending and len(filesystems) < 64: item = pending.pop(0); pending[0:0] = item.get("children", []) if item.get("fstype") in virtual_types or not isinstance(item.get("size"), int) or item["size"] <= 0: continue identity = (item.get("source"), item.get("fstype"), item["size"]) if identity in seen_filesystems: continue seen_filesystems.add(identity) percent = item.get("use%") filesystems.append({"target": item.get("target", ""), "source": item.get("source", ""), "type": item.get("fstype", ""), "totalBytes": item["size"], "usedBytes": item.get("used"), "availableBytes": item.get("avail"), "usedPercent": int(percent[:-1]) if isinstance(percent, str) and percent.endswith("%") and percent[:-1].isdigit() else None}) except (ValueError, TypeError, KeyError): filesystems = [] quota_path = "/srv/cac/sites" if pathlib.Path("/srv/cac/sites").is_dir() else "/" quota_storage = {"path": quota_path, "target": "/", "source": "unknown", "type": "unknown", "userQuotaEnabled": False, "projectQuotaEnabled": False} try: quota_mount_data = json.loads(run(["findmnt", "-J", "-T", quota_path, "-o", "TARGET,SOURCE,FSTYPE,OPTIONS"]) or "{}") quota_mounts = quota_mount_data.get("filesystems", []) if quota_mounts: quota_mount = quota_mounts[0] quota_options = {item.strip().lower() for item in str(quota_mount.get("options", "")).split(",") if item.strip()} quota_storage = { "path": quota_path, "target": quota_mount.get("target") or "/", "source": quota_mount.get("source") or "unknown", "type": quota_mount.get("fstype") or "unknown", "userQuotaEnabled": bool(quota_options.intersection({"quota", "usrquota", "uquota"})), "projectQuotaEnabled": bool(quota_options.intersection({"prjquota", "pquota"})), } except (ValueError, TypeError, KeyError): pass quota = { "kernelSupport": pathlib.Path("/proc/sys/fs/quota").is_dir(), "tools": {name: shutil.which(name) is not None for name in ("quotaon", "repquota", "setquota")}, "accountStorage": quota_storage, } listeners = [] seen_listeners = set() output = run(["ss", "-H", "-lntu"]) for line in (output or "").splitlines(): parts = line.split() if len(parts) < 5 or parts[0] not in ("tcp", "udp"): continue endpoint = split_endpoint(parts[4]) if not endpoint: continue address, port = endpoint identity = (parts[0], address, port) if identity in seen_listeners: continue seen_listeners.add(identity) listeners.append({"protocol": parts[0], "address": address, "port": port}) if len(listeners) >= 256: break listeners.sort(key=lambda item: (item["port"], item["protocol"], item["address"])) processes = [] process_output = run([ "ps", "-eo", "pid=,user:64=,%cpu=,rss=,etimes=,comm=", "--sort=-%cpu,-rss", ]) for line in (process_output or "").splitlines(): parts = line.split(None, 5) if len(parts) != 6: continue pid, user, cpu_percent, resident_kib, elapsed_seconds, command = parts try: item = { "pid": int(pid), "user": user, "command": command, "cpuPercent": float(cpu_percent.replace(",", ".")), "residentBytes": int(resident_kib) * 1024, "elapsedSeconds": int(elapsed_seconds), } except ValueError: continue if item["pid"] > 0 and item["cpuPercent"] >= 0 and item["residentBytes"] >= 0 and item["elapsedSeconds"] >= 0: processes.append(item) if len(processes) >= 20: break lab_marker = pathlib.Path('/etc/cac-panel-lab') production_marker = pathlib.Path(os.environ.get('CAC_PANEL_PRODUCTION_MARKER', '/etc/cac-panel/production')) if secure_profile_marker(lab_marker, 'cac-panel-lab-v1') and not pathlib.Path('/usr/local/cpanel').exists(): execution_profile = 'lab' elif secure_profile_marker(production_marker, 'cac-panel-production-v1', True) and not pathlib.Path('/usr/local/cpanel').exists(): execution_profile = 'production' else: execution_profile = 'observer' managed_services = [] if execution_profile == 'lab': listening = {(item['protocol'], item['address'], item['port']) for item in listeners} def root_configuration(path): try: info = os.lstat(path) return stat.S_ISREG(info.st_mode) and info.st_uid == 0 and not info.st_mode & 0o022 except OSError: return False def has_managed_sites(path, prefix): try: descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) with os.fdopen(descriptor, 'rb') as handle: header = handle.readline(256).rstrip(b'\n') return header != prefix + b'W10=' except OSError: return True def has_tls_sites(): path = pathlib.Path('/var/lib/cac-panel-lab/tls/manifest.json') try: info = path.lstat() if not stat.S_ISREG(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022 or info.st_size > 262144: return False manifest = json.loads(path.read_text()) return isinstance(manifest, dict) and manifest.get('version') == 1 and isinstance(manifest.get('sites'), list) and len(manifest['sites']) > 0 except (OSError, ValueError): return False def loopback(protocol, port): return any((protocol, address, port) in listening for address in ('127.0.0.1', '::1')) definitions = ( ('web-static', 'Publicação estática', '/var/lib/cac-panel-lab/publication/httpd.conf', b'# CAC publication v1 ', (('tcp', 8087, 'http://127.0.0.1:8087'),)), ('web-php', 'Publicação PHP', '/var/lib/cac-panel-lab/php-publication/httpd.conf', b'# CAC PHP publication v1 ', (('tcp', 8086, 'http://127.0.0.1:8086'),)), ('dns-authoritative', 'DNS autoritativo', '/var/lib/cac-panel-lab/dns/active.conf', None, (('tcp', 8053, 'tcp://127.0.0.1:8053'), ('udp', 8053, 'udp://127.0.0.1:8053'))), ('https-proxy', 'Proxy HTTPS', '/var/lib/cac-panel-lab/tls/active.conf', None, (('tcp', 8443, 'https://127.0.0.1:8443'),)), ('mail', 'Correio local', '/var/lib/cac-panel-lab/mail/active.conf', None, (('tcp', 1025, 'smtp://127.0.0.1:1025'), ('tcp', 1587, 'submission://127.0.0.1:1587'), ('tcp', 1143, 'imap://127.0.0.1:1143'), ('tcp', 1993, 'imaps://127.0.0.1:1993'), ('tcp', 1995, 'pop3s://127.0.0.1:1995'))), ('webmail', 'Webmail Roundcube', '/var/lib/cac-panel-lab/webmail/httpd.conf', None, (('tcp', 8090, 'http://127.0.0.1:8090/'),)), ('dav', 'Calendários e contatos DAV', '/var/lib/cac-panel-lab/dav/config', None, (('tcp', 5232, 'http://127.0.0.1:5232/'),)), ('ssh-access', 'Acesso SSH', '/var/lib/cac-panel-lab/ssh-service/sshd_config', None, (('tcp', 2222, 'ssh://127.0.0.1:2222'),)), ) for service_id, label, configuration, resource_header, probes in definitions: configured = root_configuration(configuration) endpoints = [endpoint for protocol, port, endpoint in probes if loopback(protocol, port)] if not configured: state = 'unexpected' if endpoints else 'not-configured' elif service_id == 'https-proxy' and not has_tls_sites(): state = 'unexpected' if endpoints else 'idle' elif resource_header is not None and not has_managed_sites(configuration, resource_header): state = 'idle' elif len(endpoints) == len(probes): state = 'active' elif endpoints: state = 'degraded' else: state = 'inactive' managed_services.append({'id': service_id, 'label': label, 'state': state, 'endpoints': endpoints}) clock_values = {} for line in (run(["timedatectl", "show", "--property=Timezone", "--property=NTP", "--property=NTPSynchronized", "--no-pager"]) or "").splitlines(): if "=" in line: key, value = line.split("=", 1) clock_values[key] = value timezone = clock_values.get("Timezone") or read("/etc/timezone") or "UTC" ntp_value = clock_values.get("NTPSynchronized", "").lower() ntp_enabled_value = clock_values.get("NTP", "").lower() clock = {"timezone": timezone, "ntpEnabled": True if ntp_enabled_value == "yes" else False if ntp_enabled_value == "no" else None, "ntpSynchronized": True if ntp_value == "yes" else False if ntp_value == "no" else None} pretty_hostname = run(["hostnamectl", "--pretty"]) software = [] packages = run(["dpkg-query", "-W", "-f=${binary:Package}\t${Version}\n"]) for line in (packages or "").splitlines(): if "\t" not in line: continue name, version = line.split("\t", 1) canonical_name = name.split(":", 1)[0] if canonical_name in SOFTWARE_PREFIXES or canonical_name.startswith(("php8.", "postgresql-")): software.append({"package": name, "version": version}) if len(software) >= 64: break software.sort(key=lambda item: item["package"]) php_runtime = {"version": None, "modules": [], "zendModules": []} php_binary = shutil.which("php8.3") or shutil.which("php") if php_binary: version = run([php_binary, "-r", "echo PHP_VERSION;"]) module_output = run([php_binary, "-m"]) target = php_runtime["modules"] for line in (module_output or "").splitlines(): value = line.strip() if value == "[PHP Modules]": target = php_runtime["modules"] elif value == "[Zend Modules]": target = php_runtime["zendModules"] elif value and not value.startswith("[") and value not in target and len(target) < 128: target.append(value) php_runtime["version"] = version if version and re.fullmatch(r"[0-9]+(?:\.[0-9]+){1,3}(?:[-+._a-zA-Z0-9]*)?", version) else None service_states = {item["unit"]: item["state"] for item in services} firewall_backend, firewall_active = "none", False if shutil.which("ufw"): firewall_backend = "ufw" status = run(["ufw", "status"]) firewall_active = None if status is None else status.lower().startswith("status: active") elif shutil.which("firewall-cmd"): firewall_backend = "firewalld" firewall_active = service_states.get("firewalld.service") == "active" elif shutil.which("nft"): firewall_backend = "nftables" rules = run(["nft", "list", "ruleset"]) firewall_active = None if rules is None else bool(rules.strip()) ssh_installed = shutil.which("sshd") is not None ssh_listening = any(item["protocol"] == "tcp" and item["port"] == 22 for item in listeners) ssh_values = {} if ssh_installed: effective = run(["sshd", "-T", "-C", "user=root,host=localhost,addr=127.0.0.1"]) or run(["sshd", "-T"]) for line in (effective or "").splitlines(): key, separator, value = line.partition(" ") if separator and key in ("permitrootlogin", "passwordauthentication"): ssh_values[key] = value.strip().lower() permit_root = ssh_values.get("permitrootlogin", "unknown") if permit_root not in ("yes", "no", "prohibit-password", "forced-commands-only", "without-password"): permit_root = "unknown" password_value = ssh_values.get("passwordauthentication") password_authentication = True if password_value == "yes" else False if password_value == "no" else None if pathlib.Path("/sys/module/apparmor/parameters/enabled").exists(): access_framework, access_active = "apparmor", read("/sys/module/apparmor/parameters/enabled").upper().startswith("Y") elif pathlib.Path("/sys/fs/selinux/enforce").exists(): access_framework, access_active = "selinux", read("/sys/fs/selinux/enforce") == "1" else: access_framework, access_active = "none", False if shutil.which("fail2ban-client"): brute_force_tool, brute_force_active = "fail2ban", service_states.get("fail2ban.service") == "active" elif shutil.which("sshguard"): brute_force_tool, brute_force_active = "sshguard", service_states.get("sshguard.service") == "active" else: brute_force_tool, brute_force_active = "none", False automatic_updates = None if pathlib.Path("/etc/apt/apt.conf.d").is_dir(): automatic_updates = any(re.search(r'APT::Periodic::Unattended-Upgrade\s+"1"', read(path)) for path in pathlib.Path("/etc/apt/apt.conf.d").glob("*") if path.is_file()) security = { "firewall": {"backend": firewall_backend, "active": firewall_active}, "ssh": {"installed": ssh_installed, "listening": ssh_listening, "permitRootLogin": permit_root, "passwordAuthentication": password_authentication}, "mandatoryAccessControl": {"framework": access_framework, "active": access_active}, "bruteForceProtection": {"tool": brute_force_tool, "active": brute_force_active}, "automaticUpdates": automatic_updates, } pending_packages = security_packages = None package_updates = [] if shutil.which("apt-get"): simulated = run(["apt-get", "-s", "upgrade"], timeout=8) if simulated is not None: installs = [line for line in simulated.splitlines() if line.startswith("Inst ")] pending_packages = len(installs) security_packages = sum(1 for line in installs if "-security" in line.lower() or "/security" in line.lower()) for line in installs[:50]: match = re.match(r"^Inst\s+([A-Za-z0-9][A-Za-z0-9.+:-]*)(?:\s+\[([^\]]+)\])?\s+\(([^\s)]+)", line) if match: package_updates.append({"package": match.group(1), "currentVersion": match.group(2), "candidateVersion": match.group(3), "security": "-security" in line.lower() or "/security" in line.lower()}) updates = {"pendingPackages": pending_packages, "securityPackages": security_packages, "rebootRequired": pathlib.Path("/var/run/reboot-required").exists(), "packages": package_updates} pid_one_stat = read('/proc/1/stat') pid_one_tail = pid_one_stat.rsplit(')', 1)[1].split() if ')' in pid_one_stat else [] instance_id = hashlib.sha256((machine_id + ':' + (pid_one_tail[19] if len(pid_one_tail) > 19 else 'unknown')).encode()).hexdigest() return { "schemaVersion": 1, "platform": "linux", "executionProfile": execution_profile, "nodeId": hashlib.sha256(("cac-panel-v1:" + machine_id).encode()).hexdigest(), "instanceId": instance_id, "hostname": platform.node(), "prettyHostname": pretty_hostname or "", "os": os_release.get("PRETTY_NAME", "Linux").strip('"'), "kernel": platform.release(), "virtualization": virtualization or "não detectada", "collectedAt": datetime.datetime.now(datetime.timezone.utc).isoformat(), "metrics": {"memoryTotalBytes": memory.get("MemTotal"), "memoryAvailableBytes": memory.get("MemAvailable"), "swapTotalBytes": memory.get("SwapTotal"), "swapFreeBytes": memory.get("SwapFree"), "diskTotalBytes": total, "diskFreeBytes": free, "uptimeSeconds": uptime, "cpuLogical": os.cpu_count(), "load1": load_1, "load5": load_5, "load15": load_15, "processesRunning": running, "processesTotal": process_total}, "services": services, "managedServices": managed_services, "network": {"interfaces": interfaces, "defaultRoute": default_route, "resolvers": resolvers[:8]}, "filesystems": filesystems, "quota": quota, "listeners": listeners, "processes": processes, "clock": clock, "software": software, "phpRuntime": php_runtime, "security": security, "updates": updates, } if __name__ == "__main__": try: print(json.dumps(collect(), ensure_ascii=False, indent=2)) except RuntimeError as error: print(str(error), file=sys.stderr) sys.exit(1)