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:
+14
-4
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user