Last active
April 23, 2026 23:35
-
-
Save h8rt3rmin8r/fc8cfecde605636b86c4a30de8b5d18a to your computer and use it in GitHub Desktop.
System inventory script for Linux servers.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| # sysinv.sh - System Inventory Collector | |
| # | |
| # NAME | |
| # sysinv.sh - System Inventory Collector | |
| # | |
| # SYNOPSIS | |
| # sysinv.sh [options] [output-file] | |
| # sysinv.sh --help | |
| # | |
| # DESCRIPTION | |
| # Collects a comprehensive snapshot of a Linux server's state for | |
| # diagnostic and pre-change investigation. Built for situations where | |
| # you need to know exactly what is running, listening, scheduled, or | |
| # consuming resources before making surgical changes to a system. | |
| # | |
| # Running with no category flags enables every category. Specifying one | |
| # or more category flags restricts collection to only those categories. | |
| # Output defaults to stdout. | |
| # | |
| # A single positional argument is accepted and treated as the output | |
| # file path; it must not begin with a hyphen. To write to a path that | |
| # does begin with a hyphen, pass it explicitly via -o, --out, or | |
| # --output. | |
| # | |
| # OPTIONS | |
| # -h, --help Show this help text and exit. | |
| # -V, --version Show script version and exit. | |
| # -o, --out, --output FILE Write the full report to FILE. Equivalent to | |
| # passing FILE as a positional argument. | |
| # -q, --quiet Suppress banner and progress messages. | |
| # -f, --force Overwrite the output file if it already | |
| # exists. | |
| # --no-color Disable ANSI color output. Auto-disabled when | |
| # stdout is not a TTY or when writing to a file | |
| # or when --json is set. | |
| # --timestamp Prefix the output filename with a UTC | |
| # timestamp in YYYYMMDDTHHMMSSZ format. | |
| # --json Emit the full report as a single JSON | |
| # document on stdout (or to the output file). | |
| # Requires jq. | |
| # | |
| # CATEGORY FLAGS | |
| # --system OS release, kernel, uptime, hostname, hardware summary, | |
| # time sync status, GPU detection. | |
| # --network Interfaces, IP addresses, routes, DNS resolvers, | |
| # TCP/UDP listening sockets. | |
| # --services systemd units (active and failed), timers, service | |
| # summary. | |
| # --processes Top CPU and memory consumers, total process counts, | |
| # file descriptor usage. | |
| # --docker Containers (all states), images, volumes, networks, | |
| # disk usage, daemon status. | |
| # --packages Package manager detection, installed count, upgradable | |
| # packages, recently installed packages. | |
| # --cron System cron, user crontabs, periodic directories, | |
| # systemd timers, anacron. | |
| # --access User accounts, current sessions, recent logins, SSH | |
| # config summary, sudoers membership. | |
| # --storage Filesystem usage, inode usage, mount points, largest | |
| # directories under common locations. | |
| # --logs Recent critical and error journal entries, log file | |
| # sizes under /var/log. | |
| # --firewall iptables, nftables, ufw, and fail2ban status. | |
| # --web Detected web servers (nginx, apache, caddy) and their | |
| # enabled site configurations. | |
| # --runtimes Language runtimes (node, python, ruby, go, rust, java) | |
| # and version managers (nvm, pyenv, rbenv, asdf). | |
| # --all Explicitly enable all categories (equivalent to the | |
| # default when no category flags are specified). | |
| # | |
| # EXAMPLES | |
| # Gather everything and print to stdout: | |
| # ./sysinv.sh | |
| # | |
| # Limit to Docker and services, emit as JSON: | |
| # ./sysinv.sh --docker --services --json --out /tmp/state.json | |
| # | |
| # Write a timestamped full report to disk, overwriting if present: | |
| # ./sysinv.sh /tmp/inventory.txt --timestamp --force | |
| # | |
| # Pre-change audit before a surgical cleanup: | |
| # ./sysinv.sh --docker --services --cron --web /tmp/pre-audit.txt | |
| # | |
| # DEPENDENCIES | |
| # Required: awk, grep, sed (present on base Debian/Ubuntu installs). | |
| # Required for --json: jq (install with: sudo apt-get install -y jq). | |
| # Optional: ip, ss, systemctl, docker, timeout, and others. The script | |
| # degrades gracefully when optional tools are absent. | |
| # | |
| # ENVIRONMENT | |
| # SYSINV_DEBUG When set to any non-empty value, emits a | |
| # per-step trace on stderr showing which | |
| # sub-step of each collector is in progress. | |
| # Useful for pinpointing where a collector | |
| # stalls on a particular host. | |
| # SYSINV_SKIP_USER_CRONTAB When set to any non-empty value, skips the | |
| # `crontab -l` probe entirely and emits a | |
| # placeholder marker in its place. Provided | |
| # as an escape hatch for hosts where the | |
| # crontab suid wrapper misbehaves under | |
| # redirection despite the wrapper's kill | |
| # guarantees. | |
| # | |
| # EXIT CODES | |
| # 0 Success | |
| # 1 Argument parsing error | |
| # 2 Output file validation error (parent missing, unwritable, exists | |
| # without --force, etc.) | |
| # 3 Missing required dependency | |
| # | |
| # AUTHOR | |
| # h8rt3rmin8r for ShruggieTech (Shruggie LLC) | |
| # | |
| # COMPATIBILITY | |
| # Ubuntu 22.04+, Debian 11+, most modern systemd-based Linux | |
| # distributions. Bash 4.3 or later. | |
| # | |
| set -o pipefail | |
| #_______________________________________________________________________________ | |
| # Function Declarations | |
| print_help() { | |
| # print_help: parses this script's own source and emits the block of | |
| # comment lines that appears between the shebang and the first line | |
| # that is not a help line. Acceptable help lines are "# " + content | |
| # (two characters stripped on emit) OR a bare "#" (emitted as an | |
| # empty line). The first section divider (#_...) and any other | |
| # "#" + non-whitespace continuation terminate the block naturally. | |
| local line | |
| local in_help=0 | |
| while IFS= read -r line; do | |
| if [[ ${in_help} -eq 0 ]]; then | |
| [[ "${line}" == '#!'* ]] && in_help=1 | |
| continue | |
| fi | |
| if [[ "${line}" == '#' ]]; then | |
| printf '\n' | |
| elif [[ "${line}" =~ ^#[[:space:]] ]]; then | |
| printf '%s\n' "${line:2}" | |
| else | |
| break | |
| fi | |
| done < "${SCRIPT_PATH}" | |
| } | |
| print_version() { | |
| # print_version: emits the script name and version to stdout. | |
| printf '%s v%s\n' "${SCRIPT_NAME}" "${SCRIPT_VERSION}" | |
| } | |
| has_cmd() { | |
| # has_cmd: returns 0 if the given command is on PATH. | |
| command -v "$1" >/dev/null 2>&1 | |
| } | |
| is_root() { | |
| # is_root: returns 0 if the effective UID is 0. | |
| [[ ${EUID:-$(id -u)} -eq 0 ]] | |
| } | |
| safe_run() { | |
| # safe_run: runs a command with hard termination guarantees. The | |
| # initial SIGTERM fires after SAFE_RUN_TIMEOUT seconds. If the | |
| # command catches or ignores SIGTERM (common for setuid wrappers | |
| # like `crontab` on misconfigured hosts), an uncatchable SIGKILL | |
| # fires SAFE_RUN_KILL_AFTER seconds later. Commands run in a new | |
| # session via setsid so that processes that open /dev/tty directly | |
| # (bypassing a closed stdin) cannot block on terminal I/O. Stderr | |
| # is merged into stdout so that tool error messages remain visible | |
| # in text-mode output. Fd 3 (the debug channel) is explicitly | |
| # closed so the invoked command cannot inherit it. | |
| if has_cmd timeout && has_cmd setsid; then | |
| setsid -w timeout --preserve-status \ | |
| --kill-after="${SAFE_RUN_KILL_AFTER}" \ | |
| "${SAFE_RUN_TIMEOUT}" "$@" </dev/null 2>&1 3>&- | |
| elif has_cmd timeout; then | |
| timeout --preserve-status \ | |
| --kill-after="${SAFE_RUN_KILL_AFTER}" \ | |
| "${SAFE_RUN_TIMEOUT}" "$@" </dev/null 2>&1 3>&- | |
| else | |
| "$@" </dev/null 2>&1 3>&- | |
| fi | |
| } | |
| safe_capture() { | |
| # safe_capture: stdout-only sibling of safe_run with the same hard | |
| # termination guarantees. Stderr is discarded entirely, which is | |
| # what you want when feeding output into a variable or a JSON | |
| # builder. Guaranteed to return within | |
| # (SAFE_RUN_TIMEOUT + SAFE_RUN_KILL_AFTER) seconds regardless of | |
| # how the wrapped command behaves. Fd 3 closed on the invoked | |
| # command to prevent debug-channel inheritance. | |
| if has_cmd timeout && has_cmd setsid; then | |
| setsid -w timeout --preserve-status \ | |
| --kill-after="${SAFE_RUN_KILL_AFTER}" \ | |
| "${SAFE_RUN_TIMEOUT}" "$@" </dev/null 2>/dev/null 3>&- | |
| elif has_cmd timeout; then | |
| timeout --preserve-status \ | |
| --kill-after="${SAFE_RUN_KILL_AFTER}" \ | |
| "${SAFE_RUN_TIMEOUT}" "$@" </dev/null 2>/dev/null 3>&- | |
| else | |
| "$@" </dev/null 2>/dev/null 3>&- | |
| fi | |
| } | |
| log_info() { | |
| # log_info: informational message to stderr; suppressed when --quiet. | |
| [[ ${OPT_QUIET} -eq 1 ]] && return 0 | |
| printf '%s[INFO]%s %s\n' "${C_BLUE}" "${C_RESET}" "$*" >&2 | |
| } | |
| log_warn() { | |
| # log_warn: warning message to stderr. | |
| printf '%s[WARN]%s %s\n' "${C_YELLOW}" "${C_RESET}" "$*" >&2 | |
| } | |
| log_error() { | |
| # log_error: error message to stderr. | |
| printf '%s[ERROR]%s %s\n' "${C_RED}" "${C_RESET}" "$*" >&2 | |
| } | |
| debug_log() { | |
| # debug_log: verbose per-step trace message, gated on the | |
| # SYSINV_DEBUG environment variable. Emits to file descriptor 3, | |
| # which setup_debug_fd points at the original stderr when | |
| # SYSINV_DEBUG is set. Routing through fd 3 lets debug messages | |
| # survive the stderr redirects applied to collectors (which use | |
| # 2>/dev/null to suppress tool noise). Without SYSINV_DEBUG, fd 3 | |
| # points at /dev/null and this function is effectively a no-op. | |
| [[ -z "${SYSINV_DEBUG:-}" ]] && return 0 | |
| printf '%s[DEBUG]%s %s\n' "${C_DIM}" "${C_RESET}" "$*" >&3 | |
| } | |
| setup_debug_fd() { | |
| # setup_debug_fd: open fd 3 for debug output. Called once from the | |
| # Execution section before any collectors run. When SYSINV_DEBUG is | |
| # set, fd 3 is a duplicate of the original stderr. Otherwise fd 3 | |
| # is wired to /dev/null so write attempts are cheap. | |
| if [[ -n "${SYSINV_DEBUG:-}" ]]; then | |
| exec 3>&2 | |
| else | |
| exec 3>/dev/null | |
| fi | |
| } | |
| hdr() { | |
| # hdr: top-level section header for text-mode output. | |
| printf '\n%s================================================================================%s\n' \ | |
| "${C_BOLD}${C_BLUE}" "${C_RESET}" | |
| printf '%s %s%s\n' "${C_BOLD}${C_BLUE}" "$1" "${C_RESET}" | |
| printf '%s================================================================================%s\n' \ | |
| "${C_BOLD}${C_BLUE}" "${C_RESET}" | |
| } | |
| subhdr() { | |
| # subhdr: subsection header for text-mode output. | |
| printf '\n%s--- %s ---%s\n' "${C_BOLD}" "$1" "${C_RESET}" | |
| } | |
| note() { | |
| # note: muted status line for text-mode output. | |
| printf '%s(%s)%s\n' "${C_DIM}" "$1" "${C_RESET}" | |
| } | |
| banner() { | |
| # banner: opening banner for text mode. Skipped in --json and --quiet. | |
| [[ ${OPT_QUIET} -eq 1 ]] && return 0 | |
| [[ ${OPT_JSON} -eq 1 ]] && return 0 | |
| local host_name user_name run_ts | |
| host_name=$(hostname -f 2>/dev/null || hostname 2>/dev/null || echo "unknown") | |
| user_name=$(id -un 2>/dev/null || echo "unknown") | |
| run_ts=$(date -u '+%Y-%m-%dT%H:%M:%SZ') | |
| printf '%s' "${C_BOLD}" | |
| printf '################################################################################\n' | |
| printf '# %s v%s\n' "${SCRIPT_NAME}" "${SCRIPT_VERSION}" | |
| printf '# Host: %s\n' "${host_name}" | |
| printf '# User: %s (uid=%s)\n' "${user_name}" "$(id -u)" | |
| printf '# Time: %s\n' "${run_ts}" | |
| printf '################################################################################\n' | |
| printf '%s' "${C_RESET}" | |
| } | |
| disable_colors() { | |
| # disable_colors: zeroes all color escape variables. | |
| C_RED='' | |
| C_GREEN='' | |
| C_YELLOW='' | |
| C_BLUE='' | |
| C_BOLD='' | |
| C_DIM='' | |
| C_RESET='' | |
| } | |
| lines_to_json_array() { | |
| # lines_to_json_array: reads lines from stdin, emits a JSON array of | |
| # strings. Empty lines are preserved as empty string entries. | |
| jq -Rn '[inputs]' | |
| } | |
| str_or_null() { | |
| # str_or_null: emits a JSON string, or JSON null if the input is empty. | |
| if [[ -z "$1" ]]; then | |
| printf 'null' | |
| else | |
| printf '%s' "$1" | jq -Rs '.' | |
| fi | |
| } | |
| #___________________________________________________________________________ | |
| # Dependency Verification | |
| check_dependencies() { | |
| # check_dependencies: verifies that required commands are on PATH. On | |
| # Debian/Ubuntu, missing commands map to install-package names via the | |
| # DEPENDENCY_PACKAGES table. Exits 3 if anything is missing. | |
| local -a required=("${DEPENDENCIES[@]}") | |
| [[ ${OPT_JSON} -eq 1 ]] && required+=("jq") | |
| local -a missing=() | |
| local cmd | |
| for cmd in "${required[@]}"; do | |
| has_cmd "${cmd}" || missing+=("${cmd}") | |
| done | |
| if [[ ${#missing[@]} -gt 0 ]]; then | |
| log_error "Missing required dependencies: ${missing[*]}" | |
| local -a packages=() | |
| for cmd in "${missing[@]}"; do | |
| packages+=("${DEPENDENCY_PACKAGES[${cmd}]:-${cmd}}") | |
| done | |
| log_error "On Debian/Ubuntu, install with: sudo apt-get install -y ${packages[*]}" | |
| exit 3 | |
| fi | |
| } | |
| #___________________________________________________________________________ | |
| # Argument Parsing | |
| is_hyphen_prefixed() { | |
| # is_hyphen_prefixed: returns 0 if the argument begins with one or two | |
| # hyphens. Used to reject malformed positional output paths. | |
| [[ "$1" == -* ]] | |
| } | |
| consume_output_arg() { | |
| # consume_output_arg: validates and stores the output file path taken | |
| # from the value side of -o/--out/--output. No hyphen rejection here: | |
| # an explicit -o flag licenses paths that start with a hyphen. | |
| local flag="$1" | |
| local value="$2" | |
| if [[ -z "${value}" ]]; then | |
| log_error "${flag} requires a FILE argument" | |
| exit 1 | |
| fi | |
| if [[ -n "${OPT_OUTPUT}" ]]; then | |
| log_error "Multiple output paths specified ('${OPT_OUTPUT}' and '${value}')" | |
| exit 1 | |
| fi | |
| OPT_OUTPUT="${value}" | |
| } | |
| parse_args() { | |
| # parse_args: consumes the argument vector and sets OPT_* and CATEGORIES | |
| # globals. Exits 1 on malformed input. | |
| local any_category=0 | |
| while [[ $# -gt 0 ]]; do | |
| case "$1" in | |
| -h|--help) | |
| print_help | |
| exit 0 | |
| ;; | |
| -V|--version) | |
| print_version | |
| exit 0 | |
| ;; | |
| -q|--quiet) | |
| OPT_QUIET=1 | |
| shift | |
| ;; | |
| -f|--force) | |
| OPT_FORCE=1 | |
| shift | |
| ;; | |
| --no-color) | |
| OPT_NO_COLOR=1 | |
| shift | |
| ;; | |
| --timestamp) | |
| OPT_TIMESTAMP=1 | |
| shift | |
| ;; | |
| --json) | |
| OPT_JSON=1 | |
| shift | |
| ;; | |
| -o|--out|--output) | |
| consume_output_arg "$1" "${2:-}" | |
| shift 2 | |
| ;; | |
| -o=*|--out=*|--output=*) | |
| consume_output_arg "${1%%=*}" "${1#*=}" | |
| shift | |
| ;; | |
| --all) | |
| for key in "${!CATEGORIES[@]}"; do | |
| CATEGORIES[${key}]=1 | |
| done | |
| any_category=1 | |
| shift | |
| ;; | |
| --system|--network|--services|--processes|--docker|--packages|\ | |
| --cron|--access|--storage|--logs|--firewall|--web|--runtimes) | |
| local key="${1#--}" | |
| CATEGORIES[${key}]=1 | |
| any_category=1 | |
| shift | |
| ;; | |
| --) | |
| shift | |
| # Everything after -- is treated as a positional output | |
| # path. Still only one allowed. | |
| if [[ $# -gt 0 ]]; then | |
| if [[ $# -gt 1 ]]; then | |
| log_error "Multiple positional arguments after -- ; only one output file is allowed" | |
| exit 1 | |
| fi | |
| if [[ -n "${OPT_OUTPUT}" ]]; then | |
| log_error "Output file already specified; cannot accept positional '$1'" | |
| exit 1 | |
| fi | |
| OPT_OUTPUT="$1" | |
| shift | |
| fi | |
| ;; | |
| -*) | |
| log_error "Unknown option: $1" | |
| log_error "Output file paths that begin with a hyphen must be passed via -o, --out, or --output." | |
| log_error "Try '${SCRIPT_NAME} --help' for more information." | |
| exit 1 | |
| ;; | |
| *) | |
| # Positional argument: treat as output file. Only one | |
| # permitted; must not begin with a hyphen (already | |
| # ruled out by the case above, but defensive check). | |
| if is_hyphen_prefixed "$1"; then | |
| log_error "Positional output paths may not begin with a hyphen: '$1'" | |
| exit 1 | |
| fi | |
| if [[ -n "${OPT_OUTPUT}" ]]; then | |
| log_error "Multiple output paths specified ('${OPT_OUTPUT}' and '$1')" | |
| exit 1 | |
| fi | |
| OPT_OUTPUT="$1" | |
| shift | |
| ;; | |
| esac | |
| done | |
| # If no explicit category was requested, enable all. | |
| if [[ ${any_category} -eq 0 ]]; then | |
| for key in "${!CATEGORIES[@]}"; do | |
| CATEGORIES[${key}]=1 | |
| done | |
| fi | |
| } | |
| #___________________________________________________________________________ | |
| # Output Setup | |
| validate_output_path() { | |
| # validate_output_path: runs the sanity checks requested for the output | |
| # path. Exits 2 on any failure. | |
| local path="$1" | |
| # Absolute or relative paths both fine, but reject empty segments | |
| # and things that clearly are not filesystem paths. | |
| if [[ -z "${path}" ]]; then | |
| log_error "Output path is empty" | |
| exit 2 | |
| fi | |
| if [[ "${path}" == */ ]]; then | |
| log_error "Output path looks like a directory (trailing slash): ${path}" | |
| exit 2 | |
| fi | |
| local dir base | |
| dir=$(dirname -- "${path}") | |
| base=$(basename -- "${path}") | |
| if [[ -z "${base}" || "${base}" == "." || "${base}" == ".." ]]; then | |
| log_error "Output path has no usable filename component: ${path}" | |
| exit 2 | |
| fi | |
| if [[ ! -d "${dir}" ]]; then | |
| log_error "Output parent directory does not exist: ${dir}" | |
| exit 2 | |
| fi | |
| if [[ ! -w "${dir}" ]]; then | |
| log_error "Output parent directory is not writable: ${dir}" | |
| exit 2 | |
| fi | |
| if [[ -e "${path}" ]]; then | |
| if [[ -d "${path}" ]]; then | |
| log_error "Output path is an existing directory: ${path}" | |
| exit 2 | |
| fi | |
| if [[ ${OPT_FORCE} -ne 1 ]]; then | |
| log_error "Output file already exists: ${path}" | |
| log_error "Use --force to overwrite." | |
| exit 2 | |
| fi | |
| log_warn "Overwriting existing file (--force): ${path}" | |
| fi | |
| } | |
| setup_output() { | |
| # setup_output: applies the timestamp prefix, validates, disables | |
| # colors when appropriate, and redirects stdout to the output file. | |
| # Auto-disable colors when not writing to an interactive terminal, | |
| # when the caller asks for it, or when producing JSON. | |
| if [[ ${OPT_NO_COLOR} -eq 1 ]] || [[ -n "${NO_COLOR:-}" ]] || [[ ! -t 1 ]] || [[ -n "${OPT_OUTPUT}" ]] || [[ ${OPT_JSON} -eq 1 ]]; then | |
| disable_colors | |
| fi | |
| if [[ -z "${OPT_OUTPUT}" ]]; then | |
| return 0 | |
| fi | |
| local target="${OPT_OUTPUT}" | |
| if [[ ${OPT_TIMESTAMP} -eq 1 ]]; then | |
| local dir base | |
| dir=$(dirname -- "${target}") | |
| base=$(basename -- "${target}") | |
| target="${dir}/$(date -u '+%Y%m%dT%H%M%SZ')-${base}" | |
| fi | |
| validate_output_path "${target}" | |
| log_info "writing report to ${target}" | |
| if ! exec >"${target}"; then | |
| log_error "Cannot open output file for writing: ${target}" | |
| exit 2 | |
| fi | |
| } | |
| #___________________________________________________________________________ | |
| # Collector: system | |
| collect_system_text() { | |
| hdr "SYSTEM" | |
| subhdr "OS Release" | |
| if [[ -r /etc/os-release ]]; then | |
| cat /etc/os-release | |
| else | |
| note "/etc/os-release not found" | |
| fi | |
| subhdr "Kernel and Architecture" | |
| uname -a | |
| [[ -r /proc/version ]] && cat /proc/version | |
| subhdr "Hostname" | |
| if has_cmd hostnamectl; then | |
| safe_run hostnamectl | |
| else | |
| hostname 2>/dev/null | |
| fi | |
| subhdr "Uptime and Load" | |
| uptime | |
| subhdr "CPU" | |
| if has_cmd lscpu; then | |
| safe_run lscpu | grep -E '^(Architecture|CPU\(s\)|Model name|Vendor ID|Thread|Core|Socket|CPU max MHz|CPU min MHz)' \ | |
| || safe_run lscpu | |
| else | |
| grep -E '^(model name|cpu cores|processor)' /proc/cpuinfo 2>/dev/null | sort -u | |
| fi | |
| subhdr "Memory" | |
| free -h | |
| subhdr "Swap" | |
| swapon --show 2>/dev/null || note "no swap configured or swapon unavailable" | |
| subhdr "Block Devices" | |
| if has_cmd lsblk; then | |
| safe_run lsblk -o NAME,SIZE,TYPE,MOUNTPOINT,FSTYPE,MODEL | |
| else | |
| note "lsblk not available" | |
| fi | |
| subhdr "Time Sync" | |
| if has_cmd timedatectl; then | |
| safe_run timedatectl status | |
| else | |
| note "timedatectl not available" | |
| fi | |
| subhdr "GPU Detection" | |
| if has_cmd nvidia-smi; then | |
| safe_run nvidia-smi -L || note "nvidia-smi found but query failed" | |
| elif has_cmd lspci; then | |
| safe_run lspci | grep -iE 'vga|3d|display' || note "no discrete GPU detected" | |
| else | |
| note "nvidia-smi and lspci both unavailable" | |
| fi | |
| subhdr "Environment Snapshot" | |
| printf 'SHELL=%s\n' "${SHELL:-unknown}" | |
| printf 'PATH=%s\n' "${PATH}" | |
| printf 'LANG=%s\n' "${LANG:-unset}" | |
| printf 'TZ=%s\n' "${TZ:-$(date +%Z)}" | |
| } | |
| collect_system_json() { | |
| local os_name os_version_id kernel arch hostname fqdn | |
| local uptime_seconds load_1 load_5 load_15 | |
| local cpu_count cpu_model mem_total_kb mem_avail_kb | |
| local swap_total_kb time_synced gpu_info | |
| if [[ -r /etc/os-release ]]; then | |
| os_name=$(. /etc/os-release 2>/dev/null; printf '%s' "${NAME:-}") | |
| os_version_id=$(. /etc/os-release 2>/dev/null; printf '%s' "${VERSION_ID:-}") | |
| fi | |
| kernel=$(uname -r 2>/dev/null) | |
| arch=$(uname -m 2>/dev/null) | |
| hostname=$(hostname 2>/dev/null) | |
| fqdn=$(hostname -f 2>/dev/null || printf '%s' "${hostname}") | |
| if [[ -r /proc/uptime ]]; then | |
| uptime_seconds=$(awk '{printf "%d", $1}' /proc/uptime) | |
| fi | |
| if [[ -r /proc/loadavg ]]; then | |
| read -r load_1 load_5 load_15 _ < /proc/loadavg | |
| fi | |
| cpu_count=$(nproc 2>/dev/null || printf '0') | |
| cpu_model=$(awk -F: '/^model name/ {gsub(/^ +/, "", $2); print $2; exit}' /proc/cpuinfo 2>/dev/null) | |
| mem_total_kb=$(awk '/^MemTotal:/ {print $2}' /proc/meminfo 2>/dev/null) | |
| mem_avail_kb=$(awk '/^MemAvailable:/ {print $2}' /proc/meminfo 2>/dev/null) | |
| swap_total_kb=$(awk '/^SwapTotal:/ {print $2}' /proc/meminfo 2>/dev/null) | |
| if has_cmd timedatectl; then | |
| time_synced=$(safe_run timedatectl show -p NTPSynchronized --value | tr -d '[:space:]') | |
| fi | |
| if has_cmd nvidia-smi; then | |
| gpu_info=$(safe_run nvidia-smi -L | tr '\n' ';' | sed 's/;$//') | |
| elif has_cmd lspci; then | |
| gpu_info=$(safe_run lspci | grep -iE 'vga|3d|display' | tr '\n' ';' | sed 's/;$//') | |
| fi | |
| jq -n \ | |
| --arg os_name "${os_name:-}" \ | |
| --arg os_version_id "${os_version_id:-}" \ | |
| --arg kernel "${kernel:-}" \ | |
| --arg arch "${arch:-}" \ | |
| --arg hostname "${hostname:-}" \ | |
| --arg fqdn "${fqdn:-}" \ | |
| --arg uptime_seconds "${uptime_seconds:-0}" \ | |
| --arg load_1 "${load_1:-0}" \ | |
| --arg load_5 "${load_5:-0}" \ | |
| --arg load_15 "${load_15:-0}" \ | |
| --arg cpu_count "${cpu_count:-0}" \ | |
| --arg cpu_model "${cpu_model:-}" \ | |
| --arg mem_total_kb "${mem_total_kb:-0}" \ | |
| --arg mem_avail_kb "${mem_avail_kb:-0}" \ | |
| --arg swap_total_kb "${swap_total_kb:-0}" \ | |
| --arg time_synced "${time_synced:-}" \ | |
| --arg gpu_info "${gpu_info:-}" \ | |
| '{ | |
| os: { name: $os_name, version_id: $os_version_id }, | |
| kernel: $kernel, | |
| architecture: $arch, | |
| hostname: $hostname, | |
| fqdn: $fqdn, | |
| uptime_seconds: ($uptime_seconds | tonumber? // 0), | |
| load_average: { | |
| one_minute: ($load_1 | tonumber? // 0), | |
| five_minute: ($load_5 | tonumber? // 0), | |
| fifteen_minute: ($load_15 | tonumber? // 0) | |
| }, | |
| cpu: { | |
| count: ($cpu_count | tonumber? // 0), | |
| model: $cpu_model | |
| }, | |
| memory: { | |
| total_kb: ($mem_total_kb | tonumber? // 0), | |
| available_kb: ($mem_avail_kb | tonumber? // 0) | |
| }, | |
| swap_total_kb: ($swap_total_kb | tonumber? // 0), | |
| time_synchronized: (if $time_synced == "yes" then true | |
| elif $time_synced == "no" then false | |
| else null end), | |
| gpu: (if $gpu_info == "" then null else $gpu_info end) | |
| }' | |
| } | |
| collect_system() { | |
| if [[ ${OPT_JSON} -eq 1 ]]; then | |
| collect_system_json | |
| else | |
| collect_system_text | |
| fi | |
| } | |
| #___________________________________________________________________________ | |
| # Collector: network | |
| collect_network_text() { | |
| hdr "NETWORK" | |
| subhdr "Interfaces and Addresses" | |
| if has_cmd ip; then | |
| ip -brief address | |
| printf '\n' | |
| ip -brief link | |
| else | |
| ifconfig -a 2>/dev/null || note "neither ip nor ifconfig available" | |
| fi | |
| subhdr "Routing Table" | |
| if has_cmd ip; then | |
| ip route | |
| else | |
| route -n 2>/dev/null || note "route command not available" | |
| fi | |
| subhdr "DNS Resolvers" | |
| if [[ -r /etc/resolv.conf ]]; then | |
| grep -vE '^\s*(#|$)' /etc/resolv.conf | |
| else | |
| note "/etc/resolv.conf not readable" | |
| fi | |
| if has_cmd resolvectl; then | |
| printf '\n' | |
| safe_run resolvectl status | head -40 | |
| fi | |
| subhdr "Listening Sockets (TCP + UDP)" | |
| if has_cmd ss; then | |
| ss -tulpn 2>/dev/null || ss -tuln | |
| elif has_cmd netstat; then | |
| netstat -tulpn 2>/dev/null || netstat -tuln | |
| else | |
| note "neither ss nor netstat available" | |
| fi | |
| subhdr "Active Connections (Summary)" | |
| if has_cmd ss; then | |
| ss -s | |
| else | |
| note "ss not available" | |
| fi | |
| } | |
| collect_network_json() { | |
| local interfaces_json routes_json dns_json listen_json | |
| if has_cmd ip; then | |
| interfaces_json=$(ip -brief address 2>/dev/null | \ | |
| awk '{ | |
| name=$1; state=$2; | |
| addrs=""; | |
| for (i=3; i<=NF; i++) { | |
| if (addrs != "") addrs = addrs "\t"; | |
| addrs = addrs $i; | |
| } | |
| printf "%s\t%s\t%s\n", name, state, addrs | |
| }' | jq -Rn ' | |
| [ inputs | split("\t") | | |
| { name: .[0], | |
| state: .[1], | |
| addresses: (.[2:] | map(select(length > 0))) } | |
| ]') | |
| routes_json=$(ip -oneline route 2>/dev/null | lines_to_json_array) | |
| else | |
| interfaces_json='[]' | |
| routes_json='[]' | |
| fi | |
| if [[ -r /etc/resolv.conf ]]; then | |
| dns_json=$(awk '$1 == "nameserver" {print $2}' /etc/resolv.conf | lines_to_json_array) | |
| else | |
| dns_json='[]' | |
| fi | |
| if has_cmd ss; then | |
| listen_json=$(ss -tulnH 2>/dev/null | \ | |
| awk '{ | |
| proto=$1; state=$2; local=$5; | |
| n = split(local, parts, ":"); | |
| port = parts[n]; | |
| addr = local; | |
| sub(":[0-9]+$", "", addr); | |
| printf "%s\t%s\t%s\n", proto, addr, port | |
| }' | jq -Rn ' | |
| [ inputs | split("\t") | | |
| { protocol: .[0], | |
| address: .[1], | |
| port: (.[2] | tonumber? // 0) } | |
| ]') | |
| else | |
| listen_json='[]' | |
| fi | |
| jq -n \ | |
| --argjson interfaces "${interfaces_json}" \ | |
| --argjson routes "${routes_json}" \ | |
| --argjson dns "${dns_json}" \ | |
| --argjson listening "${listen_json}" \ | |
| '{ | |
| interfaces: $interfaces, | |
| routes: $routes, | |
| dns_servers: $dns, | |
| listening_sockets: $listening | |
| }' | |
| } | |
| collect_network() { | |
| if [[ ${OPT_JSON} -eq 1 ]]; then | |
| collect_network_json | |
| else | |
| collect_network_text | |
| fi | |
| } | |
| #___________________________________________________________________________ | |
| # Collector: services | |
| collect_services_text() { | |
| hdr "SERVICES" | |
| if ! has_cmd systemctl; then | |
| note "systemctl not available (non-systemd system?)" | |
| return 0 | |
| fi | |
| subhdr "Running Services" | |
| safe_run systemctl list-units --type=service --state=running --no-pager --no-legend \ | |
| || note "unable to list services" | |
| subhdr "Failed Units" | |
| local failed | |
| failed=$(safe_capture systemctl list-units --state=failed --no-pager --no-legend) | |
| if [[ -z "${failed}" ]]; then | |
| note "no failed units" | |
| else | |
| printf '%s\n' "${failed}" | |
| fi | |
| subhdr "Timers" | |
| safe_run systemctl list-timers --all --no-pager \ | |
| || note "unable to list timers" | |
| subhdr "Enabled at Boot" | |
| safe_capture systemctl list-unit-files --state=enabled --no-pager --no-legend \ | |
| | awk '{print $1}' | |
| } | |
| collect_services_json() { | |
| if ! has_cmd systemctl; then | |
| jq -n '{ available: false, reason: "systemctl not present" }' | |
| return 0 | |
| fi | |
| local running_json failed_json enabled_json timers_json | |
| running_json=$(safe_capture systemctl list-units --type=service --state=running --no-pager --no-legend \ | |
| | awk '{print $1}' | lines_to_json_array) | |
| failed_json=$(safe_capture systemctl list-units --state=failed --no-pager --no-legend \ | |
| | awk '{print $2}' | lines_to_json_array) | |
| enabled_json=$(safe_capture systemctl list-unit-files --state=enabled --no-pager --no-legend \ | |
| | awk '{print $1}' | lines_to_json_array) | |
| timers_json=$(safe_capture systemctl list-timers --all --no-pager --no-legend \ | |
| | awk '{print $NF}' | lines_to_json_array) | |
| jq -n \ | |
| --argjson running "${running_json}" \ | |
| --argjson failed "${failed_json}" \ | |
| --argjson enabled "${enabled_json}" \ | |
| --argjson timers "${timers_json}" \ | |
| '{ | |
| available: true, | |
| running: $running, | |
| failed: $failed, | |
| enabled_at_boot: $enabled, | |
| timers: $timers | |
| }' | |
| } | |
| collect_services() { | |
| if [[ ${OPT_JSON} -eq 1 ]]; then | |
| collect_services_json | |
| else | |
| collect_services_text | |
| fi | |
| } | |
| #___________________________________________________________________________ | |
| # Collector: processes | |
| collect_processes_text() { | |
| hdr "PROCESSES" | |
| subhdr "Process Counts" | |
| local total | |
| total=$(ps -eo pid --no-headers 2>/dev/null | wc -l) | |
| printf 'total processes: %s\n' "${total}" | |
| printf 'processes by user:\n' | |
| ps -eo user --no-headers 2>/dev/null | sort | uniq -c | sort -rn | head -20 | |
| subhdr "Top 15 by CPU" | |
| ps -eo pid,user,pcpu,pmem,rss,comm --sort=-pcpu --no-headers 2>/dev/null | head -15 | |
| subhdr "Top 15 by Memory" | |
| ps -eo pid,user,pcpu,pmem,rss,comm --sort=-rss --no-headers 2>/dev/null | head -15 | |
| subhdr "Open File Descriptors" | |
| if [[ -r /proc/sys/fs/file-nr ]]; then | |
| read -r fd_alloc fd_unused fd_max < /proc/sys/fs/file-nr | |
| printf 'allocated: %s unused: %s max: %s\n' \ | |
| "${fd_alloc}" "${fd_unused}" "${fd_max}" | |
| else | |
| note "/proc/sys/fs/file-nr not readable" | |
| fi | |
| } | |
| collect_processes_json() { | |
| local total top_cpu_json top_mem_json fd_alloc fd_unused fd_max | |
| total=$(ps -eo pid --no-headers 2>/dev/null | wc -l | tr -d ' ') | |
| top_cpu_json=$(ps -eo pid,user,pcpu,pmem,rss,comm --sort=-pcpu --no-headers 2>/dev/null | head -15 \ | |
| | awk '{pid=$1; user=$2; pcpu=$3; pmem=$4; rss=$5; | |
| cmd=$6; for (i=7;i<=NF;i++) cmd=cmd" "$i; | |
| printf "%s\t%s\t%s\t%s\t%s\t%s\n", pid, user, pcpu, pmem, rss, cmd}' \ | |
| | jq -Rn '[ inputs | split("\t") | | |
| { pid: (.[0] | tonumber? // 0), | |
| user: .[1], | |
| cpu_percent: (.[2] | tonumber? // 0), | |
| memory_percent: (.[3] | tonumber? // 0), | |
| rss_kb: (.[4] | tonumber? // 0), | |
| command: .[5] } ]') | |
| top_mem_json=$(ps -eo pid,user,pcpu,pmem,rss,comm --sort=-rss --no-headers 2>/dev/null | head -15 \ | |
| | awk '{pid=$1; user=$2; pcpu=$3; pmem=$4; rss=$5; | |
| cmd=$6; for (i=7;i<=NF;i++) cmd=cmd" "$i; | |
| printf "%s\t%s\t%s\t%s\t%s\t%s\n", pid, user, pcpu, pmem, rss, cmd}' \ | |
| | jq -Rn '[ inputs | split("\t") | | |
| { pid: (.[0] | tonumber? // 0), | |
| user: .[1], | |
| cpu_percent: (.[2] | tonumber? // 0), | |
| memory_percent: (.[3] | tonumber? // 0), | |
| rss_kb: (.[4] | tonumber? // 0), | |
| command: .[5] } ]') | |
| if [[ -r /proc/sys/fs/file-nr ]]; then | |
| read -r fd_alloc fd_unused fd_max < /proc/sys/fs/file-nr | |
| fi | |
| jq -n \ | |
| --arg total "${total:-0}" \ | |
| --arg fd_alloc "${fd_alloc:-0}" \ | |
| --arg fd_unused "${fd_unused:-0}" \ | |
| --arg fd_max "${fd_max:-0}" \ | |
| --argjson top_cpu "${top_cpu_json}" \ | |
| --argjson top_mem "${top_mem_json}" \ | |
| '{ | |
| total_count: ($total | tonumber? // 0), | |
| file_descriptors: { | |
| allocated: ($fd_alloc | tonumber? // 0), | |
| unused: ($fd_unused | tonumber? // 0), | |
| maximum: ($fd_max | tonumber? // 0) | |
| }, | |
| top_cpu: $top_cpu, | |
| top_memory: $top_mem | |
| }' | |
| } | |
| collect_processes() { | |
| if [[ ${OPT_JSON} -eq 1 ]]; then | |
| collect_processes_json | |
| else | |
| collect_processes_text | |
| fi | |
| } | |
| #___________________________________________________________________________ | |
| # Collector: docker | |
| collect_docker_text() { | |
| hdr "DOCKER" | |
| if ! has_cmd docker; then | |
| note "docker command not found" | |
| return 0 | |
| fi | |
| subhdr "Daemon Status" | |
| if ! safe_run docker info >/dev/null; then | |
| note "docker daemon unreachable or insufficient permission" | |
| return 0 | |
| fi | |
| safe_run docker info --format \ | |
| 'server_version: {{.ServerVersion}} | |
| storage_driver: {{.Driver}} | |
| logging_driver: {{.LoggingDriver}} | |
| cgroup_driver: {{.CgroupDriver}} | |
| containers: {{.Containers}} (running: {{.ContainersRunning}}, paused: {{.ContainersPaused}}, stopped: {{.ContainersStopped}}) | |
| images: {{.Images}}' | |
| subhdr "Containers (All States)" | |
| docker ps -a --format 'table {{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}' 2>/dev/null | |
| subhdr "Images" | |
| docker images --format 'table {{.Repository}}\t{{.Tag}}\t{{.ID}}\t{{.Size}}\t{{.CreatedSince}}' 2>/dev/null | |
| subhdr "Volumes" | |
| docker volume ls 2>/dev/null | |
| subhdr "Networks" | |
| docker network ls 2>/dev/null | |
| subhdr "Disk Usage" | |
| docker system df 2>/dev/null | |
| subhdr "Compose Projects (Detected)" | |
| docker ps -a --format '{{.Labels}}' 2>/dev/null \ | |
| | tr ',' '\n' \ | |
| | grep -E '^com\.docker\.compose\.project=' \ | |
| | sort -u \ | |
| | sed 's/^com.docker.compose.project=/project: /' \ | |
| || note "no compose projects detected" | |
| } | |
| collect_docker_json() { | |
| if ! has_cmd docker; then | |
| jq -n '{ available: false, reason: "docker command not found" }' | |
| return 0 | |
| fi | |
| if ! safe_run docker info >/dev/null; then | |
| jq -n '{ available: false, reason: "docker daemon unreachable or insufficient permission" }' | |
| return 0 | |
| fi | |
| local info_json containers_json images_json volumes_json networks_json | |
| info_json=$(docker info --format '{{json .}}' 2>/dev/null \ | |
| | jq '{ | |
| server_version: .ServerVersion, | |
| storage_driver: .Driver, | |
| logging_driver: .LoggingDriver, | |
| cgroup_driver: .CgroupDriver, | |
| containers_total: .Containers, | |
| containers_running: .ContainersRunning, | |
| containers_paused: .ContainersPaused, | |
| containers_stopped: .ContainersStopped, | |
| images: .Images | |
| }') | |
| containers_json=$(docker ps -a --format '{{json .}}' 2>/dev/null \ | |
| | jq -s '[ .[] | { | |
| id: .ID, | |
| name: .Names, | |
| image: .Image, | |
| state: .State, | |
| status: .Status, | |
| ports: .Ports, | |
| labels: .Labels, | |
| created: .CreatedAt | |
| } ]') | |
| images_json=$(docker images --format '{{json .}}' 2>/dev/null \ | |
| | jq -s '[ .[] | { | |
| repository: .Repository, | |
| tag: .Tag, | |
| id: .ID, | |
| size: .Size, | |
| created: .CreatedSince | |
| } ]') | |
| volumes_json=$(docker volume ls --format '{{json .}}' 2>/dev/null \ | |
| | jq -s '[ .[] | { name: .Name, driver: .Driver, mountpoint: (.Mountpoint // null) } ]') | |
| networks_json=$(docker network ls --format '{{json .}}' 2>/dev/null \ | |
| | jq -s '[ .[] | { id: .ID, name: .Name, driver: .Driver, scope: .Scope } ]') | |
| jq -n \ | |
| --argjson info "${info_json}" \ | |
| --argjson containers "${containers_json}" \ | |
| --argjson images "${images_json}" \ | |
| --argjson volumes "${volumes_json}" \ | |
| --argjson networks "${networks_json}" \ | |
| '{ | |
| available: true, | |
| daemon: $info, | |
| containers: $containers, | |
| images: $images, | |
| volumes: $volumes, | |
| networks: $networks | |
| }' | |
| } | |
| collect_docker() { | |
| if [[ ${OPT_JSON} -eq 1 ]]; then | |
| collect_docker_json | |
| else | |
| collect_docker_text | |
| fi | |
| } | |
| #___________________________________________________________________________ | |
| # Collector: packages | |
| collect_packages_text() { | |
| hdr "PACKAGES" | |
| subhdr "Package Manager Detection" | |
| local pm="" | |
| for candidate in apt dpkg dnf yum pacman apk zypper; do | |
| if has_cmd "${candidate}"; then | |
| printf 'detected: %s\n' "${candidate}" | |
| pm="${pm:-${candidate}}" | |
| fi | |
| done | |
| [[ -z "${pm}" ]] && note "no known package manager found" | |
| if has_cmd dpkg; then | |
| subhdr "Installed Package Count (dpkg)" | |
| dpkg --get-selections 2>/dev/null | grep -c 'install$' \ | |
| | xargs -I{} echo "installed packages: {}" | |
| subhdr "Recently Installed (last 30 days)" | |
| if ls /var/log/dpkg.log* >/dev/null 2>&1; then | |
| local cutoff | |
| cutoff=$(date -d '30 days ago' '+%Y-%m-%d' 2>/dev/null || date '+%Y-%m-%d') | |
| zgrep -h ' install ' /var/log/dpkg.log* 2>/dev/null \ | |
| | awk -v cutoff="${cutoff}" '$1 >= cutoff {print $1, $2, $4}' \ | |
| | sort -u | tail -40 | |
| else | |
| note "no /var/log/dpkg.log* files found" | |
| fi | |
| fi | |
| if has_cmd apt; then | |
| subhdr "Upgradable (apt)" | |
| safe_run apt list --upgradable 2>/dev/null | tail -n +2 | head -50 \ | |
| || note "unable to query apt" | |
| fi | |
| if has_cmd snap; then | |
| subhdr "Snap Packages" | |
| safe_run snap list || note "snap present but query failed" | |
| fi | |
| if has_cmd flatpak; then | |
| subhdr "Flatpak Packages" | |
| safe_run flatpak list || note "flatpak present but query failed" | |
| fi | |
| } | |
| collect_packages_json() { | |
| local manager="" installed_count=0 upgradable_json='[]' recent_json='[]' | |
| for candidate in apt dpkg dnf yum pacman apk zypper; do | |
| if has_cmd "${candidate}"; then | |
| manager="${manager:-${candidate}}" | |
| fi | |
| done | |
| if has_cmd dpkg; then | |
| installed_count=$(dpkg --get-selections 2>/dev/null | grep -c 'install$') | |
| fi | |
| if has_cmd apt; then | |
| upgradable_json=$(safe_run apt list --upgradable 2>/dev/null \ | |
| | tail -n +2 \ | |
| | awk -F/ '{print $1}' \ | |
| | grep -v '^$' \ | |
| | lines_to_json_array) | |
| fi | |
| if ls /var/log/dpkg.log* >/dev/null 2>&1; then | |
| local cutoff | |
| cutoff=$(date -d '30 days ago' '+%Y-%m-%d' 2>/dev/null || date '+%Y-%m-%d') | |
| recent_json=$(zgrep -h ' install ' /var/log/dpkg.log* 2>/dev/null \ | |
| | awk -v cutoff="${cutoff}" '$1 >= cutoff {printf "%s\t%s\t%s\n", $1, $2, $4}' \ | |
| | sort -u \ | |
| | jq -Rn '[ inputs | split("\t") | | |
| { date: .[0], time: .[1], package: .[2] } ]') | |
| fi | |
| jq -n \ | |
| --arg manager "${manager}" \ | |
| --arg installed "${installed_count}" \ | |
| --argjson upgradable "${upgradable_json}" \ | |
| --argjson recent "${recent_json}" \ | |
| '{ | |
| manager: (if $manager == "" then null else $manager end), | |
| installed_count: ($installed | tonumber? // 0), | |
| upgradable: $upgradable, | |
| recent_installs: $recent | |
| }' | |
| } | |
| collect_packages() { | |
| if [[ ${OPT_JSON} -eq 1 ]]; then | |
| collect_packages_json | |
| else | |
| collect_packages_text | |
| fi | |
| } | |
| #___________________________________________________________________________ | |
| # Collector: cron | |
| collect_cron_text() { | |
| hdr "CRON" | |
| subhdr "/etc/crontab" | |
| if [[ -r /etc/crontab ]]; then | |
| grep -vE '^\s*(#|$)' /etc/crontab | |
| else | |
| note "/etc/crontab not readable" | |
| fi | |
| subhdr "/etc/cron.d" | |
| if [[ -d /etc/cron.d ]]; then | |
| for f in /etc/cron.d/*; do | |
| [[ -f "${f}" ]] || continue | |
| printf '### %s ###\n' "${f}" | |
| grep -vE '^\s*(#|$)' "${f}" 2>/dev/null || true | |
| printf '\n' | |
| done | |
| else | |
| note "/etc/cron.d not present" | |
| fi | |
| subhdr "Periodic Cron Directories" | |
| for d in /etc/cron.hourly /etc/cron.daily /etc/cron.weekly /etc/cron.monthly; do | |
| if [[ -d "${d}" ]]; then | |
| printf '### %s ###\n' "${d}" | |
| ls -1 "${d}" 2>/dev/null | |
| printf '\n' | |
| fi | |
| done | |
| subhdr "User Crontabs" | |
| if [[ -n "${SYSINV_SKIP_USER_CRONTAB:-}" ]]; then | |
| note "SYSINV_SKIP_USER_CRONTAB set; skipping user crontab probe" | |
| elif is_root; then | |
| local spool_dir="" | |
| [[ -d /var/spool/cron/crontabs ]] && spool_dir=/var/spool/cron/crontabs | |
| [[ -z "${spool_dir}" && -d /var/spool/cron ]] && spool_dir=/var/spool/cron | |
| if [[ -n "${spool_dir}" ]]; then | |
| for f in "${spool_dir}"/*; do | |
| [[ -f "${f}" ]] || continue | |
| printf '### %s ###\n' "$(basename "${f}")" | |
| cat "${f}" 2>/dev/null | |
| printf '\n' | |
| done | |
| else | |
| note "no user crontab directory found" | |
| fi | |
| else | |
| note "root required for all users' crontabs; showing current user only" | |
| printf '### %s ###\n' "$(id -un)" | |
| safe_run crontab -l || note "no crontab for $(id -un) (or crontab command unresponsive)" | |
| fi | |
| subhdr "systemd Timers" | |
| if has_cmd systemctl; then | |
| safe_run systemctl list-timers --all --no-pager \ | |
| || note "unable to list timers" | |
| else | |
| note "systemctl not available" | |
| fi | |
| subhdr "anacron" | |
| if [[ -r /etc/anacrontab ]]; then | |
| grep -vE '^\s*(#|$)' /etc/anacrontab | |
| else | |
| note "/etc/anacrontab not present" | |
| fi | |
| } | |
| collect_cron_json() { | |
| debug_log "cron: entering collector" | |
| local system_crontab cron_d_json periodic_json user_ct_json timers_json anacron | |
| debug_log "cron: reading /etc/crontab" | |
| if [[ -r /etc/crontab ]]; then | |
| system_crontab=$(safe_capture grep -vE '^\s*(#|$)' /etc/crontab | jq -Rs '.') | |
| else | |
| system_crontab='null' | |
| fi | |
| [[ -z "${system_crontab}" ]] && system_crontab='null' | |
| debug_log "cron: iterating /etc/cron.d" | |
| cron_d_json='[]' | |
| if [[ -d /etc/cron.d ]]; then | |
| for f in /etc/cron.d/*; do | |
| [[ -f "${f}" ]] || continue | |
| debug_log "cron: reading ${f}" | |
| local content | |
| content=$(safe_capture grep -vE '^\s*(#|$)' "${f}" || true) | |
| cron_d_json=$(jq \ | |
| --arg path "${f}" \ | |
| --arg content "${content}" \ | |
| '. + [{ path: $path, content: $content }]' \ | |
| <<< "${cron_d_json}") | |
| [[ -z "${cron_d_json}" ]] && cron_d_json='[]' | |
| done | |
| fi | |
| debug_log "cron: iterating periodic directories" | |
| periodic_json='{}' | |
| for d in hourly daily weekly monthly; do | |
| local dir="/etc/cron.${d}" | |
| if [[ -d "${dir}" ]]; then | |
| debug_log "cron: listing ${dir}" | |
| local entries | |
| entries=$(safe_capture ls -1 "${dir}" | lines_to_json_array) | |
| [[ -z "${entries}" ]] && entries='[]' | |
| periodic_json=$(jq --arg name "${d}" --argjson entries "${entries}" \ | |
| '. + { ($name): $entries }' <<< "${periodic_json}") | |
| [[ -z "${periodic_json}" ]] && periodic_json='{}' | |
| fi | |
| done | |
| debug_log "cron: collecting user crontabs" | |
| user_ct_json='[]' | |
| if [[ -n "${SYSINV_SKIP_USER_CRONTAB:-}" ]]; then | |
| debug_log "cron: SYSINV_SKIP_USER_CRONTAB set; skipping user crontab probe" | |
| user_ct_json=$(jq -n \ | |
| '[{ skipped: true, reason: "SYSINV_SKIP_USER_CRONTAB was set" }]') | |
| elif is_root; then | |
| local spool_dir="" | |
| [[ -d /var/spool/cron/crontabs ]] && spool_dir=/var/spool/cron/crontabs | |
| [[ -z "${spool_dir}" && -d /var/spool/cron ]] && spool_dir=/var/spool/cron | |
| if [[ -n "${spool_dir}" ]]; then | |
| for f in "${spool_dir}"/*; do | |
| [[ -f "${f}" ]] || continue | |
| debug_log "cron: reading user crontab ${f}" | |
| local user_name content | |
| user_name=$(basename "${f}") | |
| content=$(safe_capture cat "${f}" || true) | |
| user_ct_json=$(jq \ | |
| --arg user "${user_name}" \ | |
| --arg content "${content}" \ | |
| '. + [{ user: $user, content: $content }]' \ | |
| <<< "${user_ct_json}") | |
| [[ -z "${user_ct_json}" ]] && user_ct_json='[]' | |
| done | |
| fi | |
| else | |
| debug_log "cron: non-root branch; reading current user's crontab" | |
| local current content | |
| current=$(id -un) | |
| content=$(safe_capture crontab -l || printf '') | |
| user_ct_json=$(jq -n \ | |
| --arg user "${current}" \ | |
| --arg content "${content}" \ | |
| '[{ user: $user, content: $content, note: "non-root run; only current user shown" }]') | |
| fi | |
| [[ -z "${user_ct_json}" ]] && user_ct_json='[]' | |
| debug_log "cron: listing systemd timers" | |
| if has_cmd systemctl; then | |
| timers_json=$(safe_capture systemctl list-timers --all --no-pager --no-legend \ | |
| | awk '{print $NF}' \ | |
| | lines_to_json_array) | |
| else | |
| timers_json='[]' | |
| fi | |
| [[ -z "${timers_json}" ]] && timers_json='[]' | |
| debug_log "cron: reading /etc/anacrontab" | |
| if [[ -r /etc/anacrontab ]]; then | |
| anacron=$(safe_capture grep -vE '^\s*(#|$)' /etc/anacrontab | jq -Rs '.') | |
| else | |
| anacron='null' | |
| fi | |
| [[ -z "${anacron}" ]] && anacron='null' | |
| debug_log "cron: assembling final JSON" | |
| jq -n \ | |
| --argjson system "${system_crontab}" \ | |
| --argjson cron_d "${cron_d_json}" \ | |
| --argjson periodic "${periodic_json}" \ | |
| --argjson user_crons "${user_ct_json}" \ | |
| --argjson timers "${timers_json}" \ | |
| --argjson anacron "${anacron}" \ | |
| '{ | |
| system_crontab: $system, | |
| cron_d: $cron_d, | |
| periodic: $periodic, | |
| user_crontabs: $user_crons, | |
| systemd_timers: $timers, | |
| anacrontab: $anacron | |
| }' | |
| debug_log "cron: exiting collector" | |
| } | |
| collect_cron() { | |
| if [[ ${OPT_JSON} -eq 1 ]]; then | |
| collect_cron_json | |
| else | |
| collect_cron_text | |
| fi | |
| } | |
| #___________________________________________________________________________ | |
| # Collector: access | |
| collect_access_text() { | |
| hdr "ACCESS" | |
| subhdr "Current Sessions" | |
| if has_cmd w; then | |
| w | |
| elif has_cmd who; then | |
| who | |
| else | |
| note "neither w nor who available" | |
| fi | |
| subhdr "Last 20 Logins" | |
| if has_cmd last; then | |
| safe_run last -n 20 || note "unable to read wtmp" | |
| else | |
| note "last command not available" | |
| fi | |
| subhdr "Human Accounts (UID >= 1000)" | |
| awk -F: '$3 >= 1000 && $3 < 65534 {printf "%s (uid=%s, shell=%s, home=%s)\n", $1, $3, $7, $6}' /etc/passwd | |
| subhdr "Sudoers Group Membership" | |
| for grp in sudo wheel admin; do | |
| if getent group "${grp}" >/dev/null 2>&1; then | |
| printf '%s: %s\n' "${grp}" "$(getent group "${grp}" | cut -d: -f4)" | |
| fi | |
| done | |
| subhdr "SSH Daemon Config (non-default)" | |
| if [[ -r /etc/ssh/sshd_config ]]; then | |
| grep -vE '^\s*(#|$)' /etc/ssh/sshd_config | |
| else | |
| note "/etc/ssh/sshd_config not readable" | |
| fi | |
| } | |
| collect_access_json() { | |
| local sessions_json human_json sudoers_json sshd_json | |
| if has_cmd who; then | |
| sessions_json=$(who 2>/dev/null | awk '{printf "%s\t%s\t%s %s\t%s\n", $1, $2, $3, $4, $5}' \ | |
| | jq -Rn '[ inputs | split("\t") | | |
| { user: .[0], terminal: .[1], login_time: .[2], source: (.[3] // "") } ]') | |
| else | |
| sessions_json='[]' | |
| fi | |
| human_json=$(awk -F: '$3 >= 1000 && $3 < 65534 {printf "%s\t%s\t%s\t%s\n", $1, $3, $7, $6}' /etc/passwd \ | |
| | jq -Rn '[ inputs | split("\t") | | |
| { name: .[0], | |
| uid: (.[1] | tonumber? // 0), | |
| shell: .[2], | |
| home: .[3] } ]') | |
| sudoers_json='[]' | |
| for grp in sudo wheel admin; do | |
| if getent group "${grp}" >/dev/null 2>&1; then | |
| local members | |
| members=$(getent group "${grp}" | cut -d: -f4) | |
| local members_json | |
| members_json=$(printf '%s' "${members}" | tr ',' '\n' \ | |
| | grep -v '^$' | lines_to_json_array) | |
| sudoers_json=$(jq --arg name "${grp}" --argjson members "${members_json}" \ | |
| '. + [{ group: $name, members: $members }]' <<< "${sudoers_json}") | |
| fi | |
| done | |
| if [[ -r /etc/ssh/sshd_config ]]; then | |
| sshd_json=$(grep -vE '^\s*(#|$)' /etc/ssh/sshd_config \ | |
| | awk '{k=$1; sub(/^[^ \t]+[ \t]+/, ""); printf "%s\t%s\n", k, $0}' \ | |
| | jq -Rn '[ inputs | split("\t") | { directive: .[0], value: .[1] } ]') | |
| else | |
| sshd_json='null' | |
| fi | |
| jq -n \ | |
| --argjson sessions "${sessions_json}" \ | |
| --argjson humans "${human_json}" \ | |
| --argjson sudoers "${sudoers_json}" \ | |
| --argjson sshd "${sshd_json}" \ | |
| '{ | |
| current_sessions: $sessions, | |
| human_accounts: $humans, | |
| sudoers_groups: $sudoers, | |
| sshd_config: $sshd | |
| }' | |
| } | |
| collect_access() { | |
| if [[ ${OPT_JSON} -eq 1 ]]; then | |
| collect_access_json | |
| else | |
| collect_access_text | |
| fi | |
| } | |
| #___________________________________________________________________________ | |
| # Collector: storage | |
| collect_storage_text() { | |
| hdr "STORAGE" | |
| subhdr "Filesystem Usage" | |
| df -hT -x tmpfs -x devtmpfs -x squashfs -x overlay 2>/dev/null || df -h | |
| subhdr "Inode Usage" | |
| df -i -x tmpfs -x devtmpfs -x squashfs -x overlay 2>/dev/null || df -i | |
| subhdr "Mount Points" | |
| if has_cmd findmnt; then | |
| findmnt --types=nosysfs,nocgroup,noproc,notmpfs,nodevtmpfs 2>/dev/null \ | |
| || findmnt 2>/dev/null || mount | |
| else | |
| mount | grep -vE '^(sysfs|proc|cgroup|tmpfs|devtmpfs)' | |
| fi | |
| subhdr "Largest Directories (per common root)" | |
| for root in /home /opt /mnt /srv /var; do | |
| [[ -d "${root}" ]] || continue | |
| printf '### %s ###\n' "${root}" | |
| du -hx --max-depth=1 "${root}" 2>/dev/null | sort -h | tail -15 | |
| printf '\n' | |
| done | |
| subhdr "Largest Files Under /var/log" | |
| if [[ -d /var/log ]]; then | |
| find /var/log -type f -size +10M 2>/dev/null \ | |
| | xargs -r du -h 2>/dev/null \ | |
| | sort -h | tail -20 \ | |
| || note "no large log files found" | |
| fi | |
| } | |
| collect_storage_json() { | |
| local fs_json inodes_json mounts_json large_dirs_json | |
| fs_json=$(df -PT -x tmpfs -x devtmpfs -x squashfs -x overlay 2>/dev/null \ | |
| | tail -n +2 \ | |
| | awk '{ | |
| printf "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", | |
| $1, $2, $3, $4, $5, $6, $7 | |
| }' \ | |
| | jq -Rn '[ inputs | split("\t") | | |
| { source: .[0], | |
| fstype: .[1], | |
| size_blocks: (.[2] | tonumber? // 0), | |
| used_blocks: (.[3] | tonumber? // 0), | |
| available_blocks: (.[4] | tonumber? // 0), | |
| used_percent: .[5], | |
| mount_point: .[6] } ]') | |
| inodes_json=$(df -iP -x tmpfs -x devtmpfs -x squashfs -x overlay 2>/dev/null \ | |
| | tail -n +2 \ | |
| | awk '{ | |
| printf "%s\t%s\t%s\t%s\t%s\t%s\n", | |
| $1, $2, $3, $4, $5, $6 | |
| }' \ | |
| | jq -Rn '[ inputs | split("\t") | | |
| { source: .[0], | |
| inodes: (.[1] | tonumber? // 0), | |
| inodes_used: (.[2] | tonumber? // 0), | |
| inodes_free: (.[3] | tonumber? // 0), | |
| used_percent: .[4], | |
| mount_point: .[5] } ]') | |
| if has_cmd findmnt; then | |
| mounts_json=$(findmnt --json --types=nosysfs,nocgroup,noproc,notmpfs,nodevtmpfs 2>/dev/null \ | |
| | jq '.filesystems // []' 2>/dev/null || printf '[]') | |
| else | |
| mounts_json='[]' | |
| fi | |
| large_dirs_json='[]' | |
| for root in /home /opt /mnt /srv /var; do | |
| [[ -d "${root}" ]] || continue | |
| local entries | |
| entries=$(du -hx --max-depth=1 "${root}" 2>/dev/null | sort -h | tail -15 \ | |
| | awk '{printf "%s\t%s\n", $1, $2}' \ | |
| | jq -Rn '[ inputs | split("\t") | { size: .[0], path: .[1] } ]') | |
| large_dirs_json=$(jq --arg root "${root}" --argjson entries "${entries}" \ | |
| '. + [{ root: $root, entries: $entries }]' <<< "${large_dirs_json}") | |
| done | |
| jq -n \ | |
| --argjson filesystems "${fs_json}" \ | |
| --argjson inodes "${inodes_json}" \ | |
| --argjson mounts "${mounts_json}" \ | |
| --argjson large_dirs "${large_dirs_json}" \ | |
| '{ | |
| filesystems: $filesystems, | |
| inodes: $inodes, | |
| mounts: $mounts, | |
| largest_directories: $large_dirs | |
| }' | |
| } | |
| collect_storage() { | |
| if [[ ${OPT_JSON} -eq 1 ]]; then | |
| collect_storage_json | |
| else | |
| collect_storage_text | |
| fi | |
| } | |
| #___________________________________________________________________________ | |
| # Collector: logs | |
| collect_logs_text() { | |
| hdr "LOGS" | |
| if has_cmd journalctl; then | |
| subhdr "Journal Disk Usage" | |
| safe_run journalctl --disk-usage | |
| subhdr "Boot Count" | |
| safe_run journalctl --list-boots --no-pager | tail -10 \ | |
| || note "unable to list boots" | |
| subhdr "Recent Errors (priority 0-3, last 24h)" | |
| safe_run journalctl -p 3 --since '24 hours ago' --no-pager | tail -50 \ | |
| || note "unable to read journal" | |
| subhdr "Units With Recent Failures (last 7 days)" | |
| safe_run journalctl -p 3 --since '7 days ago' --no-pager \ | |
| | awk '{print $5}' | sort | uniq -c | sort -rn | head -15 | |
| else | |
| note "journalctl not available" | |
| fi | |
| subhdr "Log File Sizes (/var/log, top 20)" | |
| if [[ -d /var/log ]]; then | |
| find /var/log -type f 2>/dev/null \ | |
| | xargs -r du -h 2>/dev/null \ | |
| | sort -h | tail -20 | |
| else | |
| note "/var/log not present" | |
| fi | |
| } | |
| collect_logs_json() { | |
| local journal_available=false | |
| local journal_disk="" boots_json='[]' errors_json='[]' log_sizes_json='[]' | |
| if has_cmd journalctl; then | |
| journal_available=true | |
| journal_disk=$(safe_run journalctl --disk-usage | tail -1) | |
| boots_json=$(safe_run journalctl --list-boots --no-pager \ | |
| | lines_to_json_array) | |
| errors_json=$(safe_run journalctl -p 3 --since '24 hours ago' --no-pager \ | |
| | tail -50 | lines_to_json_array) | |
| fi | |
| if [[ -d /var/log ]]; then | |
| log_sizes_json=$(find /var/log -type f 2>/dev/null \ | |
| | xargs -r du -h 2>/dev/null \ | |
| | sort -h | tail -20 \ | |
| | awk '{printf "%s\t%s\n", $1, $2}' \ | |
| | jq -Rn '[ inputs | split("\t") | { size: .[0], path: .[1] } ]') | |
| fi | |
| jq -n \ | |
| --argjson available "${journal_available}" \ | |
| --arg disk_usage "${journal_disk}" \ | |
| --argjson boots "${boots_json}" \ | |
| --argjson errors "${errors_json}" \ | |
| --argjson log_sizes "${log_sizes_json}" \ | |
| '{ | |
| journal_available: $available, | |
| journal_disk_usage: $disk_usage, | |
| boots: $boots, | |
| recent_errors_24h: $errors, | |
| log_file_sizes: $log_sizes | |
| }' | |
| } | |
| collect_logs() { | |
| if [[ ${OPT_JSON} -eq 1 ]]; then | |
| collect_logs_json | |
| else | |
| collect_logs_text | |
| fi | |
| } | |
| #___________________________________________________________________________ | |
| # Collector: firewall | |
| collect_firewall_text() { | |
| hdr "FIREWALL" | |
| subhdr "ufw" | |
| if has_cmd ufw; then | |
| if is_root; then | |
| safe_run ufw status verbose | |
| else | |
| note "root required for ufw status; skipping" | |
| fi | |
| else | |
| note "ufw not installed" | |
| fi | |
| subhdr "iptables (filter/nat/mangle)" | |
| if has_cmd iptables; then | |
| if is_root; then | |
| for table in filter nat mangle; do | |
| printf '### table: %s ###\n' "${table}" | |
| safe_run iptables -t "${table}" -L -n -v | |
| printf '\n' | |
| done | |
| else | |
| note "root required for iptables; skipping" | |
| fi | |
| else | |
| note "iptables not installed" | |
| fi | |
| subhdr "nftables" | |
| if has_cmd nft; then | |
| if is_root; then | |
| safe_run nft list ruleset | head -100 || note "nft ruleset empty or unreadable" | |
| else | |
| note "root required for nft; skipping" | |
| fi | |
| else | |
| note "nft not installed" | |
| fi | |
| subhdr "fail2ban" | |
| if has_cmd fail2ban-client; then | |
| if is_root; then | |
| safe_run fail2ban-client status || note "fail2ban not running" | |
| else | |
| note "root required for fail2ban-client" | |
| fi | |
| else | |
| note "fail2ban not installed" | |
| fi | |
| } | |
| collect_firewall_json() { | |
| local ufw_raw='' iptables_raw='' nftables_raw='' fail2ban_raw='' | |
| if has_cmd ufw && is_root; then | |
| ufw_raw=$(safe_run ufw status verbose) | |
| fi | |
| if has_cmd iptables && is_root; then | |
| iptables_raw=$(for t in filter nat mangle; do | |
| printf '### table: %s ###\n' "${t}" | |
| safe_run iptables -t "${t}" -L -n -v | |
| done) | |
| fi | |
| if has_cmd nft && is_root; then | |
| nftables_raw=$(safe_run nft list ruleset) | |
| fi | |
| if has_cmd fail2ban-client && is_root; then | |
| fail2ban_raw=$(safe_run fail2ban-client status) | |
| fi | |
| jq -n \ | |
| --arg ufw "${ufw_raw}" \ | |
| --arg iptables "${iptables_raw}" \ | |
| --arg nft "${nftables_raw}" \ | |
| --arg fail2ban "${fail2ban_raw}" \ | |
| --argjson ufw_avail "$(has_cmd ufw && echo true || echo false)" \ | |
| --argjson iptables_avail "$(has_cmd iptables && echo true || echo false)" \ | |
| --argjson nft_avail "$(has_cmd nft && echo true || echo false)" \ | |
| --argjson f2b_avail "$(has_cmd fail2ban-client && echo true || echo false)" \ | |
| --argjson is_root "$(is_root && echo true || echo false)" \ | |
| '{ | |
| privileged: $is_root, | |
| ufw: { available: $ufw_avail, raw: (if $ufw == "" then null else $ufw end) }, | |
| iptables: { available: $iptables_avail, raw: (if $iptables == "" then null else $iptables end) }, | |
| nftables: { available: $nft_avail, raw: (if $nft == "" then null else $nft end) }, | |
| fail2ban: { available: $f2b_avail, raw: (if $fail2ban == "" then null else $fail2ban end) } | |
| }' | |
| } | |
| collect_firewall() { | |
| if [[ ${OPT_JSON} -eq 1 ]]; then | |
| collect_firewall_json | |
| else | |
| collect_firewall_text | |
| fi | |
| } | |
| #___________________________________________________________________________ | |
| # Collector: web | |
| collect_web_text() { | |
| hdr "WEB SERVERS" | |
| local found_any=0 | |
| if has_cmd nginx; then | |
| found_any=1 | |
| subhdr "nginx" | |
| safe_run nginx -v | |
| safe_run nginx -t | head -5 || true | |
| [[ -d /etc/nginx/sites-enabled ]] && \ | |
| { printf 'sites-enabled:\n'; ls -la /etc/nginx/sites-enabled/ 2>/dev/null; } | |
| [[ -d /etc/nginx/conf.d ]] && \ | |
| { printf 'conf.d:\n'; ls -la /etc/nginx/conf.d/ 2>/dev/null; } | |
| if has_cmd systemctl; then | |
| printf 'service state: %s\n' "$(systemctl is-active nginx 2>/dev/null)" | |
| fi | |
| fi | |
| if has_cmd apache2 || has_cmd httpd; then | |
| found_any=1 | |
| subhdr "apache" | |
| if has_cmd apache2; then | |
| safe_run apache2 -v | head -2 | |
| safe_run apache2ctl -S | head -30 || true | |
| else | |
| safe_run httpd -v | head -2 | |
| safe_run httpd -S | head -30 || true | |
| fi | |
| fi | |
| if has_cmd caddy; then | |
| found_any=1 | |
| subhdr "caddy" | |
| safe_run caddy version | |
| if has_cmd systemctl; then | |
| printf 'service state: %s\n' "$(systemctl is-active caddy 2>/dev/null)" | |
| fi | |
| [[ -r /etc/caddy/Caddyfile ]] && \ | |
| printf 'Caddyfile present at /etc/caddy/Caddyfile\n' | |
| fi | |
| if [[ ${found_any} -eq 0 ]]; then | |
| note "no common web servers detected (nginx, apache, caddy)" | |
| fi | |
| } | |
| collect_web_json() { | |
| local servers_json='[]' | |
| if has_cmd nginx; then | |
| local ver state sites_en conf_d | |
| ver=$(safe_run nginx -v | head -1 | sed 's/^nginx version: //') | |
| state=$(systemctl is-active nginx 2>/dev/null || printf 'unknown') | |
| sites_en='[]' | |
| [[ -d /etc/nginx/sites-enabled ]] && \ | |
| sites_en=$(ls /etc/nginx/sites-enabled/ 2>/dev/null | lines_to_json_array) | |
| conf_d='[]' | |
| [[ -d /etc/nginx/conf.d ]] && \ | |
| conf_d=$(ls /etc/nginx/conf.d/ 2>/dev/null | lines_to_json_array) | |
| local entry | |
| entry=$(jq -n \ | |
| --arg ver "${ver}" \ | |
| --arg state "${state}" \ | |
| --argjson sites "${sites_en}" \ | |
| --argjson confd "${conf_d}" \ | |
| '{ name: "nginx", version: $ver, service_state: $state, | |
| sites_enabled: $sites, conf_d: $confd }') | |
| servers_json=$(jq --argjson entry "${entry}" '. + [$entry]' <<< "${servers_json}") | |
| fi | |
| if has_cmd apache2 || has_cmd httpd; then | |
| local ver state bin | |
| bin="apache2"; has_cmd apache2 || bin="httpd" | |
| ver=$(safe_run "${bin}" -v | head -1 | sed 's/^Server version: //') | |
| state=$(systemctl is-active apache2 2>/dev/null \ | |
| || systemctl is-active httpd 2>/dev/null \ | |
| || printf 'unknown') | |
| local entry | |
| entry=$(jq -n --arg ver "${ver}" --arg state "${state}" --arg bin "${bin}" \ | |
| '{ name: "apache", binary: $bin, version: $ver, service_state: $state }') | |
| servers_json=$(jq --argjson entry "${entry}" '. + [$entry]' <<< "${servers_json}") | |
| fi | |
| if has_cmd caddy; then | |
| local ver state | |
| ver=$(safe_run caddy version | head -1) | |
| state=$(systemctl is-active caddy 2>/dev/null || printf 'unknown') | |
| local has_config=false | |
| [[ -r /etc/caddy/Caddyfile ]] && has_config=true | |
| local entry | |
| entry=$(jq -n \ | |
| --arg ver "${ver}" \ | |
| --arg state "${state}" \ | |
| --argjson has_config "${has_config}" \ | |
| '{ name: "caddy", version: $ver, service_state: $state, caddyfile_present: $has_config }') | |
| servers_json=$(jq --argjson entry "${entry}" '. + [$entry]' <<< "${servers_json}") | |
| fi | |
| jq -n --argjson servers "${servers_json}" '{ servers: $servers }' | |
| } | |
| collect_web() { | |
| if [[ ${OPT_JSON} -eq 1 ]]; then | |
| collect_web_json | |
| else | |
| collect_web_text | |
| fi | |
| } | |
| #___________________________________________________________________________ | |
| # Collector: runtimes | |
| collect_runtimes_text() { | |
| hdr "LANGUAGE RUNTIMES" | |
| subhdr "Interpreters and Compilers" | |
| local rt ver | |
| for rt in "${RUNTIME_PROBES[@]}"; do | |
| if has_cmd "${rt}"; then | |
| ver=$(safe_run "${rt}" --version | head -1) | |
| printf '%-10s %s\n' "${rt}" "${ver}" | |
| fi | |
| done | |
| subhdr "Version Managers" | |
| local -a managers=() | |
| [[ -d "${HOME}/.nvm" ]] && managers+=("nvm (${HOME}/.nvm)") | |
| [[ -d "${HOME}/.pyenv" ]] && managers+=("pyenv (${HOME}/.pyenv)") | |
| [[ -d "${HOME}/.rbenv" ]] && managers+=("rbenv (${HOME}/.rbenv)") | |
| [[ -d "${HOME}/.asdf" ]] && managers+=("asdf (${HOME}/.asdf)") | |
| [[ -d "${HOME}/.rustup" ]] && managers+=("rustup (${HOME}/.rustup)") | |
| [[ -d "${HOME}/.cargo" ]] && managers+=("cargo (${HOME}/.cargo)") | |
| [[ -d "${HOME}/.sdkman" ]] && managers+=("sdkman (${HOME}/.sdkman)") | |
| [[ -d /usr/local/go ]] && managers+=("go (/usr/local/go)") | |
| if [[ ${#managers[@]} -eq 0 ]]; then | |
| note "no version managers detected in common locations" | |
| else | |
| printf '%s\n' "${managers[@]}" | |
| fi | |
| subhdr "Globally Installed Node Packages" | |
| if has_cmd npm; then | |
| safe_run npm list -g --depth=0 | tail -n +2 | head -30 \ | |
| || note "unable to list global npm packages" | |
| else | |
| note "npm not available" | |
| fi | |
| subhdr "System Python Packages" | |
| if has_cmd pip3; then | |
| safe_run pip3 list | head -30 || note "unable to list pip packages" | |
| elif has_cmd pip; then | |
| safe_run pip list | head -30 | |
| else | |
| note "pip not available" | |
| fi | |
| } | |
| collect_runtimes_json() { | |
| local interps_json='[]' | |
| local rt ver | |
| for rt in "${RUNTIME_PROBES[@]}"; do | |
| if has_cmd "${rt}"; then | |
| ver=$(safe_run "${rt}" --version | head -1) | |
| local entry | |
| entry=$(jq -n --arg name "${rt}" --arg version "${ver}" \ | |
| '{ name: $name, version: $version }') | |
| interps_json=$(jq --argjson entry "${entry}" '. + [$entry]' <<< "${interps_json}") | |
| fi | |
| done | |
| local managers_json='[]' entry | |
| local -a candidates=( | |
| "nvm:${HOME}/.nvm" | |
| "pyenv:${HOME}/.pyenv" | |
| "rbenv:${HOME}/.rbenv" | |
| "asdf:${HOME}/.asdf" | |
| "rustup:${HOME}/.rustup" | |
| "cargo:${HOME}/.cargo" | |
| "sdkman:${HOME}/.sdkman" | |
| "go:/usr/local/go" | |
| ) | |
| for c in "${candidates[@]}"; do | |
| local name="${c%%:*}" | |
| local path="${c#*:}" | |
| if [[ -d "${path}" ]]; then | |
| entry=$(jq -n --arg name "${name}" --arg path "${path}" \ | |
| '{ name: $name, path: $path }') | |
| managers_json=$(jq --argjson entry "${entry}" '. + [$entry]' <<< "${managers_json}") | |
| fi | |
| done | |
| local npm_global_json='[]' | |
| if has_cmd npm; then | |
| npm_global_json=$(safe_run npm list -g --depth=0 --parseable 2>/dev/null \ | |
| | tail -n +2 | lines_to_json_array) | |
| fi | |
| jq -n \ | |
| --argjson interpreters "${interps_json}" \ | |
| --argjson version_managers "${managers_json}" \ | |
| --argjson npm_global "${npm_global_json}" \ | |
| '{ | |
| interpreters: $interpreters, | |
| version_managers: $version_managers, | |
| npm_global_packages: $npm_global | |
| }' | |
| } | |
| collect_runtimes() { | |
| if [[ ${OPT_JSON} -eq 1 ]]; then | |
| collect_runtimes_json | |
| else | |
| collect_runtimes_text | |
| fi | |
| } | |
| #___________________________________________________________________________ | |
| # Run Orchestration | |
| build_meta_json() { | |
| # build_meta_json: emits a metadata object describing this run. | |
| local host_name fqdn user_name run_ts enabled_json | |
| host_name=$(hostname 2>/dev/null) | |
| fqdn=$(hostname -f 2>/dev/null || printf '%s' "${host_name}") | |
| user_name=$(id -un 2>/dev/null) | |
| run_ts=$(date -u '+%Y-%m-%dT%H:%M:%SZ') | |
| local -a enabled=() | |
| for key in "${CATEGORY_ORDER[@]}"; do | |
| [[ ${CATEGORIES[${key}]:-0} -eq 1 ]] && enabled+=("${key}") | |
| done | |
| enabled_json=$(printf '%s\n' "${enabled[@]}" | lines_to_json_array) | |
| jq -n \ | |
| --arg script "${SCRIPT_NAME}" \ | |
| --arg version "${SCRIPT_VERSION}" \ | |
| --arg hostname "${host_name}" \ | |
| --arg fqdn "${fqdn}" \ | |
| --arg user "${user_name}" \ | |
| --arg uid "$(id -u)" \ | |
| --arg euid "$(id -u -n 2>/dev/null; printf '')" \ | |
| --arg timestamp "${run_ts}" \ | |
| --argjson privileged "$(is_root && echo true || echo false)" \ | |
| --argjson categories "${enabled_json}" \ | |
| '{ | |
| script: $script, | |
| version: $version, | |
| hostname: $hostname, | |
| fqdn: $fqdn, | |
| user: $user, | |
| uid: ($uid | tonumber? // 0), | |
| privileged: $privileged, | |
| timestamp: $timestamp, | |
| categories: $categories | |
| }' | |
| } | |
| run_collectors_text() { | |
| # run_collectors_text: dispatches enabled collectors in text mode in a | |
| # deterministic order so multi-run diffs are stable. | |
| for key in "${CATEGORY_ORDER[@]}"; do | |
| if [[ ${CATEGORIES[${key}]:-0} -eq 1 ]]; then | |
| log_info "collecting: ${key}" | |
| "collect_${key}" | |
| fi | |
| done | |
| } | |
| run_collectors_json() { | |
| # run_collectors_json: collects each enabled category into a temp file, | |
| # then merges all temps into a single top-level JSON document. | |
| local tmpdir | |
| tmpdir=$(mktemp -d -t sysinv.XXXXXX) || { | |
| log_error "cannot create temp directory" | |
| exit 2 | |
| } | |
| # Clean up on exit. | |
| trap 'rm -rf "'"${tmpdir}"'"' EXIT | |
| # Emit metadata to its own temp. | |
| build_meta_json > "${tmpdir}/meta.json" | |
| for key in "${CATEGORY_ORDER[@]}"; do | |
| if [[ ${CATEGORIES[${key}]:-0} -eq 1 ]]; then | |
| log_info "collecting: ${key}" | |
| if ! "collect_${key}" > "${tmpdir}/${key}.json" 2>/dev/null; then | |
| log_warn "collector ${key} failed; emitting null" | |
| printf 'null' > "${tmpdir}/${key}.json" | |
| fi | |
| fi | |
| done | |
| local -a jq_args=(--slurpfile meta "${tmpdir}/meta.json") | |
| local filter='{ meta: $meta[0]' | |
| for key in "${CATEGORY_ORDER[@]}"; do | |
| if [[ -f "${tmpdir}/${key}.json" ]] \ | |
| && [[ ${CATEGORIES[${key}]:-0} -eq 1 ]]; then | |
| jq_args+=(--slurpfile "${key}" "${tmpdir}/${key}.json") | |
| filter+=", ${key}: \$${key}[0]" | |
| fi | |
| done | |
| filter+=' }' | |
| jq -n "${jq_args[@]}" "${filter}" | |
| } | |
| run_collectors() { | |
| # run_collectors: entry point that branches by output mode. | |
| if [[ ${OPT_JSON} -eq 1 ]]; then | |
| run_collectors_json | |
| else | |
| run_collectors_text | |
| fi | |
| } | |
| #_______________________________________________________________________________ | |
| # Variables and Arrays | |
| readonly SCRIPT_NAME="sysinv.sh" | |
| readonly SCRIPT_VERSION="1.1.5" | |
| # Self-referencing path resolved once at load time. Used by print_help | |
| # to parse this script's own header block. | |
| readonly SCRIPT_PATH="$(readlink -f "${BASH_SOURCE[0]}" 2>/dev/null || printf '%s' "${BASH_SOURCE[0]}")" | |
| # Hard timeout (seconds) applied to every safe_run/safe_capture | |
| # invocation. After this elapses, SIGTERM is sent. Guards against | |
| # hangs from commands that phone home (e.g. Hadoop's `yarn`) or | |
| # fall back to interactive stdin (e.g. `perl`, `ruby` with no args). | |
| readonly SAFE_RUN_TIMEOUT=5 | |
| # Grace period (seconds) between the initial SIGTERM and the | |
| # fallback SIGKILL. SIGKILL cannot be caught or ignored, so this | |
| # guarantees termination of any process that catches or blocks on | |
| # SIGTERM (e.g. setuid wrappers like `crontab` under misconfigured | |
| # NSS/LDAP, PAM setups that hang on network lookups). | |
| readonly SAFE_RUN_KILL_AFTER=2 | |
| # Option state (all defaults OFF; parse_args toggles as needed). | |
| OPT_QUIET=0 | |
| OPT_NO_COLOR=0 | |
| OPT_TIMESTAMP=0 | |
| OPT_FORCE=0 | |
| OPT_JSON=0 | |
| OPT_OUTPUT="" | |
| # Required commands for any non-help run. `jq` is additionally required | |
| # when --json is requested and is appended dynamically. | |
| DEPENDENCIES=( awk grep sed ) | |
| # Mapping from command name to the Debian/Ubuntu package that provides | |
| # it. Used to produce a copy-pasteable apt-get install hint on failure. | |
| declare -A DEPENDENCY_PACKAGES=( | |
| [awk]=gawk | |
| [grep]=grep | |
| [sed]=sed | |
| [date]=coreutils | |
| [jq]=jq | |
| [ip]=iproute2 | |
| [ss]=iproute2 | |
| ) | |
| # Category enablement map. Keys are category names; values are 1 | |
| # (enabled) or 0 (disabled). All keys must exist here so parse_args | |
| # and the orchestrators can reference them safely. | |
| declare -A CATEGORIES=( | |
| [system]=0 | |
| [network]=0 | |
| [services]=0 | |
| [processes]=0 | |
| [docker]=0 | |
| [packages]=0 | |
| [cron]=0 | |
| [access]=0 | |
| [storage]=0 | |
| [logs]=0 | |
| [firewall]=0 | |
| [web]=0 | |
| [runtimes]=0 | |
| ) | |
| # Deterministic execution order for categories. Keep this stable so | |
| # that diffing two runs of the same host produces minimal noise. | |
| CATEGORY_ORDER=( | |
| system network services processes docker packages | |
| cron access storage logs firewall web runtimes | |
| ) | |
| # Commands probed by the runtimes collector. Every probe is wrapped in | |
| # safe_run so a missing/hanging binary (e.g. Hadoop `yarn`) cannot | |
| # freeze the script. Add new runtimes here as needed. | |
| RUNTIME_PROBES=( | |
| node npm pnpm yarn bun deno | |
| python python3 pip pip3 | |
| ruby gem bundle | |
| go rustc cargo | |
| java javac mvn gradle | |
| php perl lua | |
| dotnet swift | |
| ) | |
| # ANSI color escapes. Cleared by disable_colors() when writing to a | |
| # file, a non-TTY, or JSON output. | |
| C_RED=$'\033[31m' | |
| C_GREEN=$'\033[32m' | |
| C_YELLOW=$'\033[33m' | |
| C_BLUE=$'\033[34m' | |
| C_BOLD=$'\033[1m' | |
| C_DIM=$'\033[2m' | |
| C_RESET=$'\033[0m' | |
| #_______________________________________________________________________________ | |
| # Execution | |
| parse_args "$@" | |
| setup_debug_fd | |
| check_dependencies | |
| setup_output | |
| banner | |
| run_collectors | |
| exit 0 |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment