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