a1a17e81a1
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.
456 lines
17 KiB
Bash
456 lines
17 KiB
Bash
#!/usr/bin/env bash
|
|
|
|
#########################################################################################
|
|
#### changelog-diff.sh — Compare installed package versions between snapshots ####
|
|
#### Supports before/after upgrade diffs, remote host comparison via SSH ####
|
|
#### Detects additions, removals, upgrades, and downgrades across dpkg and rpm ####
|
|
#### ####
|
|
#### Author: Phil Connor ####
|
|
#### Contact: contact@mylinux.work ####
|
|
#### License: MIT ####
|
|
#### Version 1.00 ####
|
|
#### ####
|
|
#### Usage: ####
|
|
#### ./changelog-diff.sh --snapshot ####
|
|
#### ./changelog-diff.sh --diff snap1.txt snap2.txt ####
|
|
#### ./changelog-diff.sh --remote user@host ####
|
|
#### ####
|
|
#### See --help for all options. ####
|
|
#########################################################################################
|
|
|
|
set -euo pipefail
|
|
|
|
# ── Defaults ──────────────────────────────────────────────────────────
|
|
VERBOSE="${VERBOSE:-false}"
|
|
COLOR="${COLOR:-auto}"
|
|
SNAPSHOT_DIR="${SNAPSHOT_DIR:-/var/backups/pkg-snapshots}"
|
|
MODE=""
|
|
DIFF_FILE_A=""
|
|
DIFF_FILE_B=""
|
|
REMOTE_HOST=""
|
|
|
|
# ── 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; }
|
|
|
|
# ── Helpers ───────────────────────────────────────────────────────────
|
|
section_header() {
|
|
echo ""
|
|
echo -e " ${BOLD}${CYAN}── $1 ──${RESET}"
|
|
echo ""
|
|
}
|
|
|
|
field() {
|
|
printf " ${BOLD}%-22s${RESET} %s\n" "$1" "$2"
|
|
}
|
|
|
|
detect_package_manager() {
|
|
if command -v dpkg-query &>/dev/null; then
|
|
echo "dpkg"
|
|
elif command -v rpm &>/dev/null; then
|
|
echo "rpm"
|
|
else
|
|
echo "unknown"
|
|
fi
|
|
}
|
|
|
|
get_package_list() {
|
|
local pm
|
|
pm=$(detect_package_manager)
|
|
case "$pm" in
|
|
dpkg)
|
|
dpkg-query -W -f='${Package} ${Version}\n' 2>/dev/null | sort
|
|
;;
|
|
rpm)
|
|
rpm -qa --queryformat '%{NAME} %{VERSION}-%{RELEASE}\n' 2>/dev/null | sort
|
|
;;
|
|
*)
|
|
err "No supported package manager found (need dpkg or rpm)"
|
|
exit 1
|
|
;;
|
|
esac
|
|
}
|
|
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
# SNAPSHOT
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
|
|
do_snapshot() {
|
|
if [[ ! -d "$SNAPSHOT_DIR" ]]; then
|
|
mkdir -p "$SNAPSHOT_DIR" 2>/dev/null || {
|
|
err "Cannot create snapshot directory: ${SNAPSHOT_DIR}"
|
|
exit 1
|
|
}
|
|
fi
|
|
|
|
local timestamp
|
|
timestamp=$(date +%Y%m%d-%H%M%S)
|
|
local host
|
|
host=$(hostname -s 2>/dev/null || hostname)
|
|
local pm
|
|
pm=$(detect_package_manager)
|
|
local snapshot_file="${SNAPSHOT_DIR}/${host}-${pm}-${timestamp}.txt"
|
|
|
|
verbose "Package manager: ${pm}"
|
|
verbose "Snapshot file: ${snapshot_file}"
|
|
|
|
get_package_list > "$snapshot_file"
|
|
|
|
local pkg_count
|
|
pkg_count=$(wc -l < "$snapshot_file")
|
|
|
|
log "Snapshot saved: ${snapshot_file}"
|
|
field "Packages:" "$pkg_count"
|
|
field "Package manager:" "$pm"
|
|
field "File:" "$snapshot_file"
|
|
}
|
|
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
# DIFF
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
|
|
do_diff() {
|
|
local file_a="$1"
|
|
local file_b="$2"
|
|
|
|
if [[ ! -f "$file_a" ]]; then
|
|
err "File not found: ${file_a}"
|
|
exit 1
|
|
fi
|
|
if [[ ! -f "$file_b" ]]; then
|
|
err "File not found: ${file_b}"
|
|
exit 1
|
|
fi
|
|
|
|
echo ""
|
|
echo -e "${BOLD}Package Diff${RESET}"
|
|
field "Before:" "$file_a"
|
|
field "After:" "$file_b"
|
|
|
|
local added=0 removed=0 upgraded=0 downgraded=0
|
|
|
|
# Build associative arrays
|
|
local tmp_added tmp_removed tmp_changed
|
|
tmp_added=$(mktemp)
|
|
tmp_removed=$(mktemp)
|
|
tmp_changed=$(mktemp)
|
|
trap 'rm -f "$tmp_added" "$tmp_removed" "$tmp_changed"' EXIT
|
|
|
|
# Find added packages (in B but not A)
|
|
while IFS=' ' read -r pkg ver; do
|
|
if ! grep -q "^${pkg} " "$file_a"; then
|
|
echo "${pkg} ${ver}" >> "$tmp_added"
|
|
fi
|
|
done < "$file_b"
|
|
|
|
# Find removed packages (in A but not B)
|
|
while IFS=' ' read -r pkg ver; do
|
|
if ! grep -q "^${pkg} " "$file_b"; then
|
|
echo "${pkg} ${ver}" >> "$tmp_removed"
|
|
fi
|
|
done < "$file_a"
|
|
|
|
# Find changed packages
|
|
while IFS=' ' read -r pkg ver_b; do
|
|
local ver_a
|
|
ver_a=$(grep "^${pkg} " "$file_a" 2>/dev/null | head -1 | cut -d' ' -f2-)
|
|
if [[ -n "$ver_a" && "$ver_a" != "$ver_b" ]]; then
|
|
echo "${pkg} ${ver_a} ${ver_b}" >> "$tmp_changed"
|
|
fi
|
|
done < "$file_b"
|
|
|
|
# Display additions
|
|
if [[ -s "$tmp_added" ]]; then
|
|
section_header "Added Packages"
|
|
while IFS=' ' read -r pkg ver; do
|
|
printf " ${CYAN}+${RESET} %-40s %s\n" "$pkg" "$ver"
|
|
added=$((added + 1))
|
|
done < "$tmp_added"
|
|
fi
|
|
|
|
# Display removals
|
|
if [[ -s "$tmp_removed" ]]; then
|
|
section_header "Removed Packages"
|
|
while IFS=' ' read -r pkg ver; do
|
|
printf " ${RED}-${RESET} %-40s %s\n" "$pkg" "$ver"
|
|
removed=$((removed + 1))
|
|
done < "$tmp_removed"
|
|
fi
|
|
|
|
# Display upgrades and downgrades
|
|
if [[ -s "$tmp_changed" ]]; then
|
|
local has_upgrades=false
|
|
local has_downgrades=false
|
|
|
|
# First pass: categorize
|
|
while IFS=' ' read -r pkg ver_a ver_b; do
|
|
if dpkg --compare-versions "$ver_b" gt "$ver_a" 2>/dev/null; then
|
|
has_upgrades=true
|
|
elif dpkg --compare-versions "$ver_b" lt "$ver_a" 2>/dev/null; then
|
|
has_downgrades=true
|
|
else
|
|
# Fallback: string comparison
|
|
if [[ "$ver_b" > "$ver_a" ]]; then
|
|
has_upgrades=true
|
|
else
|
|
has_downgrades=true
|
|
fi
|
|
fi
|
|
done < "$tmp_changed"
|
|
|
|
if [[ "$has_upgrades" == "true" ]]; then
|
|
section_header "Upgraded Packages"
|
|
while IFS=' ' read -r pkg ver_a ver_b; do
|
|
local is_upgrade=false
|
|
if dpkg --compare-versions "$ver_b" gt "$ver_a" 2>/dev/null; then
|
|
is_upgrade=true
|
|
elif ! dpkg --compare-versions "$ver_b" lt "$ver_a" 2>/dev/null && [[ "$ver_b" > "$ver_a" ]]; then
|
|
is_upgrade=true
|
|
fi
|
|
if [[ "$is_upgrade" == "true" ]]; then
|
|
printf " ${GREEN}↑${RESET} %-35s %s → %s\n" "$pkg" "$ver_a" "$ver_b"
|
|
upgraded=$((upgraded + 1))
|
|
fi
|
|
done < "$tmp_changed"
|
|
fi
|
|
|
|
if [[ "$has_downgrades" == "true" ]]; then
|
|
section_header "Downgraded Packages"
|
|
while IFS=' ' read -r pkg ver_a ver_b; do
|
|
local is_downgrade=false
|
|
if dpkg --compare-versions "$ver_b" lt "$ver_a" 2>/dev/null; then
|
|
is_downgrade=true
|
|
elif ! dpkg --compare-versions "$ver_b" gt "$ver_a" 2>/dev/null && [[ "$ver_b" < "$ver_a" ]]; then
|
|
is_downgrade=true
|
|
fi
|
|
if [[ "$is_downgrade" == "true" ]]; then
|
|
printf " ${YELLOW}↓${RESET} %-35s %s → %s\n" "$pkg" "$ver_a" "$ver_b"
|
|
downgraded=$((downgraded + 1))
|
|
fi
|
|
done < "$tmp_changed"
|
|
fi
|
|
fi
|
|
|
|
# Summary
|
|
local total=$((added + removed + upgraded + downgraded))
|
|
|
|
echo ""
|
|
echo -e " ${BOLD}══════════════════════════════════════════${RESET}"
|
|
echo -e " ${BOLD}Change Summary${RESET}"
|
|
echo -e " ${BOLD}══════════════════════════════════════════${RESET}"
|
|
field "Total changes:" "$total"
|
|
if [[ "$added" -gt 0 ]]; then
|
|
printf " ${BOLD}%-22s${RESET} ${CYAN}%s${RESET}\n" "Additions:" "$added"
|
|
else
|
|
field "Additions:" "0"
|
|
fi
|
|
if [[ "$removed" -gt 0 ]]; then
|
|
printf " ${BOLD}%-22s${RESET} ${RED}%s${RESET}\n" "Removals:" "$removed"
|
|
else
|
|
field "Removals:" "0"
|
|
fi
|
|
if [[ "$upgraded" -gt 0 ]]; then
|
|
printf " ${BOLD}%-22s${RESET} ${GREEN}%s${RESET}\n" "Upgrades:" "$upgraded"
|
|
else
|
|
field "Upgrades:" "0"
|
|
fi
|
|
if [[ "$downgraded" -gt 0 ]]; then
|
|
printf " ${BOLD}%-22s${RESET} ${YELLOW}%s${RESET}\n" "Downgrades:" "$downgraded"
|
|
else
|
|
field "Downgrades:" "0"
|
|
fi
|
|
echo ""
|
|
}
|
|
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
# REMOTE COMPARE
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
|
|
do_remote() {
|
|
local remote="$1"
|
|
|
|
if ! command -v ssh &>/dev/null; then
|
|
err "ssh is required for remote comparison"
|
|
exit 1
|
|
fi
|
|
|
|
log "Fetching local package list..."
|
|
local local_file
|
|
local_file=$(mktemp)
|
|
|
|
log "Fetching remote package list from ${remote}..."
|
|
local remote_file
|
|
remote_file=$(mktemp)
|
|
trap 'rm -f "$local_file" "$remote_file"' EXIT
|
|
|
|
get_package_list > "$local_file"
|
|
|
|
local pm
|
|
pm=$(detect_package_manager)
|
|
case "$pm" in
|
|
dpkg)
|
|
ssh "$remote" "dpkg-query -W -f='\${Package} \${Version}\n' 2>/dev/null | sort" > "$remote_file" || {
|
|
err "Failed to fetch package list from ${remote}"
|
|
exit 1
|
|
}
|
|
;;
|
|
rpm)
|
|
ssh "$remote" "rpm -qa --queryformat '%{NAME} %{VERSION}-%{RELEASE}\n' 2>/dev/null | sort" > "$remote_file" || {
|
|
err "Failed to fetch package list from ${remote}"
|
|
exit 1
|
|
}
|
|
;;
|
|
esac
|
|
|
|
log "Comparing local vs ${remote}..."
|
|
do_diff "$local_file" "$remote_file"
|
|
}
|
|
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
# USAGE
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
|
|
usage() {
|
|
cat <<EOF
|
|
${SCRIPT_NAME} — Compare installed package versions between snapshots
|
|
|
|
USAGE:
|
|
${SCRIPT_NAME} --snapshot
|
|
${SCRIPT_NAME} --diff <before.txt> <after.txt>
|
|
${SCRIPT_NAME} --remote <user@host>
|
|
|
|
MODES:
|
|
--snapshot Save current package list to a timestamped file
|
|
--diff FILE1 FILE2 Compare two snapshot files
|
|
--remote HOST Compare local packages with a remote host via SSH
|
|
|
|
OPTIONS:
|
|
--snapshot-dir DIR Directory for snapshots (default: ${SNAPSHOT_DIR})
|
|
--verbose Enable debug output
|
|
--no-color Disable colored output
|
|
--help Show this help
|
|
|
|
ENVIRONMENT VARIABLES:
|
|
SNAPSHOT_DIR Snapshot directory (default: /var/backups/pkg-snapshots)
|
|
COLOR Color mode: auto, always, never (default: auto)
|
|
|
|
EXAMPLES:
|
|
# Take a snapshot before upgrade
|
|
./changelog-diff.sh --snapshot
|
|
|
|
# Upgrade packages, take another snapshot, then diff
|
|
./changelog-diff.sh --snapshot
|
|
sudo apt upgrade -y
|
|
./changelog-diff.sh --snapshot
|
|
./changelog-diff.sh --diff /var/backups/pkg-snapshots/host-*.txt
|
|
|
|
# Compare with a remote server
|
|
./changelog-diff.sh --remote admin@prod-server
|
|
|
|
# Custom snapshot directory
|
|
./changelog-diff.sh --snapshot --snapshot-dir /tmp/snapshots
|
|
EOF
|
|
}
|
|
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
# ARGUMENT PARSING
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
|
|
parse_args() {
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--snapshot)
|
|
MODE="snapshot"; shift ;;
|
|
--diff)
|
|
MODE="diff"
|
|
if [[ $# -lt 3 ]]; then
|
|
err "--diff requires two file arguments"
|
|
exit 1
|
|
fi
|
|
DIFF_FILE_A="$2"
|
|
DIFF_FILE_B="$3"
|
|
shift 3 ;;
|
|
--remote)
|
|
MODE="remote"
|
|
if [[ $# -lt 2 ]]; then
|
|
err "--remote requires a host argument"
|
|
exit 1
|
|
fi
|
|
REMOTE_HOST="$2"
|
|
shift 2 ;;
|
|
--snapshot-dir)
|
|
SNAPSHOT_DIR="$2"; shift 2 ;;
|
|
--verbose)
|
|
VERBOSE="true"; shift ;;
|
|
--no-color)
|
|
COLOR="never"; shift ;;
|
|
--help|-h)
|
|
setup_colors
|
|
usage
|
|
exit 0 ;;
|
|
*)
|
|
err "Unknown option: $1"
|
|
echo "Run ${SCRIPT_NAME} --help for usage" >&2
|
|
exit 1 ;;
|
|
esac
|
|
done
|
|
|
|
if [[ -z "$MODE" ]]; then
|
|
err "No mode specified. Use --snapshot, --diff, or --remote"
|
|
echo "Run ${SCRIPT_NAME} --help for usage" >&2
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
# MAIN
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
|
|
main() {
|
|
parse_args "$@"
|
|
setup_colors
|
|
|
|
echo ""
|
|
echo -e "${BOLD}Package Changelog Diff — $(hostname -f 2>/dev/null || hostname)${RESET}"
|
|
echo -e "${DIM}$(date '+%Y-%m-%d %H:%M:%S %Z')${RESET}"
|
|
|
|
case "$MODE" in
|
|
snapshot)
|
|
do_snapshot
|
|
;;
|
|
diff)
|
|
do_diff "$DIFF_FILE_A" "$DIFF_FILE_B"
|
|
;;
|
|
remote)
|
|
do_remote "$REMOTE_HOST"
|
|
;;
|
|
esac
|
|
}
|
|
|
|
main "$@"
|