#!/usr/bin/env bash ######################################################################################### #### cron-lister.sh — List all cron jobs across users, system cron, and timers #### #### Scans user crontabs, /etc/cron.*, systemd timers, and anacron #### #### #### #### Author: Phil Connor #### #### Contact: contact@mylinux.work #### #### License: MIT #### #### Version 1.00 #### #### #### #### Usage: #### #### ./cron-lister.sh #### #### ./cron-lister.sh --format raw #### #### #### #### See --help for all options. #### ######################################################################################### set -euo pipefail # ── Defaults ────────────────────────────────────────────────────────── FORMAT="${FORMAT:-table}" VERBOSE="${VERBOSE:-false}" COLOR="${COLOR:-auto}" # ── State ───────────────────────────────────────────────────────────── SCRIPT_NAME="$(basename "$0")" readonly SCRIPT_NAME COUNT_USER_CRONTAB=0 COUNT_SYSTEM_CRONTAB=0 COUNT_CRON_D=0 COUNT_CRON_DIRS=0 COUNT_SYSTEMD_TIMER=0 COUNT_ANACRON=0 # ── Colors ──────────────────────────────────────────────────────────── setup_colors() { if [[ "$COLOR" == "never" ]]; then GREEN="" YELLOW="" BLUE="" MAGENTA="" CYAN="" BOLD="" DIM="" RESET="" return fi if [[ "$COLOR" == "always" ]] || [[ -t 1 ]]; then GREEN='\033[0;32m' YELLOW='\033[0;33m' BLUE='\033[0;34m' MAGENTA='\033[0;35m' CYAN='\033[0;36m' BOLD='\033[1m' DIM='\033[2m' RESET='\033[0m' else GREEN="" YELLOW="" BLUE="" MAGENTA="" CYAN="" BOLD="" DIM="" RESET="" fi } # ── Logging ─────────────────────────────────────────────────────────── log() { echo -e "${DIM}[INFO]${RESET} $*"; } warn() { echo -e "${YELLOW}[WARN]${RESET} $*" >&2; } verbose() { if [[ "$VERBOSE" == "true" ]]; then echo -e "${DIM}[DEBUG]${RESET} $*"; fi; } # ── Helpers ─────────────────────────────────────────────────────────── section_header() { echo "" echo -e " ${BOLD}${CYAN}── $1 ──${RESET}" echo "" } print_table_header() { printf " ${BOLD}%-18s %-14s %-22s %s${RESET}\n" "SOURCE" "USER/UNIT" "SCHEDULE" "COMMAND" printf " %s\n" "$(printf '%.0s─' {1..80})" } print_job() { local source="$1" local user="$2" local schedule="$3" local command="$4" # Truncate long commands if [[ ${#command} -gt 60 ]]; then command="${command:0:57}..." fi if [[ "$FORMAT" == "raw" ]]; then printf "%s\t%s\t%s\t%s\n" "$source" "$user" "$schedule" "$command" return fi local color case "$source" in user-crontab) color="$GREEN" ;; /etc/crontab) color="$BLUE" ;; /etc/cron.d/*) color="$CYAN" ;; cron.hourly|cron.daily|cron.weekly|cron.monthly) color="$MAGENTA" ;; systemd-timer) color="$YELLOW" ;; anacron) color="$DIM" ;; *) color="" ;; esac printf " %b%-18s%b %-14s %-22s %s\n" "$color" "$source" "$RESET" "$user" "$schedule" "$command" } # ══════════════════════════════════════════════════════════════════════ # USER CRONTABS # ══════════════════════════════════════════════════════════════════════ scan_user_crontabs() { section_header "User Crontabs" local crontab_dir="/var/spool/cron/crontabs" local found=false if [[ -d "$crontab_dir" ]] && [[ -r "$crontab_dir" ]]; then while IFS= read -r crontab_file; do [[ -z "$crontab_file" ]] && continue found=true local username username=$(basename "$crontab_file") verbose "Reading crontab for user: $username" while IFS= read -r line; do # Skip comments and empty lines [[ -z "$line" || "$line" == "#"* || "$line" == "SHELL="* || "$line" == "PATH="* || "$line" == "MAILTO="* ]] && continue local schedule cmd schedule=$(echo "$line" | awk '{print $1, $2, $3, $4, $5}') cmd=$(echo "$line" | awk '{for(i=6;i<=NF;i++) printf "%s ", $i; print ""}' | sed 's/ *$//') print_job "user-crontab" "$username" "$schedule" "$cmd" COUNT_USER_CRONTAB=$((COUNT_USER_CRONTAB + 1)) done < "$crontab_file" done < <(find "$crontab_dir" -type f 2>/dev/null) fi if [[ "$found" == "false" ]]; then verbose "No user crontabs found in $crontab_dir" fi } # ══════════════════════════════════════════════════════════════════════ # SYSTEM CRONTAB # ══════════════════════════════════════════════════════════════════════ scan_system_crontab() { section_header "/etc/crontab" if [[ ! -f /etc/crontab ]]; then verbose "/etc/crontab not found" return fi while IFS= read -r line; do [[ -z "$line" || "$line" == "#"* || "$line" == "SHELL="* || "$line" == "PATH="* || "$line" == "MAILTO="* || "$line" == "HOME="* ]] && continue local schedule user cmd schedule=$(echo "$line" | awk '{print $1, $2, $3, $4, $5}') user=$(echo "$line" | awk '{print $6}') cmd=$(echo "$line" | awk '{for(i=7;i<=NF;i++) printf "%s ", $i; print ""}' | sed 's/ *$//') if [[ -n "$cmd" ]]; then print_job "/etc/crontab" "$user" "$schedule" "$cmd" COUNT_SYSTEM_CRONTAB=$((COUNT_SYSTEM_CRONTAB + 1)) fi done < /etc/crontab } # ══════════════════════════════════════════════════════════════════════ # /etc/cron.d # ══════════════════════════════════════════════════════════════════════ scan_cron_d() { section_header "/etc/cron.d" if [[ ! -d /etc/cron.d ]]; then verbose "/etc/cron.d not found" return fi while IFS= read -r cron_file; do [[ -z "$cron_file" ]] && continue local filename filename=$(basename "$cron_file") # Skip dpkg and package manager files [[ "$filename" == *.dpkg-* || "$filename" == *.ucf-* || "$filename" == "." || "$filename" == ".." ]] && continue verbose "Reading /etc/cron.d/$filename" while IFS= read -r line; do [[ -z "$line" || "$line" == "#"* || "$line" == "SHELL="* || "$line" == "PATH="* || "$line" == "MAILTO="* || "$line" == "HOME="* ]] && continue local schedule user cmd schedule=$(echo "$line" | awk '{print $1, $2, $3, $4, $5}') user=$(echo "$line" | awk '{print $6}') cmd=$(echo "$line" | awk '{for(i=7;i<=NF;i++) printf "%s ", $i; print ""}' | sed 's/ *$//') if [[ -n "$cmd" ]]; then print_job "/etc/cron.d/$filename" "$user" "$schedule" "$cmd" COUNT_CRON_D=$((COUNT_CRON_D + 1)) fi done < "$cron_file" done < <(find /etc/cron.d -maxdepth 1 -type f 2>/dev/null) } # ══════════════════════════════════════════════════════════════════════ # CRON DIRECTORIES # ══════════════════════════════════════════════════════════════════════ scan_cron_dirs() { section_header "Cron Directories" local period for period in hourly daily weekly monthly; do local dir="/etc/cron.${period}" if [[ ! -d "$dir" ]]; then continue fi while IFS= read -r script; do [[ -z "$script" ]] && continue local script_name script_name=$(basename "$script") # Skip non-executable and package manager leftovers [[ "$script_name" == *.dpkg-* || "$script_name" == *.ucf-* || "$script_name" == "." || "$script_name" == ".." ]] && continue if [[ -x "$script" ]]; then print_job "cron.${period}" "root" "$period" "$script_name" COUNT_CRON_DIRS=$((COUNT_CRON_DIRS + 1)) else verbose "Skipping non-executable: $script" fi done < <(find "$dir" -maxdepth 1 -type f 2>/dev/null) done } # ══════════════════════════════════════════════════════════════════════ # SYSTEMD TIMERS # ══════════════════════════════════════════════════════════════════════ scan_systemd_timers() { section_header "Systemd Timers" if ! command -v systemctl &>/dev/null; then verbose "systemd not available" return fi systemctl list-timers --all --no-legend --no-pager 2>/dev/null | while IFS= read -r line; do [[ -z "$line" ]] && continue local unit_name schedule_info # Timer unit is the second-to-last field, schedule is NEXT + LEFT unit_name=$(echo "$line" | awk '{print $(NF-1)}') schedule_info=$(echo "$line" | awk '{print $1, $2, $3}') if [[ -n "$unit_name" && "$unit_name" != "UNIT" ]]; then # Get the trigger schedule from the timer unit local on_calendar on_calendar=$(systemctl show "$unit_name" --property=TimersCalendar 2>/dev/null | sed 's/TimersCalendar=//' | head -1) if [[ -z "$on_calendar" || "$on_calendar" == "" ]]; then on_calendar=$(systemctl show "$unit_name" --property=TimersMonotonic 2>/dev/null | sed 's/TimersMonotonic=//' | head -1) fi on_calendar="${on_calendar:-$schedule_info}" # Truncate schedule if too long if [[ ${#on_calendar} -gt 20 ]]; then on_calendar="${on_calendar:0:17}..." fi print_job "systemd-timer" "$unit_name" "$on_calendar" "$(echo "$line" | awk '{print $NF}')" COUNT_SYSTEMD_TIMER=$((COUNT_SYSTEMD_TIMER + 1)) fi done } # ══════════════════════════════════════════════════════════════════════ # ANACRON # ══════════════════════════════════════════════════════════════════════ scan_anacron() { section_header "Anacron" if [[ ! -f /etc/anacrontab ]]; then verbose "/etc/anacrontab not found" return fi while IFS= read -r line; do [[ -z "$line" || "$line" == "#"* || "$line" == "SHELL="* || "$line" == "PATH="* || "$line" == "MAILTO="* || "$line" == "HOME="* || "$line" == "START_HOURS_RANGE="* || "$line" == "RANDOM_DELAY="* ]] && continue local period delay ident cmd period=$(echo "$line" | awk '{print $1}') delay=$(echo "$line" | awk '{print $2}') ident=$(echo "$line" | awk '{print $3}') cmd=$(echo "$line" | awk '{for(i=4;i<=NF;i++) printf "%s ", $i; print ""}' | sed 's/ *$//') if [[ -n "$cmd" ]]; then print_job "anacron" "$ident" "every ${period}d +${delay}m" "$cmd" COUNT_ANACRON=$((COUNT_ANACRON + 1)) fi done < /etc/anacrontab } # ══════════════════════════════════════════════════════════════════════ # SUMMARY # ══════════════════════════════════════════════════════════════════════ print_summary() { local total=$((COUNT_USER_CRONTAB + COUNT_SYSTEM_CRONTAB + COUNT_CRON_D + COUNT_CRON_DIRS + COUNT_SYSTEMD_TIMER + COUNT_ANACRON)) echo "" echo -e " ${BOLD}══════════════════════════════════════════${RESET}" echo -e " ${BOLD}Summary${RESET}" echo -e " ${BOLD}══════════════════════════════════════════${RESET}" printf " %-22s %d\n" "User crontabs:" "$COUNT_USER_CRONTAB" printf " %-22s %d\n" "/etc/crontab:" "$COUNT_SYSTEM_CRONTAB" printf " %-22s %d\n" "/etc/cron.d:" "$COUNT_CRON_D" printf " %-22s %d\n" "cron.{h,d,w,m}:" "$COUNT_CRON_DIRS" printf " %-22s %d\n" "Systemd timers:" "$COUNT_SYSTEMD_TIMER" printf " %-22s %d\n" "Anacron:" "$COUNT_ANACRON" printf " %s\n" "$(printf '%.0s─' {1..30})" printf " ${BOLD}%-22s %d${RESET}\n" "Total:" "$total" echo "" } # ══════════════════════════════════════════════════════════════════════ # USAGE # ══════════════════════════════════════════════════════════════════════ usage() { cat <&2 echo "Run ${SCRIPT_NAME} --help for usage" >&2 exit 1 ;; esac done if [[ "$FORMAT" != "table" && "$FORMAT" != "raw" ]]; then echo "Invalid format: $FORMAT (must be 'table' or 'raw')" >&2 exit 1 fi } # ══════════════════════════════════════════════════════════════════════ # MAIN # ══════════════════════════════════════════════════════════════════════ main() { parse_args "$@" setup_colors if [[ "$FORMAT" != "raw" ]]; then echo "" echo -e "${BOLD}Cron Job Lister — $(hostname -f 2>/dev/null || hostname)${RESET}" echo -e "${DIM}$(date '+%Y-%m-%d %H:%M:%S %Z')${RESET}" fi if [[ "$FORMAT" == "table" ]]; then echo "" print_table_header fi scan_user_crontabs scan_system_crontab scan_cron_d scan_cron_dirs scan_systemd_timers scan_anacron if [[ "$FORMAT" != "raw" ]]; then print_summary fi } main "$@"