#!/usr/bin/env bash ######################################################################################### #### motd-generator.sh — Generate a dynamic MOTD with system stats and health info #### #### Shows hostname, IP, uptime, disk, load, memory, updates, and service status #### #### Dry-run by default for --install — use --force to write to update-motd.d #### #### #### #### Author: Phil Connor #### #### Contact: contact@mylinux.work #### #### License: MIT #### #### Version 1.02 #### #### #### #### Usage: #### #### ./motd-generator.sh #### #### ./motd-generator.sh --plain #### #### ./motd-generator.sh --install --force #### #### #### #### See --help for all options. #### ######################################################################################### set -euo pipefail # ── Defaults ────────────────────────────────────────────────────────── VERBOSE="${VERBOSE:-false}" COLOR="${COLOR:-auto}" DRY_RUN="${DRY_RUN:-true}" MODE="${MODE:-display}" PLAIN="${PLAIN:-false}" MOTD_TARGET="${MOTD_TARGET:-/etc/update-motd.d/99-custom}" # ── State ───────────────────────────────────────────────────────────── SCRIPT_NAME="$(basename "$0")" readonly SCRIPT_NAME # ── Colors ──────────────────────────────────────────────────────────── setup_colors() { if [[ "$COLOR" == "never" ]]; then RED="" GREEN="" YELLOW="" CYAN="" BOLD="" DIM="" RESET="" return fi if [[ "$COLOR" == "always" ]] || [[ -t 1 ]]; then RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m' CYAN='\033[0;36m' BOLD='\033[1m' DIM='\033[2m' RESET='\033[0m' else RED="" GREEN="" YELLOW="" CYAN="" BOLD="" DIM="" RESET="" fi } # ── Logging ─────────────────────────────────────────────────────────── log() { echo -e "${CYAN}[INFO]${RESET} $*"; } warn() { echo -e "${YELLOW}[WARN]${RESET} $*" >&2; } err() { echo -e "${RED}[ERROR]${RESET} $*" >&2; } verbose() { if [[ "$VERBOSE" == "true" ]]; then echo -e "${DIM}[DEBUG]${RESET} $*"; fi; } # ── Data Gathering ─────────────────────────────────────────────────── get_hostname() { hostname -f 2>/dev/null || hostname } get_primary_ip() { local ip="" if command -v ip &>/dev/null; then ip=$(ip route get 1.1.1.1 2>/dev/null | grep -oP 'src \K[\d.]+' | head -1) fi if [[ -z "$ip" ]] && command -v hostname &>/dev/null; then ip=$(hostname -I 2>/dev/null | awk '{print $1}') fi echo "${ip:-N/A}" } get_uptime() { uptime -p 2>/dev/null || uptime | sed 's/.*up //' | sed 's/, [0-9]* user.*//' } get_disk_usage() { df / 2>/dev/null | tail -1 | awk '{print $5}' | tr -d '%' } get_load_average() { cut -d' ' -f1-3 /proc/loadavg 2>/dev/null || echo "N/A" } get_memory_usage() { if [[ -f /proc/meminfo ]]; then local total_kb avail_kb total_kb=$(awk '/^MemTotal:/ {print $2}' /proc/meminfo) avail_kb=$(awk '/^MemAvailable:/ {print $2}' /proc/meminfo) if [[ "$total_kb" -gt 0 ]]; then local pct pct=$(awk "BEGIN { printf \"%.0f\", ($total_kb - $avail_kb) * 100 / $total_kb }") local total_mb=$(( total_kb / 1024 )) local used_mb=$(( (total_kb - avail_kb) / 1024 )) echo "${pct}|${used_mb}|${total_mb}" return fi fi echo "0|0|0" } get_pending_updates() { if command -v apt-get &>/dev/null; then apt list --upgradable 2>/dev/null | grep -c "upgradable" || true elif command -v dnf &>/dev/null; then dnf check-update --quiet 2>/dev/null | grep -cE "^\S" || true elif command -v yum &>/dev/null; then yum check-update --quiet 2>/dev/null | grep -cE "^\S" || true else echo "N/A" fi } get_logged_in_users() { who 2>/dev/null | wc -l } get_failed_services() { if command -v systemctl &>/dev/null; then systemctl --no-legend --state=failed 2>/dev/null | wc -l else echo "N/A" fi } # ── Color Thresholds ───────────────────────────────────────────────── threshold_color() { local value="$1" local warn_at="${2:-75}" local crit_at="${3:-90}" if [[ "$value" -ge "$crit_at" ]]; then echo "$RED" elif [[ "$value" -ge "$warn_at" ]]; then echo "$YELLOW" else echo "$GREEN" fi } load_color() { local load_1m="$1" local cpus cpus=$(nproc 2>/dev/null || echo 1) local pct pct=$(awk "BEGIN { printf \"%.0f\", ($load_1m / $cpus) * 100 }") threshold_color "$pct" 75 90 } # ── MOTD Output ────────────────────────────────────────────────────── generate_motd_plain() { local host ip up disk_pct load mem_info mem_pct mem_used mem_total local updates users_count failed_count host=$(get_hostname) ip=$(get_primary_ip) up=$(get_uptime) disk_pct=$(get_disk_usage) load=$(get_load_average) mem_info=$(get_memory_usage) mem_pct=$(echo "$mem_info" | cut -d'|' -f1) mem_used=$(echo "$mem_info" | cut -d'|' -f2) mem_total=$(echo "$mem_info" | cut -d'|' -f3) updates=$(get_pending_updates) users_count=$(get_logged_in_users) failed_count=$(get_failed_services) local dc mc lc dc=$(threshold_color "$disk_pct" 75 90) mc=$(threshold_color "$mem_pct" 75 90) local load_1m load_1m=$(echo "$load" | awk '{print $1}') lc=$(load_color "$load_1m") echo -e " ${BOLD}Hostname:${RESET} $host" echo -e " ${BOLD}IP Address:${RESET} $ip" echo -e " ${BOLD}Uptime:${RESET} $up" echo -e " ${BOLD}Disk (root):${RESET} ${dc}${disk_pct}%${RESET}" echo -e " ${BOLD}Load Average:${RESET} ${lc}${load}${RESET}" echo -e " ${BOLD}Memory:${RESET} ${mc}${mem_used}M / ${mem_total}M (${mem_pct}%)${RESET}" echo -e " ${BOLD}Pending Updates:${RESET} $updates" echo -e " ${BOLD}Logged-in Users:${RESET} $users_count" echo -e " ${BOLD}Failed Services:${RESET} $failed_count" } generate_motd_box() { local host ip up disk_pct load mem_info mem_pct mem_used mem_total local updates users_count failed_count host=$(get_hostname) ip=$(get_primary_ip) up=$(get_uptime) disk_pct=$(get_disk_usage) load=$(get_load_average) mem_info=$(get_memory_usage) mem_pct=$(echo "$mem_info" | cut -d'|' -f1) mem_used=$(echo "$mem_info" | cut -d'|' -f2) mem_total=$(echo "$mem_info" | cut -d'|' -f3) updates=$(get_pending_updates) users_count=$(get_logged_in_users) failed_count=$(get_failed_services) local dc mc lc dc=$(threshold_color "$disk_pct" 75 90) mc=$(threshold_color "$mem_pct" 75 90) local load_1m load_1m=$(echo "$load" | awk '{print $1}') lc=$(load_color "$load_1m") local fc_color="$GREEN" if [[ "$failed_count" != "N/A" && "$failed_count" -gt 0 ]]; then fc_color="$RED" fi # Build rows as "label|value" pairs to measure widest content local label_w=18 local rows=( "Hostname:|$host" "IP Address:|$ip" "Uptime:|$up" "Disk (root):|${disk_pct}%" "Load Average:|$load" "Memory:|${mem_used}M / ${mem_total}M (${mem_pct}%)" "Pending Updates:|$updates" "Logged-in Users:|$users_count" "Failed Services:|$failed_count" ) # Calculate box width from widest content local header="System Status: ${host}" local dateline dateline=$(date '+%Y-%m-%d %H:%M:%S %Z') local w=${#header} [[ ${#dateline} -gt $w ]] && w=${#dateline} local row label value row_len for row in "${rows[@]}"; do label="${row%%|*}" value="${row#*|}" row_len=$(( label_w + 1 + ${#value} )) [[ $row_len -gt $w ]] && w=$row_len done # Add 2 for inner padding (space on each side) w=$(( w + 2 )) # Minimum width [[ $w -lt 56 ]] && w=56 # Build box-drawing borders at calculated width local bar bar=$(printf '═%.0s' $(seq 1 $((w + 2)))) local border="╔${bar}╗" local bottom="╚${bar}╝" local sep="╠${bar}╣" BOX_W=$w # export to _box_row echo "" echo -e " ${CYAN}${border}${RESET}" printf " ${CYAN}║${RESET} ${BOLD}%-${w}s${RESET} ${CYAN}║${RESET}\n" "$header" printf " ${CYAN}║${RESET} ${DIM}%-${w}s${RESET} ${CYAN}║${RESET}\n" "$dateline" echo -e " ${CYAN}${sep}${RESET}" _box_row "Hostname: " "$host" _box_row "IP Address: " "$ip" _box_row "Uptime: " "$up" _box_row "Disk (root): " "${disk_pct}%" "$dc" _box_row "Load Average: " "$load" "$lc" _box_row "Memory: " "${mem_used}M / ${mem_total}M (${mem_pct}%)" "$mc" _box_row "Pending Updates: " "$updates" _box_row "Logged-in Users: " "$users_count" _box_row "Failed Services: " "$failed_count" "$fc_color" echo -e " ${CYAN}${bottom}${RESET}" echo "" } _box_row() { # Print a row inside the box with correct padding regardless of content length # Usage: _box_row "Label:" "value" [color_prefix] local label="$1" local value="$2" local color="${3:-}" local inner_w="${BOX_W:-56}" # Build the visible text (no ANSI) local vis_text=" ${label} ${value}" local vis_len=${#vis_text} # Pad to fill the box local pad_len=$(( inner_w - vis_len )) [[ $pad_len -lt 0 ]] && pad_len=0 local padding padding=$(printf '%*s' "$pad_len" '') # Build output with optional color on value if [[ -n "$color" ]]; then printf " ${CYAN}║${RESET} %s %b%s${RESET}%s ${CYAN}║${RESET}\n" "$label" "$color" "$value" "$padding" else printf " ${CYAN}║${RESET} %s %s%s ${CYAN}║${RESET}\n" "$label" "$value" "$padding" fi } # ── Install ────────────────────────────────────────────────────────── install_motd() { if [[ "$DRY_RUN" == "true" ]]; then log "[DRY-RUN] Would install MOTD script to ${MOTD_TARGET}" log "[DRY-RUN] Run with --force to actually install" echo "" log "Generated script preview:" echo " #!/bin/bash" echo " # Generated by ${SCRIPT_NAME} on $(date '+%Y-%m-%d %H:%M:%S')" echo " $(readlink -f "$0") --plain --no-color" return fi if [[ $EUID -ne 0 ]]; then err "Installation requires root privileges" exit 1 fi local script_path script_path=$(readlink -f "$0") local motd_dir motd_dir=$(dirname "$MOTD_TARGET") if [[ ! -d "$motd_dir" ]]; then mkdir -p "$motd_dir" fi cat > "$MOTD_TARGET" <&2 exit 1 ;; esac done } # ══════════════════════════════════════════════════════════════════════ # MAIN # ══════════════════════════════════════════════════════════════════════ main() { parse_args "$@" setup_colors case "$MODE" in install) install_motd ;; display) if [[ "$PLAIN" == "true" ]]; then generate_motd_plain else generate_motd_box fi ;; esac } main "$@"