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. | |
| # | |
| # 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: 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. | |
| # | |
| # DEVIATION FROM STATED SPEC: the original spec required every help | |
| # line to be "hash + one or more whitespace chars". Bare "#" lines | |
| # (blank comment lines) would therefore terminate the block, and | |
| # most editors silently strip trailing whitespace, which makes the | |
| # strict form fragile across normal editing workflows. Accepting | |
| # bare "#" as an explicit empty-line marker preserves the intended | |
| # termination behavior on dividers while surviving routine edits. | |
| print_help() { | |
| 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: emits the script name and version to stdout. | |
| print_version() { | |
| printf '%s v%s\n' "${SCRIPT_NAME}" "${SCRIPT_VERSION}" | |
| } | |
| # has_cmd: returns 0 if the given command is on PATH. | |
| has_cmd() { | |
| command -v "$1" >/dev/null 2>&1 | |
| } | |
| # is_root: returns 0 if the effective UID is 0. | |
| is_root() { | |
| [[ ${EUID:-$(id -u)} -eq 0 ]] | |
| } | |
| # safe_run: runs a command with a hard timeout and stdin closed to | |
| # /dev/null. Prevents hangs from interactive fallbacks (e.g. perl | |
| # with no args) and from commands that phone home (e.g. `yarn` on | |
| # Hadoop systems, `bundle` without a Gemfile, `crontab -l` under | |
| # misconfigured NSS/LDAP). Stderr is merged into stdout so that | |
| # error messages are visible in text-mode output. Exit code is | |
| # preserved. Use safe_capture instead when the output is being | |
| # assigned to a variable that must not contain stderr noise. | |
| safe_run() { | |
| if has_cmd timeout; then | |
| timeout --preserve-status "${SAFE_RUN_TIMEOUT}" "$@" </dev/null 2>&1 | |
| else | |
| "$@" </dev/null 2>&1 | |
| fi | |
| } | |
| # safe_capture: stdout-only sibling of safe_run. Applies the same | |
| # timeout and stdin-closure guards but discards stderr entirely, | |
| # which is what you want when feeding output into a variable or a | |
| # JSON builder. On command failure, exits non-zero with empty | |
| # stdout; callers typically chain with `|| true` or `|| printf ''`. | |
| safe_capture() { | |
| if has_cmd timeout; then | |
| timeout --preserve-status "${SAFE_RUN_TIMEOUT}" "$@" </dev/null 2>/dev/null | |
| else | |
| "$@" </dev/null 2>/dev/null | |
| fi | |
| } | |
| # log_info: informational message to stderr; suppressed when --quiet. | |
| log_info() { | |
| [[ ${OPT_QUIET} -eq 1 ]] && return 0 | |
| printf '%s[INFO]%s %s\n' "${C_BLUE}" "${C_RESET}" "$*" >&2 | |
| } | |
| # log_warn: warning message to stderr. | |
| log_warn() { | |
| printf '%s[WARN]%s %s\n' "${C_YELLOW}" "${C_RESET}" "$*" >&2 | |
| } | |
| # log_error: error message to stderr. | |
| log_error() { | |
| printf '%s[ERROR]%s %s\n' "${C_RED}" "${C_RESET}" "$*" >&2 | |
| } | |
| # hdr: top-level section header for text-mode output. | |
| hdr() { | |
| 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: subsection header for text-mode output. | |
| subhdr() { | |
| printf '\n%s--- %s ---%s\n' "${C_BOLD}" "$1" "${C_RESET}" | |
| } | |
| # note: muted status line for text-mode output. | |
| note() { | |
| printf '%s(%s)%s\n' "${C_DIM}" "$1" "${C_RESET}" | |
| } | |
| # banner: opening banner for text mode. Skipped in --json and --quiet. | |
| banner() { | |
| [[ ${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: zeroes all color escape variables. | |
| disable_colors() { | |
| C_RED='' | |
| C_GREEN='' | |
| C_YELLOW='' | |
| C_BLUE='' | |
| C_BOLD='' | |
| C_DIM='' | |
| C_RESET='' | |
| } | |
| # lines_to_json_array: reads lines from stdin, emits a JSON array of | |
| # strings. Empty lines are preserved as empty string entries. | |
| lines_to_json_array() { | |
| jq -Rn '[inputs]' | |
| } | |
| # str_or_null: emits a JSON string, or JSON null if the input is empty. | |
| str_or_null() { | |
| if [[ -z "$1" ]]; then | |
| printf 'null' | |
| else | |
| printf '%s' "$1" | jq -Rs '.' | |
| fi | |
| } | |
| #___________________________________________________________________________ | |
| # Dependency Verification | |
| # 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. | |
| check_dependencies() { | |
| 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: returns 0 if the argument begins with one or two | |
| # hyphens. Used to reject malformed positional output paths. | |
| is_hyphen_prefixed() { | |
| [[ "$1" == -* ]] | |
| } | |
| # 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. | |
| consume_output_arg() { | |
| 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: consumes the argument vector and sets OPT_* and CATEGORIES | |
| # globals. Exits 1 on malformed input. | |
| parse_args() { | |
| 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: runs the sanity checks requested for the output | |
| # path. Exits 2 on any failure. | |
| validate_output_path() { | |
| 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: applies the timestamp prefix, validates, disables | |
| # colors when appropriate, and redirects stdout to the output file. | |
| setup_output() { | |
| # 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 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() { | |
| local system_crontab cron_d_json periodic_json user_ct_json timers_json anacron | |
| if [[ -r /etc/crontab ]]; then | |
| system_crontab=$(grep -vE '^\s*(#|$)' /etc/crontab | jq -Rs '.') | |
| else | |
| system_crontab='null' | |
| fi | |
| if [[ -d /etc/cron.d ]]; then | |
| cron_d_json='[]' | |
| for f in /etc/cron.d/*; do | |
| [[ -f "${f}" ]] || continue | |
| local content | |
| content=$(grep -vE '^\s*(#|$)' "${f}" 2>/dev/null || true) | |
| cron_d_json=$(jq \ | |
| --arg path "${f}" \ | |
| --arg content "${content}" \ | |
| '. + [{ path: $path, content: $content }]' \ | |
| <<< "${cron_d_json}") | |
| done | |
| else | |
| cron_d_json='[]' | |
| fi | |
| periodic_json='{}' | |
| for d in hourly daily weekly monthly; do | |
| local dir="/etc/cron.${d}" | |
| if [[ -d "${dir}" ]]; then | |
| local entries | |
| entries=$(ls -1 "${dir}" 2>/dev/null | lines_to_json_array) | |
| periodic_json=$(jq --arg name "${d}" --argjson entries "${entries}" \ | |
| '. + { ($name): $entries }' <<< "${periodic_json}") | |
| fi | |
| done | |
| user_ct_json='[]' | |
| if 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 | |
| local user_name content | |
| user_name=$(basename "${f}") | |
| content=$(cat "${f}" 2>/dev/null) | |
| user_ct_json=$(jq \ | |
| --arg user "${user_name}" \ | |
| --arg content "${content}" \ | |
| '. + [{ user: $user, content: $content }]' \ | |
| <<< "${user_ct_json}") | |
| done | |
| fi | |
| else | |
| local current content | |
| current=$(id -un) | |
| content=$(safe_capture crontab -l || printf '') | |
| user_ct_json=$(jq \ | |
| --arg user "${current}" \ | |
| --arg content "${content}" \ | |
| '[{ user: $user, content: $content, note: "non-root run; only current user shown" }]') | |
| fi | |
| 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 | |
| if [[ -r /etc/anacrontab ]]; then | |
| anacron=$(grep -vE '^\s*(#|$)' /etc/anacrontab | jq -Rs '.') | |
| else | |
| anacron='null' | |
| fi | |
| 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 | |
| }' | |
| } | |
| 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: emits a metadata object describing this run. | |
| build_meta_json() { | |
| 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: dispatches enabled collectors in text mode in a | |
| # deterministic order so multi-run diffs are stable. | |
| run_collectors_text() { | |
| for key in "${CATEGORY_ORDER[@]}"; do | |
| if [[ ${CATEGORIES[${key}]:-0} -eq 1 ]]; then | |
| log_info "collecting: ${key}" | |
| "collect_${key}" | |
| fi | |
| done | |
| } | |
| # run_collectors_json: collects each enabled category into a temp file, | |
| # then merges all temps into a single top-level JSON document. | |
| run_collectors_json() { | |
| 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: entry point that branches by output mode. | |
| run_collectors() { | |
| 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.1" | |
| # 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 invocation. 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 | |
| # 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 "$@" | |
| 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