Last active
April 23, 2026 23:35
-
-
Save h8rt3rmin8r/fc8cfecde605636b86c4a30de8b5d18a to your computer and use it in GitHub Desktop.
Revisions
-
h8rt3rmin8r revised this gist
Apr 23, 2026 . 1 changed file with 71 additions and 82 deletions.There are no files selected for viewing
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 charactersOriginal file line number Diff line number Diff line change @@ -123,21 +123,13 @@ 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 @@ -155,32 +147,32 @@ set -o pipefail 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}" \ @@ -194,14 +186,14 @@ set -o pipefail 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}" \ @@ -215,67 +207,67 @@ set -o pipefail 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 @@ -292,8 +284,8 @@ set -o pipefail printf '%s' "${C_RESET}" } disable_colors() { # disable_colors: zeroes all color escape variables. C_RED='' C_GREEN='' C_YELLOW='' @@ -303,14 +295,14 @@ set -o pipefail 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 @@ -321,10 +313,10 @@ set -o pipefail #___________________________________________________________________________ # 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") @@ -348,16 +340,16 @@ set -o pipefail #___________________________________________________________________________ # 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 @@ -371,9 +363,9 @@ set -o pipefail 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 @@ -479,9 +471,9 @@ set -o pipefail #___________________________________________________________________________ # 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 @@ -528,16 +520,13 @@ set -o pipefail 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 @@ -2045,8 +2034,8 @@ images: {{.Images}}' #___________________________________________________________________________ # 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}") @@ -2083,9 +2072,9 @@ images: {{.Images}}' }' } 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}" @@ -2094,9 +2083,9 @@ images: {{.Images}}' 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" @@ -2132,8 +2121,8 @@ images: {{.Images}}' 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 @@ -2247,4 +2236,4 @@ images: {{.Images}}' setup_output banner run_collectors exit 0 -
h8rt3rmin8r revised this gist
Apr 23, 2026 . 1 changed file with 4 additions and 49 deletions.There are no files selected for viewing
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 charactersOriginal file line number Diff line number Diff line change @@ -215,43 +215,6 @@ set -o pipefail fi } # log_info: informational message to stderr; suppressed when --quiet. log_info() { [[ ${OPT_QUIET} -eq 1 ]] && return 0 @@ -1324,13 +1287,7 @@ images: {{.Images}}' 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" @@ -1422,10 +1379,8 @@ images: {{.Images}}' 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" }]') @@ -2191,7 +2146,7 @@ images: {{.Images}}' # 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]}")" -
h8rt3rmin8r revised this gist
Apr 23, 2026 . 1 changed file with 58 additions and 11 deletions.There are no files selected for viewing
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 charactersOriginal file line number Diff line number Diff line change @@ -178,18 +178,19 @@ set -o pipefail # 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. safe_run() { 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 } @@ -198,21 +199,59 @@ set -o pipefail # 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. safe_capture() { 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 } # capture_to_file: runs a command with stdout directed to a regular # temporary file rather than through a shell pipe. The file's # contents are emitted to stdout (via cat) after the command has # completed or been killed; the file is then removed. # # This exists because some programs (notably setuid wrappers whose # PAM stack interacts with stdout under redirection) demonstrate # pathological blocking when fd 1 is connected to a pipe but behave # normally when fd 1 is a regular file. The wrapped command in this # helper never sees a pipe on its stdout at all; the pipe only # appears later when `cat` writes the captured file through the # function's own stdout to the caller. Separating the command from # the pipe was the fix that eliminated the crontab-on-cloudbase # stall; use this helper whenever a probe hits that specific class # of problem. # # Same timeout/SIGKILL guarantees and fd-3 closure as safe_capture. # Return value reflects the wrapped command's exit status. capture_to_file() { local tmp rc=0 tmp=$(mktemp -t sysinv-capture.XXXXXX) || return 1 debug_log "capture_to_file: $* -> ${tmp}" if has_cmd timeout; then timeout --preserve-status \ --kill-after="${SAFE_RUN_KILL_AFTER}" \ "${SAFE_RUN_TIMEOUT}" "$@" </dev/null >"${tmp}" 2>/dev/null 3>&- \ || rc=$? else "$@" </dev/null >"${tmp}" 2>/dev/null 3>&- || rc=$? fi cat "${tmp}" rm -f "${tmp}" return ${rc} } # log_info: informational message to stderr; suppressed when --quiet. log_info() { [[ ${OPT_QUIET} -eq 1 ]] && return 0 @@ -1285,7 +1324,13 @@ images: {{.Images}}' else note "root required for all users' crontabs; showing current user only" printf '### %s ###\n' "$(id -un)" local _ct_content _ct_content=$(capture_to_file crontab -l) if [[ -z "${_ct_content}" ]]; then note "no crontab for $(id -un) (or crontab command unresponsive)" else printf '%s\n' "${_ct_content}" fi fi subhdr "systemd Timers" @@ -1377,7 +1422,9 @@ images: {{.Images}}' debug_log "cron: non-root branch; reading current user's crontab" local current content current=$(id -un) debug_log "cron: resolved user=${current}; invoking crontab -l via capture_to_file" content=$(capture_to_file crontab -l || printf '') debug_log "cron: crontab -l returned ${#content} bytes" user_ct_json=$(jq \ --arg user "${current}" \ --arg content "${content}" \ @@ -2144,7 +2191,7 @@ images: {{.Images}}' # Variables and Arrays readonly SCRIPT_NAME="sysinv.sh" readonly SCRIPT_VERSION="1.1.4" # 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]}")" -
h8rt3rmin8r revised this gist
Apr 23, 2026 . 1 changed file with 64 additions and 27 deletions.There are no files selected for viewing
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 charactersOriginal file line number Diff line number Diff line change @@ -94,6 +94,13 @@ # 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 @@ -163,30 +170,44 @@ set -o pipefail [[ ${EUID:-$(id -u)} -eq 0 ]] } # 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. safe_run() { 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 elif has_cmd timeout; then timeout --preserve-status \ --kill-after="${SAFE_RUN_KILL_AFTER}" \ "${SAFE_RUN_TIMEOUT}" "$@" </dev/null 2>&1 else "$@" </dev/null 2>&1 fi } # 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. safe_capture() { 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 elif has_cmd timeout; then timeout --preserve-status \ --kill-after="${SAFE_RUN_KILL_AFTER}" \ "${SAFE_RUN_TIMEOUT}" "$@" </dev/null 2>/dev/null else "$@" </dev/null 2>/dev/null fi @@ -1245,7 +1266,9 @@ images: {{.Images}}' 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 @@ -1294,8 +1317,8 @@ images: {{.Images}}' [[ -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}" @@ -1306,9 +1329,8 @@ images: {{.Images}}' --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" @@ -1322,12 +1344,17 @@ images: {{.Images}}' [[ -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 @@ -1343,6 +1370,7 @@ images: {{.Images}}' --arg content "${content}" \ '. + [{ user: $user, content: $content }]' \ <<< "${user_ct_json}") [[ -z "${user_ct_json}" ]] && user_ct_json='[]' done fi else @@ -1355,16 +1383,17 @@ images: {{.Images}}' --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 @@ -2115,16 +2144,24 @@ images: {{.Images}}' # Variables and Arrays readonly SCRIPT_NAME="sysinv.sh" readonly SCRIPT_VERSION="1.1.3" # 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 -
h8rt3rmin8r revised this gist
Apr 23, 2026 . 1 changed file with 55 additions and 6 deletions.There are no files selected for viewing
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 charactersOriginal file line number Diff line number Diff line change @@ -88,6 +88,13 @@ # 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. # # EXIT CODES # 0 Success # 1 Argument parsing error @@ -201,6 +208,30 @@ set -o pipefail printf '%s[ERROR]%s %s\n' "${C_RED}" "${C_RESET}" "$*" >&2 } # 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. debug_log() { [[ -z "${SYSINV_DEBUG:-}" ]] && return 0 printf '%s[DEBUG]%s %s\n' "${C_DIM}" "${C_RESET}" "$*" >&3 } # 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. setup_debug_fd() { if [[ -n "${SYSINV_DEBUG:-}" ]]; then exec 3>&2 else exec 3>/dev/null fi } # hdr: top-level section header for text-mode output. hdr() { printf '\n%s================================================================================%s\n' \ @@ -1251,20 +1282,25 @@ images: {{.Images}}' } 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" if [[ -d /etc/cron.d ]]; then cron_d_json='[]' 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}" \ @@ -1275,17 +1311,21 @@ images: {{.Images}}' cron_d_json='[]' 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}") fi done debug_log "cron: collecting user crontabs" user_ct_json='[]' if is_root; then local spool_dir="" @@ -1294,9 +1334,10 @@ images: {{.Images}}' 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}" \ @@ -1305,6 +1346,7 @@ images: {{.Images}}' 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 '') @@ -1314,20 +1356,25 @@ images: {{.Images}}' '[{ user: $user, content: $content, note: "non-root run; only current user shown" }]') fi 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) [[ -z "${timers_json}" ]] && timers_json='[]' else timers_json='[]' fi 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}" \ @@ -1343,6 +1390,7 @@ images: {{.Images}}' systemd_timers: $timers, anacrontab: $anacron }' debug_log "cron: exiting collector" } collect_cron() { @@ -2067,7 +2115,7 @@ images: {{.Images}}' # Variables and Arrays readonly SCRIPT_NAME="sysinv.sh" readonly SCRIPT_VERSION="1.1.2" # 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]}")" @@ -2155,6 +2203,7 @@ images: {{.Images}}' # Execution parse_args "$@" setup_debug_fd check_dependencies setup_output banner -
h8rt3rmin8r revised this gist
Apr 23, 2026 . 1 changed file with 31 additions and 15 deletions.There are no files selected for viewing
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 charactersOriginal file line number Diff line number Diff line change @@ -159,8 +159,11 @@ set -o pipefail # 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 @@ -169,6 +172,19 @@ set -o pipefail 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 @@ -788,24 +804,24 @@ set -o pipefail 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}' } @@ -816,13 +832,13 @@ set -o pipefail 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 \ @@ -1215,12 +1231,12 @@ images: {{.Images}}' 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" @@ -1291,15 +1307,15 @@ images: {{.Images}}' 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 @@ -2051,7 +2067,7 @@ images: {{.Images}}' # 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]}")" -
h8rt3rmin8r created this gist
Apr 23, 2026 .There are no files selected for viewing
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 charactersOriginal file line number Diff line number Diff line change @@ -0,0 +1,2146 @@ #!/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). Stderr is merged into # stdout. Exit code is preserved. safe_run() { if has_cmd timeout; then timeout --preserve-status "${SAFE_RUN_TIMEOUT}" "$@" </dev/null 2>&1 else "$@" </dev/null 2>&1 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" systemctl list-units --type=service --state=running --no-pager --no-legend 2>/dev/null \ || note "unable to list services" subhdr "Failed Units" local failed failed=$(systemctl list-units --state=failed --no-pager --no-legend 2>/dev/null) if [[ -z "${failed}" ]]; then note "no failed units" else printf '%s\n' "${failed}" fi subhdr "Timers" systemctl list-timers --all --no-pager 2>/dev/null \ || note "unable to list timers" subhdr "Enabled at Boot" systemctl list-unit-files --state=enabled --no-pager --no-legend 2>/dev/null \ | 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=$(systemctl list-units --type=service --state=running --no-pager --no-legend 2>/dev/null \ | awk '{print $1}' | lines_to_json_array) failed_json=$(systemctl list-units --state=failed --no-pager --no-legend 2>/dev/null \ | awk '{print $2}' | lines_to_json_array) enabled_json=$(systemctl list-unit-files --state=enabled --no-pager --no-legend 2>/dev/null \ | awk '{print $1}' | lines_to_json_array) timers_json=$(systemctl list-timers --all --no-pager --no-legend 2>/dev/null \ | 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)" crontab -l 2>/dev/null || note "no crontab for $(id -un)" fi subhdr "systemd Timers" if has_cmd systemctl; then systemctl list-timers --all --no-pager 2>/dev/null \ || 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=$(crontab -l 2>/dev/null || 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=$(systemctl list-timers --all --no-pager --no-legend 2>/dev/null \ | 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.0" # 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