Files
linux-scripts/logrotate-smoke-tests.sh
chiefgeek a1a17e81a1 Sync all scripts from website downloads — 352 scripts total
Includes updated JS challenge scripts with Claude-User whitelist,
same-site referer bypass, Blackbox-Exporter allowed bot, and all
new exporters, cheat sheets, and automation scripts.
2026-05-25 03:31:08 +02:00

533 lines
20 KiB
Bash

#!/usr/bin/env bash
############################################################################################
#### logrotate-smoke-tests.sh — Verify logrotate configs, rotation, and log hygiene ####
#### Zero external dependencies. Read-only — never modifies configs or log files. ####
#### Requires: bash 4+, coreutils ####
#### ####
#### Author: Phil Connor ####
#### Contact: contact@mylinux.work ####
#### License: MIT ####
#### Version 1.01 ####
#### ####
#### Usage: ####
#### sudo ./logrotate-smoke-tests.sh ####
#### ####
#### See --help for all options. ####
############################################################################################
set -euo pipefail
# ── Defaults ──────────────────────────────────────────────────────────
LOG_DIR="${LOG_DIR:-/var/log}"
MAX_LOG_SIZE_MB="${MAX_LOG_SIZE_MB:-500}"
MAX_DISK_USAGE_PCT="${MAX_DISK_USAGE_PCT:-80}"
MAX_ARCHIVE_AGE_DAYS="${MAX_ARCHIVE_AGE_DAYS:-90}"
SKIP_SYNTAX="${SKIP_SYNTAX:-false}"
OUTPUT_FORMAT="${OUTPUT_FORMAT:-text}" # text, tap, junit
JUNIT_FILE="${JUNIT_FILE:-smoke-results.xml}"
VERBOSE="${VERBOSE:-false}"
COLOR="${COLOR:-auto}"
# ── State ─────────────────────────────────────────────────────────────
PASS=0
FAIL=0
SKIP=0
TOTAL=0
RESULTS=()
START_TIME=""
# ── Colors ────────────────────────────────────────────────────────────
setup_colors() {
if [[ "$COLOR" == "never" ]]; then
RED="" GREEN="" YELLOW="" BLUE="" BOLD="" RESET=""
return
fi
if [[ "$COLOR" == "always" ]] || [[ -t 1 ]]; then
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
BLUE='\033[0;34m'
BOLD='\033[1m'
RESET='\033[0m'
else
RED="" GREEN="" YELLOW="" BLUE="" BOLD="" RESET=""
fi
}
# ── Logging ───────────────────────────────────────────────────────────
log() { echo -e "${BLUE}[INFO]${RESET} $*"; }
warn() { echo -e "${YELLOW}[WARN]${RESET} $*" >&2; }
err() { echo -e "${RED}[ERROR]${RESET} $*" >&2; }
verbose() { if [[ "$VERBOSE" == "true" ]]; then echo -e "${BLUE}[DEBUG]${RESET} $*"; fi; }
# ── Test Result Recording ─────────────────────────────────────────────
record_pass() {
local name="$1"
local detail="${2:-}"
((PASS++)) || true
((TOTAL++)) || true
RESULTS+=("PASS|${name}|${detail}")
if [[ "$OUTPUT_FORMAT" == "tap" ]]; then
echo "ok ${TOTAL} - ${name}"
else
echo -e " ${GREEN}${RESET} ${name}${detail:+ — ${detail}}"
fi
}
record_fail() {
local name="$1"
local detail="${2:-}"
((FAIL++)) || true
((TOTAL++)) || true
RESULTS+=("FAIL|${name}|${detail}")
if [[ "$OUTPUT_FORMAT" == "tap" ]]; then
echo "not ok ${TOTAL} - ${name}"
[[ -n "$detail" ]] && echo " # ${detail}"
else
echo -e " ${RED}${RESET} ${name}${detail:+ — ${detail}}"
fi
}
record_skip() {
local name="$1"
local reason="${2:-}"
((SKIP++)) || true
((TOTAL++)) || true
RESULTS+=("SKIP|${name}|${reason}")
if [[ "$OUTPUT_FORMAT" == "tap" ]]; then
echo "ok ${TOTAL} - ${name} # SKIP ${reason}"
else
echo -e " ${YELLOW}${RESET} ${name}${reason:+ — ${reason}}"
fi
}
# ══════════════════════════════════════════════════════════════════════
# TEST SUITES
# ══════════════════════════════════════════════════════════════════════
# ── 1. Binary & Config ───────────────────────────────────────────────
test_binary_and_config() {
if [[ "$OUTPUT_FORMAT" != "tap" ]]; then
echo ""
echo -e "${BOLD}Binary & Config${RESET}"
fi
# 1. logrotate binary exists and is executable
local lr_path
lr_path=$(command -v logrotate 2>/dev/null) || lr_path=""
if [[ -n "$lr_path" && -x "$lr_path" ]]; then
record_pass "logrotate binary exists" "$lr_path"
else
record_fail "logrotate binary exists" "logrotate not found in PATH"
fi
# 2. /etc/logrotate.conf exists and is readable
if [[ -r /etc/logrotate.conf ]]; then
record_pass "/etc/logrotate.conf exists and is readable"
else
record_fail "/etc/logrotate.conf exists and is readable" "file missing or not readable"
fi
# 4. /etc/logrotate.d/ directory exists with configs
if [[ -d /etc/logrotate.d ]]; then
local conf_count
conf_count=$(find /etc/logrotate.d -maxdepth 1 -type f 2>/dev/null | wc -l)
if [[ "$conf_count" -gt 0 ]]; then
record_pass "/etc/logrotate.d/ directory exists" "${conf_count} config files"
else
record_fail "/etc/logrotate.d/ directory exists" "directory exists but contains no config files"
fi
else
record_fail "/etc/logrotate.d/ directory exists" "directory missing"
fi
}
# ── 2. Config Syntax ─────────────────────────────────────────────────
test_config_syntax() {
if [[ "$SKIP_SYNTAX" == "true" ]]; then
if [[ "$OUTPUT_FORMAT" != "tap" ]]; then
echo ""
echo -e "${BOLD}Config Syntax${RESET}"
fi
record_skip "logrotate.conf syntax valid" "SKIP_SYNTAX=true"
return
fi
local lr_path
lr_path=$(command -v logrotate 2>/dev/null) || lr_path=""
if [[ -z "$lr_path" ]]; then
if [[ "$OUTPUT_FORMAT" != "tap" ]]; then
echo ""
echo -e "${BOLD}Config Syntax${RESET}"
fi
record_skip "logrotate.conf syntax valid" "logrotate binary not found"
return
fi
if [[ "$OUTPUT_FORMAT" != "tap" ]]; then
echo ""
echo -e "${BOLD}Config Syntax${RESET}"
fi
# 3. logrotate config syntax valid
local syntax_output
if syntax_output=$(logrotate -d /etc/logrotate.conf 2>&1); then
record_pass "logrotate.conf syntax valid"
verbose "logrotate -d output: ${syntax_output:0:200}"
else
record_fail "logrotate.conf syntax valid" "logrotate -d returned errors"
verbose "logrotate -d output: ${syntax_output:0:500}"
fi
# 5. Individual config syntax check
if [[ -d /etc/logrotate.d ]]; then
local cfg
for cfg in /etc/logrotate.d/*; do
[[ -f "$cfg" ]] || continue
local cfg_name
cfg_name=$(basename "$cfg")
local cfg_output
if cfg_output=$(logrotate -d "$cfg" 2>&1); then
record_pass "logrotate.d/${cfg_name}" "syntax valid"
else
record_fail "logrotate.d/${cfg_name}" "syntax error"
verbose "logrotate -d ${cfg} output: ${cfg_output:0:500}"
fi
done
fi
}
# ── 3. Log Hygiene ───────────────────────────────────────────────────
test_log_hygiene() {
if [[ "$OUTPUT_FORMAT" != "tap" ]]; then
echo ""
echo -e "${BOLD}Log Hygiene${RESET}"
fi
# 6. Compressed log archives exist where expected
local archive_count
archive_count=$(find "$LOG_DIR" -maxdepth 2 -type f \( -name '*.gz' -o -name '*.xz' \) 2>/dev/null | wc -l)
if [[ "$archive_count" -gt 0 ]]; then
record_pass "Compressed log archives exist" "${archive_count} archives found"
else
record_fail "Compressed log archives exist" "no .gz or .xz files found in ${LOG_DIR}"
fi
# 7. No oversized unrotated logs
local oversized
oversized=$(find "$LOG_DIR" -maxdepth 2 -type f -not -name '*.gz' -not -name '*.xz' \
-size +"${MAX_LOG_SIZE_MB}M" 2>/dev/null) || oversized=""
if [[ -z "$oversized" ]]; then
record_pass "No oversized unrotated logs" "all under ${MAX_LOG_SIZE_MB} MB"
else
local oversized_count
oversized_count=$(echo "$oversized" | wc -l)
local first_file
first_file=$(echo "$oversized" | head -1)
record_fail "No oversized unrotated logs" "${oversized_count} file(s) exceed ${MAX_LOG_SIZE_MB} MB (e.g. ${first_file})"
fi
# 8. Log directory permissions
if [[ -d "$LOG_DIR" ]]; then
local dir_owner
dir_owner=$(stat -c '%U' "$LOG_DIR" 2>/dev/null) || dir_owner="unknown"
local dir_perms
dir_perms=$(stat -c '%a' "$LOG_DIR" 2>/dev/null) || dir_perms="000"
local world_writable=false
if [[ "${dir_perms: -1}" =~ [2367] ]]; then
world_writable=true
fi
if [[ "$dir_owner" == "root" && "$world_writable" == "false" ]]; then
record_pass "${LOG_DIR} permissions" "owner ${dir_owner}, mode ${dir_perms}"
elif [[ "$dir_owner" != "root" ]]; then
record_fail "${LOG_DIR} permissions" "owner is ${dir_owner}, expected root"
else
record_fail "${LOG_DIR} permissions" "world-writable (mode ${dir_perms})"
fi
else
record_fail "${LOG_DIR} permissions" "directory does not exist"
fi
}
# ── 4. Disk & Retention ──────────────────────────────────────────────
test_disk_and_retention() {
if [[ "$OUTPUT_FORMAT" != "tap" ]]; then
echo ""
echo -e "${BOLD}Disk & Retention${RESET}"
fi
# 9. /var/log disk usage under threshold
local usage_pct
usage_pct=$(df "$LOG_DIR" 2>/dev/null | awk 'NR==2 {gsub(/%/,""); print $5}') || usage_pct=""
if [[ -n "$usage_pct" ]]; then
if [[ "$usage_pct" -lt "$MAX_DISK_USAGE_PCT" ]]; then
record_pass "${LOG_DIR} disk usage" "${usage_pct}% (under ${MAX_DISK_USAGE_PCT}% threshold)"
else
record_fail "${LOG_DIR} disk usage" "${usage_pct}% (exceeds ${MAX_DISK_USAGE_PCT}% threshold)"
fi
else
record_skip "${LOG_DIR} disk usage" "could not determine disk usage"
fi
# 10. Stale .gz/.xz files older than retention threshold
local stale_count
stale_count=$(find "$LOG_DIR" -maxdepth 2 -type f \( -name '*.gz' -o -name '*.xz' \) \
-mtime +"${MAX_ARCHIVE_AGE_DAYS}" 2>/dev/null | wc -l)
if [[ "$stale_count" -eq 0 ]]; then
record_pass "No stale archives" "none older than ${MAX_ARCHIVE_AGE_DAYS} days"
else
record_fail "No stale archives" "${stale_count} archive(s) older than ${MAX_ARCHIVE_AGE_DAYS} days"
fi
# 11. logrotate status file exists and is recent
local status_file="/var/lib/logrotate/logrotate.status"
if [[ ! -f "$status_file" ]]; then
# Some distros use /var/lib/logrotate.status
status_file="/var/lib/logrotate.status"
fi
if [[ -f "$status_file" ]]; then
local status_mtime
status_mtime=$(stat -c '%Y' "$status_file" 2>/dev/null) || status_mtime=0
local now
now=$(date +%s)
local age_days=$(( (now - status_mtime) / 86400 ))
if [[ "$age_days" -le 2 ]]; then
local status_date
status_date=$(date -d "@${status_mtime}" +%Y-%m-%d 2>/dev/null) || status_date="unknown"
record_pass "logrotate status file" "updated ${status_date}"
else
record_fail "logrotate status file" "last updated ${age_days} days ago"
fi
else
record_fail "logrotate status file" "not found at /var/lib/logrotate/logrotate.status or /var/lib/logrotate.status"
fi
}
# ── 5. Common Misconfigs ─────────────────────────────────────────────
test_misconfigs() {
if [[ "$OUTPUT_FORMAT" != "tap" ]]; then
echo ""
echo -e "${BOLD}Common Misconfigs${RESET}"
fi
if [[ ! -d /etc/logrotate.d ]]; then
record_skip "Config 'rotate' directive check" "/etc/logrotate.d not found"
record_skip "Config 'compress' directive check" "/etc/logrotate.d not found"
return
fi
# 12a. Check for missing 'rotate' directive
local missing_rotate=()
local cfg
for cfg in /etc/logrotate.d/*; do
[[ -f "$cfg" ]] || continue
if ! grep -qE '^\s*rotate\s+' "$cfg" 2>/dev/null; then
# Check if the main config has a global rotate
if ! grep -qE '^\s*rotate\s+' /etc/logrotate.conf 2>/dev/null; then
missing_rotate+=("$(basename "$cfg")")
fi
fi
done
if [[ ${#missing_rotate[@]} -eq 0 ]]; then
record_pass "All configs have 'rotate' directive"
else
record_fail "All configs have 'rotate' directive" "missing in: ${missing_rotate[*]}"
fi
# 12b. Check for missing 'compress' directive
local missing_compress=()
for cfg in /etc/logrotate.d/*; do
[[ -f "$cfg" ]] || continue
if ! grep -qE '^\s*(compress|nocompress)' "$cfg" 2>/dev/null; then
if ! grep -qE '^\s*compress' /etc/logrotate.conf 2>/dev/null; then
missing_compress+=("$(basename "$cfg")")
fi
fi
done
if [[ ${#missing_compress[@]} -eq 0 ]]; then
record_pass "All configs have 'compress' directive"
else
record_fail "All configs have 'compress' directive" "missing in: ${missing_compress[*]}"
fi
}
# ══════════════════════════════════════════════════════════════════════
# OUTPUT
# ══════════════════════════════════════════════════════════════════════
print_summary() {
local end_time
end_time=$(date +%s)
local duration=$(( end_time - START_TIME ))
echo ""
echo -e "${BOLD}────────────────────────────────────────${RESET}"
echo -e "${BOLD}Summary${RESET} ${LOG_DIR}"
echo -e " ${GREEN}${PASS} passed${RESET} ${RED}${FAIL} failed${RESET} ${YELLOW}${SKIP} skipped${RESET} (${duration}s)"
echo -e "${BOLD}────────────────────────────────────────${RESET}"
if [[ $FAIL -eq 0 ]]; then
echo -e "${GREEN}${BOLD}All tests passed.${RESET}"
else
echo -e "${RED}${BOLD}${FAIL} test(s) failed.${RESET}"
fi
}
print_tap_header() {
echo "TAP version 13"
}
print_tap_footer() {
echo "1..${TOTAL}"
echo "# pass ${PASS}"
echo "# fail ${FAIL}"
echo "# skip ${SKIP}"
}
write_junit() {
local end_time
end_time=$(date +%s)
local duration=$(( end_time - START_TIME ))
cat > "$JUNIT_FILE" <<JUNIT_EOF
<?xml version="1.0" encoding="UTF-8"?>
<testsuites tests="${TOTAL}" failures="${FAIL}" skipped="${SKIP}" time="${duration}">
<testsuite name="logrotate-smoke-tests" tests="${TOTAL}" failures="${FAIL}" skipped="${SKIP}" time="${duration}">
JUNIT_EOF
for result in "${RESULTS[@]}"; do
local status name detail
status=$(echo "$result" | cut -d'|' -f1)
name=$(echo "$result" | cut -d'|' -f2)
detail=$(echo "$result" | cut -d'|' -f3)
# XML-escape the values
name=$(echo "$name" | sed 's/&/\&amp;/g; s/</\&lt;/g; s/>/\&gt;/g; s/"/\&quot;/g')
detail=$(echo "$detail" | sed 's/&/\&amp;/g; s/</\&lt;/g; s/>/\&gt;/g; s/"/\&quot;/g')
case "$status" in
PASS)
echo " <testcase name=\"${name}\" classname=\"smoke\">" >> "$JUNIT_FILE"
[[ -n "$detail" ]] && echo " <system-out>${detail}</system-out>" >> "$JUNIT_FILE"
echo " </testcase>" >> "$JUNIT_FILE"
;;
FAIL)
echo " <testcase name=\"${name}\" classname=\"smoke\">" >> "$JUNIT_FILE"
echo " <failure message=\"${detail}\">FAILED: ${name}${detail}</failure>" >> "$JUNIT_FILE"
echo " </testcase>" >> "$JUNIT_FILE"
;;
SKIP)
echo " <testcase name=\"${name}\" classname=\"smoke\">" >> "$JUNIT_FILE"
echo " <skipped message=\"${detail}\"/>" >> "$JUNIT_FILE"
echo " </testcase>" >> "$JUNIT_FILE"
;;
esac
done
echo " </testsuite>" >> "$JUNIT_FILE"
echo "</testsuites>" >> "$JUNIT_FILE"
log "JUnit report written to ${JUNIT_FILE}"
}
# ══════════════════════════════════════════════════════════════════════
# MAIN
# ══════════════════════════════════════════════════════════════════════
usage() {
cat <<EOF
Usage: $(basename "$0") [OPTIONS]
Smoke-test logrotate configuration, rotation health, and log hygiene.
Read-only — never modifies configs or log files.
Options:
--format FORMAT Output: text (default), tap, junit
--junit-file FILE JUnit output path (default: smoke-results.xml)
--verbose Show debug output
--no-color Disable colored output
--help Show this help
Environment variables:
LOG_DIR Log directory (default: /var/log)
MAX_LOG_SIZE_MB Oversized log threshold in MB (default: 500)
MAX_DISK_USAGE_PCT Disk usage threshold in % (default: 80)
MAX_ARCHIVE_AGE_DAYS Stale archive threshold in days (default: 90)
SKIP_SYNTAX Skip logrotate -d syntax checks (default: false)
Examples:
# Basic run
sudo ./$(basename "$0")
# Custom thresholds
MAX_LOG_SIZE_MB=200 MAX_DISK_USAGE_PCT=70 sudo ./$(basename "$0")
# Skip syntax checks
SKIP_SYNTAX=true sudo ./$(basename "$0")
# JUnit output for CI
sudo ./$(basename "$0") --format junit --junit-file results.xml
# TAP output
sudo ./$(basename "$0") --format tap
EOF
}
main() {
# Parse arguments
while [[ $# -gt 0 ]]; do
case "$1" in
--format) OUTPUT_FORMAT="$2"; shift ;;
--junit-file) JUNIT_FILE="$2"; shift ;;
--verbose) VERBOSE=true ;;
--no-color) COLOR=never ;;
--help|-h) usage; exit 0 ;;
*) err "Unknown option: $1"; usage; exit 1 ;;
esac
shift
done
setup_colors
START_TIME=$(date +%s)
if [[ "$OUTPUT_FORMAT" == "tap" ]]; then
print_tap_header
else
echo ""
echo -e "${BOLD}Log Rotation Smoke Tests${RESET}"
echo -e "Target: ${LOG_DIR}"
echo -e "Time: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
fi
# Run test suites
test_binary_and_config
test_config_syntax
test_log_hygiene
test_disk_and_retention
test_misconfigs
# Output
if [[ "$OUTPUT_FORMAT" == "tap" ]]; then
print_tap_footer
elif [[ "$OUTPUT_FORMAT" == "junit" ]]; then
print_summary
write_junit
else
print_summary
fi
# Exit code
[[ $FAIL -eq 0 ]] && exit 0 || exit 1
}
main "$@"