Finish the pass: log_inspect, service_manager, rsync_magic, disk_cleanup

log_inspect.sh discarded grep's stderr and ignored its exit status, so
an unprivileged search over root-owned logs was indistinguishable from a
search that genuinely found nothing. grep's three outcomes now mean
three different things: matched, matched nothing, or could not read
everything -- the last of which says so and exits non-zero. Confirmed
grep returns 2 rather than 1 in that case, which is why the naive
"status -eq 1" check would never have fired.

service_manager.sh validates the action before dispatch and requires
root for the five that change system state, leaving status and list open
to anyone. $action is quoted at both call sites.

rsync_magic.sh had --inplace on unconditionally. It writes straight into
destination files instead of to a temporary and renaming, so an
interrupted run leaves them partially overwritten -- the opposite of
what a backup tool should guarantee. Now opt-in, with a warning when
used. Its log lives under /var/log and every line pipes through tee, so
under pipefail an unprivileged run died on the first line with a bare
permission error; it now falls back to stdout rather than failing the
sync over its own logging. --delete also confirms before running, since
reversing the two arguments erases the backup.

disk_cleanup.sh moves from `set -o pipefail` to full strict mode, with
the two pipelines that legitimately return non-zero handled at their
call sites rather than by leaving the script lax. Its "largest files"
walk also gained -xdev, which it was missing while security_audit.sh
next door already had it -- without it the walk descends /proc, /sys and
every network mount.

