Skip to content

Instantly share code, notes, and snippets.

@h8rt3rmin8r
Last active April 23, 2026 23:35
Show Gist options
  • Select an option

  • Save h8rt3rmin8r/fc8cfecde605636b86c4a30de8b5d18a to your computer and use it in GitHub Desktop.

Select an option

Save h8rt3rmin8r/fc8cfecde605636b86c4a30de8b5d18a to your computer and use it in GitHub Desktop.

Revisions

  1. h8rt3rmin8r revised this gist Apr 23, 2026. 1 changed file with 71 additions and 82 deletions.
    153 changes: 71 additions & 82 deletions sysinv.sh
    Original file line number Diff line number Diff line change
    @@ -123,21 +123,13 @@ 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() {
    # 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: emits the script name and version to stdout.
    print_version() {
    # print_version: emits the script name and version to stdout.
    printf '%s v%s\n' "${SCRIPT_NAME}" "${SCRIPT_VERSION}"
    }

    # has_cmd: returns 0 if the given command is on PATH.
    has_cmd() {
    # has_cmd: returns 0 if the given command is on PATH.
    command -v "$1" >/dev/null 2>&1
    }

    # is_root: returns 0 if the effective UID is 0.
    is_root() {
    # is_root: returns 0 if the effective UID is 0.
    [[ ${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. Fd 3 (the debug channel) is explicitly
    # closed so the invoked command cannot inherit it.
    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: 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.
    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: informational message to stderr; suppressed when --quiet.
    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: warning message to stderr.
    log_warn() {
    # log_warn: warning message to stderr.
    printf '%s[WARN]%s %s\n' "${C_YELLOW}" "${C_RESET}" "$*" >&2
    }

    # log_error: error message to stderr.
    log_error() {
    # log_error: error message to stderr.
    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() {
    # 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: 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() {
    # 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: top-level section header for text-mode output.
    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: subsection header for text-mode output.
    subhdr() {
    # subhdr: subsection header for text-mode output.
    printf '\n%s--- %s ---%s\n' "${C_BOLD}" "$1" "${C_RESET}"
    }

    # note: muted status line for text-mode output.
    note() {
    # note: muted status line for text-mode output.
    printf '%s(%s)%s\n' "${C_DIM}" "$1" "${C_RESET}"
    }

    # banner: opening banner for text mode. Skipped in --json and --quiet.
    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: zeroes all color escape variables.
    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: reads lines from stdin, emits a JSON array of
    # strings. Empty lines are preserved as empty string entries.
    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: emits a JSON string, or JSON null if the input is empty.
    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: 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() {
    # 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: returns 0 if the argument begins with one or two
    # hyphens. Used to reject malformed positional output paths.
    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: 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() {
    # 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: consumes the argument vector and sets OPT_* and CATEGORIES
    # globals. Exits 1 on malformed input.
    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: runs the sanity checks requested for the output
    # path. Exits 2 on any failure.
    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: applies the timestamp prefix, validates, disables
    # colors when appropriate, and redirects stdout to the output file.
    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
    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: emits a metadata object describing this run.
    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: dispatches enabled collectors in text mode in a
    # deterministic order so multi-run diffs are stable.
    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: collects each enabled category into a temp file,
    # then merges all temps into a single top-level JSON document.
    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: entry point that branches by output mode.
    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
    exit 0
  2. h8rt3rmin8r revised this gist Apr 23, 2026. 1 changed file with 4 additions and 49 deletions.
    53 changes: 4 additions & 49 deletions sysinv.sh
    Original file line number Diff line number Diff line change
    @@ -215,43 +215,6 @@ set -o pipefail
    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
    @@ -1324,13 +1287,7 @@ 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
    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)
    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 \
    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.4"
    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]}")"
  3. h8rt3rmin8r revised this gist Apr 23, 2026. 1 changed file with 58 additions and 11 deletions.
    69 changes: 58 additions & 11 deletions sysinv.sh
    Original 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.
    # 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
    "${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
    "${SAFE_RUN_TIMEOUT}" "$@" </dev/null 2>&1 3>&-
    else
    "$@" </dev/null 2>&1
    "$@" </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.
    # 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
    "${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
    "${SAFE_RUN_TIMEOUT}" "$@" </dev/null 2>/dev/null 3>&-
    else
    "$@" </dev/null 2>/dev/null
    "$@" </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)"
    safe_run crontab -l || note "no crontab for $(id -un) (or crontab command unresponsive)"
    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)
    content=$(safe_capture crontab -l || printf '')
    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.3"
    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]}")"
  4. h8rt3rmin8r revised this gist Apr 23, 2026. 1 changed file with 64 additions and 27 deletions.
    91 changes: 64 additions & 27 deletions sysinv.sh
    Original 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 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: 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; then
    timeout --preserve-status "${SAFE_RUN_TIMEOUT}" "$@" </dev/null 2>&1
    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. 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: 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; then
    timeout --preserve-status "${SAFE_RUN_TIMEOUT}" "$@" </dev/null 2>/dev/null
    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 is_root; then
    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
    cron_d_json='[]'
    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
    else
    cron_d_json='[]'
    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 is_root; then
    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)
    [[ -z "${timers_json}" ]] && timers_json='[]'
    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.2"
    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 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).
    # 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
  5. h8rt3rmin8r revised this gist Apr 23, 2026. 1 changed file with 55 additions and 6 deletions.
    61 changes: 55 additions & 6 deletions sysinv.sh
    Original 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=$(grep -vE '^\s*(#|$)' /etc/crontab | jq -Rs '.')
    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=$(grep -vE '^\s*(#|$)' "${f}" 2>/dev/null || true)
    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=$(ls -1 "${dir}" 2>/dev/null | lines_to_json_array)
    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=$(cat "${f}" 2>/dev/null)
    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=$(grep -vE '^\s*(#|$)' /etc/anacrontab | jq -Rs '.')
    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.1"
    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
  6. h8rt3rmin8r revised this gist Apr 23, 2026. 1 changed file with 31 additions and 15 deletions.
    46 changes: 31 additions & 15 deletions sysinv.sh
    Original 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). Stderr is merged into
    # stdout. Exit code is preserved.
    # 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"
    systemctl list-units --type=service --state=running --no-pager --no-legend 2>/dev/null \
    safe_run systemctl list-units --type=service --state=running --no-pager --no-legend \
    || note "unable to list services"

    subhdr "Failed Units"
    local failed
    failed=$(systemctl list-units --state=failed --no-pager --no-legend 2>/dev/null)
    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"
    systemctl list-timers --all --no-pager 2>/dev/null \
    safe_run systemctl list-timers --all --no-pager \
    || note "unable to list timers"

    subhdr "Enabled at Boot"
    systemctl list-unit-files --state=enabled --no-pager --no-legend 2>/dev/null \
    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=$(systemctl list-units --type=service --state=running --no-pager --no-legend 2>/dev/null \
    running_json=$(safe_capture systemctl list-units --type=service --state=running --no-pager --no-legend \
    | awk '{print $1}' | lines_to_json_array)
    failed_json=$(systemctl list-units --state=failed --no-pager --no-legend 2>/dev/null \
    failed_json=$(safe_capture systemctl list-units --state=failed --no-pager --no-legend \
    | awk '{print $2}' | lines_to_json_array)
    enabled_json=$(systemctl list-unit-files --state=enabled --no-pager --no-legend 2>/dev/null \
    enabled_json=$(safe_capture systemctl list-unit-files --state=enabled --no-pager --no-legend \
    | awk '{print $1}' | lines_to_json_array)
    timers_json=$(systemctl list-timers --all --no-pager --no-legend 2>/dev/null \
    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)"
    crontab -l 2>/dev/null || note "no crontab for $(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
    systemctl list-timers --all --no-pager 2>/dev/null \
    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=$(crontab -l 2>/dev/null || printf '')
    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=$(systemctl list-timers --all --no-pager --no-legend 2>/dev/null \
    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.0"
    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]}")"
  7. h8rt3rmin8r created this gist Apr 23, 2026.
    2,146 changes: 2,146 additions & 0 deletions sysinv.sh
    Original 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