Files
linux-scripts/cron-doctor.sh
T
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

523 lines
21 KiB
Bash

#!/usr/bin/env bash
#########################################################################################
#### cron-doctor.sh — Diagnose common cron and systemd timer problems ####
#### Checks PATH, missing binaries, unescaped %, output redirection, permissions, ####
#### overlap risk, and failed timer services ####
#### ####
#### Author: Phil Connor ####
#### Contact: contact@mylinux.work ####
#### License: MIT ####
#### Version 1.0 ####
#### ####
#### Usage: ####
#### ./cron-doctor.sh ####
#### ./cron-doctor.sh --user admin ####
#### ./cron-doctor.sh --fix-suggestions ####
#### ####
#### See --help for all options. ####
#########################################################################################
set -euo pipefail
# ── Defaults ──────────────────────────────────────────────────────────
SCRIPT_NAME="$(basename "$0")"
readonly SCRIPT_NAME
VERBOSE="${VERBOSE:-false}"
COLOR="${COLOR:-auto}"
TARGET_USER=""
FIX_SUGGESTIONS=false
CRON_ONLY=false
TIMERS_ONLY=false
# ── Counters ──────────────────────────────────────────────────────────
WARN_COUNT=0
FAIL_COUNT=0
INFO_COUNT=0
FAIL_MESSAGES=()
WARN_MESSAGES=()
# ── 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 " ${GREEN}[OK]${RESET} $*"; }
warn() { echo -e " ${YELLOW}[WARN]${RESET} $*"; WARN_MESSAGES+=("$*"); (( WARN_COUNT++ )) || true; }
fail() { echo -e " ${RED}[FAIL]${RESET} $*"; FAIL_MESSAGES+=("$*"); (( FAIL_COUNT++ )) || true; }
info() { echo -e " ${CYAN}[INFO]${RESET} $*"; (( INFO_COUNT++ )) || true; }
suggest() { [[ "$FIX_SUGGESTIONS" == "true" ]] && echo -e " ${DIM}$*${RESET}"; return 0; }
verbose() { [[ "$VERBOSE" == "true" ]] && echo -e " ${DIM}[DEBUG]${RESET} $*"; return 0; }
section() {
echo ""
echo -e " ${BOLD}${CYAN}── $1 ──${RESET}"
echo ""
}
# ── Usage ─────────────────────────────────────────────────────────────
usage() {
cat <<EOF
Usage: $SCRIPT_NAME [OPTIONS]
Diagnose common cron and systemd timer problems.
Options:
--user USERNAME Check only this user's crontab (default: all users)
--fix-suggestions Show suggested fix commands for each issue
--cron-only Skip systemd timer checks
--timers-only Skip cron checks
--verbose Show debug info
--no-color Disable color output
-h, --help Show this help
Examples:
$SCRIPT_NAME
$SCRIPT_NAME --user deploy --fix-suggestions
$SCRIPT_NAME --timers-only
EOF
exit 0
}
# ── Argument parsing ──────────────────────────────────────────────────
setup_colors
while [[ $# -gt 0 ]]; do
case "$1" in
--user) TARGET_USER="$2"; shift 2 ;;
--fix-suggestions) FIX_SUGGESTIONS=true; shift ;;
--cron-only) CRON_ONLY=true; shift ;;
--timers-only) TIMERS_ONLY=true; shift ;;
--verbose) VERBOSE=true; shift ;;
--no-color) COLOR="never"; shift ;;
-h|--help) usage ;;
*) echo "Unknown option: $1" >&2; usage ;;
esac
done
setup_colors # re-init in case --no-color was passed
# ── Detect crontab directory ──────────────────────────────────────────
detect_cron_spool() {
if [[ -d /var/spool/cron/crontabs ]]; then
echo "/var/spool/cron/crontabs" # Debian/Ubuntu
elif [[ -d /var/spool/cron ]]; then
echo "/var/spool/cron" # RHEL/Rocky
else
echo ""
fi
}
CRON_SPOOL="$(detect_cron_spool)"
# ── Get list of crontab files to check ────────────────────────────────
get_crontab_files() {
local files=()
if [[ -n "$CRON_SPOOL" ]]; then
if [[ -n "$TARGET_USER" ]]; then
[[ -f "$CRON_SPOOL/$TARGET_USER" ]] && files+=("$CRON_SPOOL/$TARGET_USER")
else
for f in "$CRON_SPOOL"/*; do
[[ -f "$f" ]] && files+=("$f")
done
fi
fi
printf '%s\n' "${files[@]}" 2>/dev/null || true
}
# ── Parse cron entries from a file ────────────────────────────────────
# Outputs: schedule|command (skips comments, blanks, variables)
parse_cron_entries() {
local file="$1" has_user_field="${2:-false}"
while IFS= read -r line; do
# skip comments and blank lines
[[ "$line" =~ ^[[:space:]]*# ]] && continue
[[ "$line" =~ ^[[:space:]]*$ ]] && continue
# skip variable assignments (MAILTO=, PATH=, SHELL=, etc.)
[[ "$line" =~ ^[[:space:]]*[A-Za-z_]+= ]] && continue
if [[ "$has_user_field" == "true" ]]; then
# system crontab: min hour dom mon dow user command
echo "$line" | awk '{
if ($1 ~ /^@/) { sched=$1; user=$2; cmd=""; for(i=3;i<=NF;i++) cmd=cmd" "$i }
else { sched=$1" "$2" "$3" "$4" "$5; user=$6; cmd=""; for(i=7;i<=NF;i++) cmd=cmd" "$i }
gsub(/^[[:space:]]+/, "", cmd)
print sched"|"cmd
}'
else
# user crontab: min hour dom mon dow command
echo "$line" | awk '{
if ($1 ~ /^@/) { sched=$1; cmd=""; for(i=2;i<=NF;i++) cmd=cmd" "$i }
else { sched=$1" "$2" "$3" "$4" "$5; cmd=""; for(i=6;i<=NF;i++) cmd=cmd" "$i }
gsub(/^[[:space:]]+/, "", cmd)
print sched"|"cmd
}'
fi
done < "$file"
}
# ── Check: crontab environment (PATH) ─────────────────────────────────
check_cron_environment() {
local file="$1" label="$2"
local has_path=false has_mailto=false
while IFS= read -r line; do
[[ "$line" =~ ^[[:space:]]*PATH= ]] && has_path=true
[[ "$line" =~ ^[[:space:]]*MAILTO= ]] && has_mailto=true
done < "$file"
if [[ "$has_path" == "false" ]]; then
warn "${label}: no PATH set — cron uses /usr/bin:/bin only"
suggest "Add to top of crontab: PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
else
verbose "${label}: PATH is set"
fi
if [[ "$has_mailto" == "false" ]]; then
local has_unredirected=false
while IFS='|' read -r _sched cmd; do
[[ -z "$cmd" ]] && continue
if ! echo "$cmd" | grep -qE '>\s*/|>\s*&|2>&1|>/dev/null'; then
has_unredirected=true
break
fi
done < <(parse_cron_entries "$file" false)
if [[ "$has_unredirected" == "true" ]]; then
warn "${label}: no MAILTO and some jobs lack output redirection — output may be lost"
suggest "Add MAILTO=admin@example.com or redirect: command >> /var/log/job.log 2>&1"
fi
fi
}
# ── Check: missing binaries ───────────────────────────────────────────
check_missing_binaries() {
local file="$1" label="$2" has_user="${3:-false}"
while IFS='|' read -r _sched cmd; do
[[ -z "$cmd" ]] && continue
# extract the first word (binary) — handle cd/env/sudo/flock prefixes
local binary
binary=$(echo "$cmd" | sed -E '
s#^(cd [^ ;]+[; ]+(&&[[:space:]]*)?)##
s#^(sudo (-u [^ ]+ )?)##
s#^(env (-i )?([A-Za-z_]+=[^ ]+ )*)##
s#^(/usr/bin/flock [^ ]+ )##
s#^(/bin/sh -c |/bin/bash -c )##
' | awk '{print $1}')
# strip trailing shell metacharacters (;, &&, ||, |)
binary="${binary%%[;&|]*}"
[[ -z "$binary" ]] && continue
# skip shell builtins
[[ "$binary" =~ ^(test|true|false|echo|cd|source|\[|\[\[)$ ]] && continue
# if it's an absolute path, check directly
if [[ "$binary" == /* ]]; then
if [[ ! -f "$binary" ]]; then
fail "${label}: binary not found: ${binary}"
suggest "Check path: which $(basename "$binary")"
elif [[ ! -x "$binary" ]]; then
fail "${label}: not executable: ${binary}"
suggest "chmod +x ${binary}"
fi
else
# relative binary — check if it exists in cron's default PATH
if ! command -v "$binary" &>/dev/null; then
verbose "${label}: can't verify relative command: ${binary}"
fi
fi
done < <(parse_cron_entries "$file" "$has_user")
}
# ── Check: unescaped percent signs ────────────────────────────────────
check_percent_signs() {
local file="$1" label="$2"
local lineno=0
while IFS= read -r line; do
(( lineno++ )) || true
[[ "$line" =~ ^[[:space:]]*# ]] && continue
[[ "$line" =~ ^[[:space:]]*$ ]] && continue
[[ "$line" =~ ^[[:space:]]*[A-Za-z_]+= ]] && continue
# check for % not preceded by \ (unescaped)
if echo "$line" | grep -qP '(?<!\\)%'; then
fail "${label} line ${lineno}: unescaped % sign — cron converts this to newline"
suggest "Escape percent signs: date +\\%Y-\\%m-\\%d"
fi
done < "$file"
}
# ── Check: crontab file permissions ───────────────────────────────────
check_crontab_permissions() {
local file="$1" label="$2"
if [[ ! -r "$file" ]]; then
verbose "${label}: can't read file (permission denied)"
return
fi
local perms owner
perms=$(stat -c '%a' "$file" 2>/dev/null) || return
owner=$(stat -c '%U' "$file" 2>/dev/null) || return
# system files (/etc/crontab, /etc/cron.d/*) are expected to be 644 root-owned
# user crontabs are expected to be 600 owned by the user
if [[ "$file" == /etc/* ]]; then
if [[ "$owner" != "root" ]]; then
warn "${label}: owned by ${owner}, expected root"
fi
else
local expected_user
expected_user=$(basename "$file")
if [[ "$perms" != "600" ]]; then
warn "${label}: permissions are ${perms}, expected 600"
suggest "chmod 600 ${file}"
fi
if [[ "$owner" != "$expected_user" && "$owner" != "root" ]]; then
fail "${label}: owned by ${owner}, expected ${expected_user} or root"
fi
fi
}
# ── Check: missing trailing newline ───────────────────────────────────
check_trailing_newline() {
local file="$1" label="$2"
if [[ ! -r "$file" ]] || [[ ! -s "$file" ]]; then
return
fi
# check if file ends with newline
if [[ "$(tail -c 1 "$file" | xxd -p)" != "0a" ]]; then
fail "${label}: no trailing newline — last cron entry will not run"
suggest "echo '' >> ${file}"
fi
}
# ── Check: overlap risk ──────────────────────────────────────────────
check_overlap_risk() {
local file="$1" label="$2" has_user="${3:-false}"
while IFS='|' read -r sched cmd; do
[[ -z "$cmd" ]] && continue
# check for frequent schedules (every minute or every 5 min)
local is_frequent=false
if echo "$sched" | grep -qE '^\*[[:space:]]|^\*/[1-5][[:space:]]'; then
is_frequent=true
fi
if [[ "$is_frequent" == "true" ]]; then
# check if command uses flock or lockfile
if ! echo "$cmd" | grep -qiE 'flock|lockfile|lock'; then
warn "${label}: frequent job (${sched%% *}) without locking: $(echo "$cmd" | cut -c1-60)"
suggest "Wrap with flock: /usr/bin/flock -n /var/lock/myjob.lock $cmd"
fi
fi
done < <(parse_cron_entries "$file" "$has_user")
}
# ── Check: cron.allow / cron.deny ─────────────────────────────────────
check_cron_access() {
section "Cron Access Control"
if [[ -f /etc/cron.allow ]]; then
info "/etc/cron.allow exists — only listed users can use cron"
if [[ -n "$TARGET_USER" ]]; then
if grep -qxF "$TARGET_USER" /etc/cron.allow 2>/dev/null; then
log "${TARGET_USER} is in cron.allow"
else
fail "${TARGET_USER} is NOT in cron.allow — cron jobs will not run"
suggest "echo '${TARGET_USER}' >> /etc/cron.allow"
fi
fi
elif [[ -f /etc/cron.deny ]]; then
info "/etc/cron.deny exists — listed users are blocked"
if [[ -n "$TARGET_USER" ]]; then
if grep -qxF "$TARGET_USER" /etc/cron.deny 2>/dev/null; then
fail "${TARGET_USER} is in cron.deny — cron jobs will not run"
suggest "Remove ${TARGET_USER} from /etc/cron.deny"
else
log "${TARGET_USER} is not in cron.deny"
fi
fi
else
verbose "No cron.allow or cron.deny found"
fi
}
# ── Check: systemd timers ─────────────────────────────────────────────
check_systemd_timers() {
section "Systemd Timers"
if ! command -v systemctl &>/dev/null; then
info "systemctl not found — skipping timer checks"
return
fi
# failed timer-triggered services
local failed
failed=$(systemctl list-units --type=service --state=failed --no-pager --plain 2>/dev/null | \
awk '{print $1}' | grep -v '^$' | grep -v '^UNIT' || true)
if [[ -n "$failed" ]]; then
while IFS= read -r svc; do
# check if this service has a matching timer
local timer="${svc%.service}.timer"
if systemctl list-unit-files "$timer" &>/dev/null 2>&1; then
fail "Timer-triggered service failed: ${svc}"
suggest "journalctl -u ${svc} -b --no-pager | tail -20"
fi
done <<< "$failed"
else
log "No failed timer-triggered services"
fi
# timers enabled but not active
while IFS= read -r line; do
local timer_name state
timer_name=$(echo "$line" | awk '{print $1}')
state=$(echo "$line" | awk '{print $3}')
[[ -z "$timer_name" ]] && continue
[[ "$timer_name" != *.timer ]] && continue
if [[ "$state" != "active" ]]; then
warn "Timer ${timer_name} is loaded but not active (state: ${state})"
suggest "systemctl start ${timer_name}"
fi
done < <(systemctl list-units --type=timer --all --no-pager --plain 2>/dev/null || true)
# timers without Persistent=true
while IFS= read -r timer_name; do
[[ -z "$timer_name" ]] && continue
[[ "$timer_name" != *.timer ]] && continue
local persistent
persistent=$(systemctl show "$timer_name" -p Persistent 2>/dev/null | cut -d= -f2)
if [[ "$persistent" == "no" ]]; then
local has_calendar
has_calendar=$(systemctl show "$timer_name" -p TimersCalendar 2>/dev/null)
if [[ -n "$has_calendar" && "$has_calendar" != "TimersCalendar=" ]]; then
warn "${timer_name}: Persistent=false — missed runs during downtime won't catch up"
suggest "Add Persistent=true to [Timer] section: systemctl edit ${timer_name}"
fi
fi
done < <(systemctl list-units --type=timer --state=active --no-pager --plain 2>/dev/null | awk '{print $1}')
}
# ── Run cron checks on a single file ─────────────────────────────────
check_crontab_file() {
local file="$1" label="$2" has_user="${3:-false}"
verbose "Checking: ${file}"
check_crontab_permissions "$file" "$label"
check_trailing_newline "$file" "$label"
check_cron_environment "$file" "$label"
check_percent_signs "$file" "$label"
check_missing_binaries "$file" "$label" "$has_user"
check_overlap_risk "$file" "$label" "$has_user"
}
# ══════════════════════════════════════════════════════════════════════
# Main
# ══════════════════════════════════════════════════════════════════════
echo ""
echo -e " ${BOLD}Cron Doctor${RESET} — diagnosing scheduled task issues"
echo -e " ${DIM}$(date '+%Y-%m-%d %H:%M:%S')${RESET}"
# ── Cron checks ───────────────────────────────────────────────────────
if [[ "$TIMERS_ONLY" == "false" ]]; then
check_cron_access
# User crontabs
section "User Crontabs"
crontab_files=$(get_crontab_files)
if [[ -z "$crontab_files" ]]; then
if [[ -n "$TARGET_USER" ]]; then
info "No crontab found for user: ${TARGET_USER}"
else
info "No user crontabs found in ${CRON_SPOOL:-/var/spool/cron}"
fi
else
while IFS= read -r file; do
[[ -z "$file" ]] && continue
user=$(basename "$file")
check_crontab_file "$file" "crontab(${user})" false
done <<< "$crontab_files"
fi
# System crontab
if [[ -f /etc/crontab ]]; then
section "System Crontab (/etc/crontab)"
check_crontab_file "/etc/crontab" "/etc/crontab" true
fi
# /etc/cron.d drop-ins
if [[ -d /etc/cron.d ]]; then
section "Drop-ins (/etc/cron.d)"
found_drop_ins=false
for f in /etc/cron.d/*; do
[[ ! -f "$f" ]] && continue
# skip dpkg/ucf leftovers
[[ "$f" =~ \.(dpkg-|ucf-) ]] && continue
found_drop_ins=true
check_crontab_file "$f" "cron.d/$(basename "$f")" true
done
if [[ "$found_drop_ins" == "false" ]]; then
info "No drop-in files in /etc/cron.d"
fi
fi
fi
# ── Systemd timer checks ─────────────────────────────────────────────
if [[ "$CRON_ONLY" == "false" ]]; then
check_systemd_timers
fi
# ── Summary ───────────────────────────────────────────────────────────
echo ""
echo -e " ${BOLD}── Summary ──${RESET}"
echo ""
TOTAL=$(( FAIL_COUNT + WARN_COUNT ))
if [[ $TOTAL -eq 0 ]]; then
echo -e " ${GREEN}✓ No issues found${RESET}"
else
if [[ $FAIL_COUNT -gt 0 ]]; then
echo -e " ${RED}${FAIL_COUNT} failure(s):${RESET}"
for msg in "${FAIL_MESSAGES[@]}"; do
echo -e " ${RED}${RESET} ${msg}"
done
fi
if [[ $WARN_COUNT -gt 0 ]]; then
echo -e " ${YELLOW}${WARN_COUNT} warning(s)${RESET}"
fi
fi
echo ""
if [[ $FAIL_COUNT -gt 0 ]]; then
exit 2
elif [[ $WARN_COUNT -gt 0 ]]; then
exit 1
else
exit 0
fi