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.
523 lines
21 KiB
Bash
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
|