All fifteen scripts now run under set -euo pipefail.
This commit is contained in:
2026-08-22 22:24:50 -07:00
parent f8a86736f9
commit 96774aba35
4 changed files with 124 additions and 29 deletions
+14 -4
View File
@@ -32,7 +32,11 @@
# disk_cleanup.sh --clean --age 14 --dry-run # Preview cleanup with 14-day threshold # disk_cleanup.sh --clean --age 14 --dry-run # Preview cleanup with 14-day threshold
# #
set -o pipefail # Full strict mode. The two pipelines below that legitimately return
# non-zero -- find hitting unreadable directories, and head closing a
# pipe early -- are handled at their call sites rather than by leaving
# the whole script lax.
set -euo pipefail
# ===== CONFIGURATION ===== # ===== CONFIGURATION =====
AGE_THRESHOLD=7 AGE_THRESHOLD=7
@@ -158,8 +162,12 @@ show_disk_usage() {
df -h -x tmpfs -x devtmpfs || error_exit "Failed to get disk usage information" df -h -x tmpfs -x devtmpfs || error_exit "Failed to get disk usage information"
echo -e "\n==== Top 10 Largest Files ====" echo -e "\n==== Top 10 Largest Files ===="
find / -type f -printf '%s %p\n' 2>/dev/null | sort -nr | head -n 10 | \ # -xdev: without it this walks /proc, /sys and every network mount,
awk '{size=$1/1024/1024; printf("%.1f MB - ", size); $1=""; print $0}' || \ # which is slow and reports files that are not really taking up disk.
# The `|| true` absorbs both find's non-zero on unreadable directories
# and the SIGPIPE head causes once it has its ten lines.
{ find / -xdev -type f -printf '%s %p\n' 2>/dev/null | sort -nr | head -n 10 | \
awk '{size=$1/1024/1024; printf("%.1f MB - ", size); $1=""; print $0}'; } || \
echo "Warning: Could not retrieve largest files" echo "Warning: Could not retrieve largest files"
} }
@@ -218,7 +226,9 @@ clean_temporary_files() {
# Count files that would be deleted # Count files that would be deleted
local file_count local file_count
file_count=$(find "$dir" -type f -mtime +"$AGE_THRESHOLD" 2>/dev/null | wc -l) # `|| true` on the pipeline: find exits non-zero for unreadable
# subdirectories, which under pipefail would abort the whole cleanup.
file_count=$(find "$dir" -type f -mtime +"$AGE_THRESHOLD" 2>/dev/null | wc -l || true)
if [ "$file_count" -gt 0 ]; then if [ "$file_count" -gt 0 ]; then
if [ "$DRY_RUN" = true ]; then if [ "$DRY_RUN" = true ]; then
+27 -5
View File
@@ -17,17 +17,39 @@
# #
# Usage: log_inspect.sh [search <pattern> | tail <logfile>] # Usage: log_inspect.sh [search <pattern> | tail <logfile>]
# Description: Searches across /var/log for a pattern, or tails a specific log file. # Description: Searches across /var/log for a pattern, or tails a specific log file.
#
# Most of /var/log is root-only. Run this with sudo, or results will be
# quietly partial -- see the note on the search branch.
if [ "$1" = "search" ]; then set -euo pipefail
pattern="$2"
if [ "${1:-}" = "search" ]; then
pattern="${2:-}"
if [ -z "$pattern" ]; then if [ -z "$pattern" ]; then
echo "Usage: $0 search <pattern>"; exit 1 echo "Usage: $0 search <pattern>"; exit 1
fi fi
echo "Searching for '$pattern' in /var/log..." echo "Searching for '$pattern' in /var/log..."
grep -R -i --color=auto "$pattern" /var/log 2>/dev/null # 2>/dev/null hid permission errors, so an unprivileged run looked like
# "no matches" rather than "could not read most of /var/log". Say so
# explicitly instead. grep exits 1 on no match, which is not an error.
# grep distinguishes three outcomes and they mean different things
# here: 0 matched, 1 matched nothing, 2 could not read everything. The
# original discarded stderr and ignored the status, so an unprivileged
# run over root-owned logs was indistinguishable from a clean search.
status=0
grep -R -i --color=auto -- "$pattern" /var/log 2>/dev/null || status=$?
case "$status" in
0) ;;
1) echo "No matches found." ;;
*)
echo "Search was incomplete -- some files under /var/log could not be read." >&2
[ "$EUID" -ne 0 ] && echo "Re-run with sudo for a complete search." >&2
exit "$status"
;;
esac
exit 0 exit 0
elif [ "$1" = "tail" ]; then elif [ "${1:-}" = "tail" ]; then
logfile="$2" logfile="${2:-}"
if [ -z "$logfile" ]; then if [ -z "$logfile" ]; then
echo "Usage: $0 tail <log_file_path>"; exit 1 echo "Usage: $0 tail <log_file_path>"; exit 1
fi fi
+52 -6
View File
@@ -25,18 +25,28 @@ TIMESTAMP=$(date +"%Y-%m-%d_%H-%M-%S")
# ======= Help Function ======= # ======= Help Function =======
usage() { usage() {
echo "Usage: $0 [--dry-run] <source> <destination>" echo "Usage: $0 [--dry-run] [--inplace] [--yes] <source> <destination>"
echo "Optional: --dry-run to simulate the sync" echo " --dry-run simulate the sync, change nothing"
echo " --inplace write into destination files directly (see note below)"
echo " --yes skip the confirmation prompt for --delete"
exit 1 exit 1
} }
# ======= Argument Parsing ======= # ======= Argument Parsing =======
DRY_RUN=0 DRY_RUN=0
INPLACE=0
ASSUME_YES=0
if [[ "${1:-}" == "--dry-run" ]]; then while [[ "${1:-}" == --* ]]; do
DRY_RUN=1 case "$1" in
--dry-run) DRY_RUN=1 ;;
--inplace) INPLACE=1 ;;
--yes) ASSUME_YES=1 ;;
--help|-h) usage ;;
*) echo "Unknown option: $1" >&2; usage ;;
esac
shift shift
fi done
SOURCE="${1:-}" SOURCE="${1:-}"
DEST="${2:-}" DEST="${2:-}"
@@ -66,11 +76,22 @@ RSYNC_OPTS=(
-X # preserve extended attributes -X # preserve extended attributes
--delete # delete extraneous files from destination --delete # delete extraneous files from destination
--numeric-ids # don't map uid/gid numbers to usernames --numeric-ids # don't map uid/gid numbers to usernames
--inplace # update destination files in place
--backup # backup overwritten files --backup # backup overwritten files
--backup-dir="${DEST}/.backup-${TIMESTAMP}" # backup location --backup-dir="${DEST}/.backup-${TIMESTAMP}" # backup location
) )
# --inplace was previously always on. It writes directly into the
# destination file rather than to a temporary and renaming, so an
# interrupted transfer leaves the destination partially overwritten and
# corrupt -- the opposite of what a backup should guarantee. rsync's
# default (temp file, atomic rename) costs extra space on the target and
# is worth it here, so --inplace is now opt-in for the cases that need
# it, such as very large files on space-constrained targets.
if [[ "$INPLACE" -eq 1 ]]; then
RSYNC_OPTS+=(--inplace)
echo "WARNING: --inplace means an interrupted run can leave corrupt files at the destination."
fi
# Add dry-run flag if needed # Add dry-run flag if needed
if [[ "$DRY_RUN" -eq 1 ]]; then if [[ "$DRY_RUN" -eq 1 ]]; then
RSYNC_OPTS+=(--dry-run) RSYNC_OPTS+=(--dry-run)
@@ -82,6 +103,31 @@ if [[ -f "$EXCLUDES" ]]; then
RSYNC_OPTS+=(--exclude-from="$EXCLUDES") RSYNC_OPTS+=(--exclude-from="$EXCLUDES")
fi fi
# The log lives under /var/log, which needs root. Every echo below pipes
# through `tee -a`, so with set -o pipefail an unprivileged run died on
# the first line with a bare "Permission denied" and no explanation.
# Fall back to stdout instead of failing the sync over its logging.
if ! { [ -w "$LOG_FILE" ] || { [ ! -e "$LOG_FILE" ] && [ -w "$(dirname "$LOG_FILE")" ]; }; }; then
echo "NOTE: cannot write $LOG_FILE (need root); logging to stdout only." >&2
LOG_FILE=/dev/null
fi
# --delete removes anything at the destination that is not in the source.
# Reversing the two arguments therefore erases the backup. --backup-dir
# above catches the deleted files, but confirm anyway -- the prompt is
# cheaper than discovering the mistake later.
if [[ "$DRY_RUN" -ne 1 && "$ASSUME_YES" -ne 1 ]]; then
echo "About to sync with --delete:"
echo " FROM: $SOURCE/"
echo " TO: $DEST/ (extraneous files here will be removed)"
if [ ! -t 0 ]; then
echo "Refusing to run unattended without --yes." >&2
exit 1
fi
read -r -p "Proceed? (yes/NO): " reply
[ "$reply" = "yes" ] || { echo "Cancelled."; exit 0; }
fi
# ======= Run Rsync ======= # ======= Run Rsync =======
echo "Starting rsync at $TIMESTAMP" | tee -a "$LOG_FILE" echo "Starting rsync at $TIMESTAMP" | tee -a "$LOG_FILE"
echo "Source: $SOURCE" | tee -a "$LOG_FILE" echo "Source: $SOURCE" | tee -a "$LOG_FILE"
+29 -12
View File
@@ -18,9 +18,31 @@
# Usage: service_manager.sh <action> <service_name> # Usage: service_manager.sh <action> <service_name>
# Actions: start, stop, restart, status, enable, disable, list # Actions: start, stop, restart, status, enable, disable, list
# Description: Uses systemctl or service to control services. # Description: Uses systemctl or service to control services.
#
# status and list are read-only; everything else changes system state
# and needs root.
action="$1" set -euo pipefail
service="$2"
action="${1:-}"
service="${2:-}"
# Validate up front so an unknown action cannot reach systemctl as a
# bare word, and so the privilege check below has something to gate on.
case "$action" in
start|stop|restart|status|enable|disable|list) ;;
"") echo "Usage: $0 {start|stop|restart|status|enable|disable|list} <service_name>" >&2; exit 1 ;;
*) echo "Invalid action '$action'. Use start, stop, restart, status, enable, disable, or list." >&2; exit 1 ;;
esac
case "$action" in
start|stop|restart|enable|disable)
if [ "$EUID" -ne 0 ]; then
echo "'$action' changes system state and requires root. Re-run with sudo." >&2
exit 1
fi
;;
esac
if [ "$action" = "list" ]; then if [ "$action" = "list" ]; then
# List running services # List running services
@@ -41,21 +63,16 @@ fi
if command -v systemctl &> /dev/null; then if command -v systemctl &> /dev/null; then
case "$action" in case "$action" in
start|stop|restart|status) start|stop|restart|status|enable|disable)
systemctl $action "$service" # Quoted: $action is validated above, but leaving it bare invites
;; # word-splitting the moment anyone passes it through a variable.
enable|disable) systemctl "$action" "$service"
systemctl $action "$service"
;;
*)
echo "Invalid action. Use start, stop, restart, status, enable, disable, or list."
exit 1
;; ;;
esac esac
elif command -v service &> /dev/null; then elif command -v service &> /dev/null; then
case "$action" in case "$action" in
start|stop|restart) start|stop|restart)
service "$service" $action service "$service" "$action"
;; ;;
status) status)
service "$service" status service "$service" status