Files
Varaverk/Tools/bulk_permissions_repair.sh
Gmer4Lfe e8b114094a Bring script headers onto the template and close safeguard gaps
Headers claimed protections the code never had, and several destructive paths had no
guard against a collapsed config value.
2026-08-01 20:37:59 -04:00

268 lines
12 KiB
Bash
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/bin/bash
# ==============================================================================================
# ============================= Bulk Permissions Repair ========================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Applies correct ownership and permissions to one or more specific paths.
# Targeted repair — faster than media_shares_permissions.sh, which processes
# every configured share. Use after failed transfers that left root:root ownership,
# containers writing as root before PUID/PGID was fixed, manual file copies, or
# new shares that need permissions applied before the next nightly run.
#
# Counts files with wrong ownership before fixing. A high count on a recently
# written share means a container has wrong PUID/PGID — add PUID=99 PGID=100
# to its Docker template. Common culprits: SABnzbd, qBittorrent, slskd.
#
# Side effect worth knowing: this applies owner/mode unconditionally, and chown/chmod
# restamp an inode's ctime even when the value doesn't change. sonarr/radarr/lidarr_cleanup.sh
# gate orphan deletion on ctime, so running this over a whole media root resets that clock
# and pauses orphan collection there for *_ORPHAN_AGE days. That is why the nightly
# media_shares_permissions.sh applies its passes conditionally. Harmless for the targeted
# repairs this tool is meant for — worth remembering before pointing it at an entire share.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# For each path given on the command line:
#
# 1. Path guard — refuse anything shallower than two components
# 2. Existence check — a missing path is a failure for that entry, not the run
# 3. Report scale — file count, dir count, total size, so the operator sees the job size
# 4. Count entries with wrong ownership (the diagnostic number in the summary)
# 5. chown -R PERMISSIONS_OWNER across the path
# 6. find -type d → chmod PERMISSIONS_DIR_MODE
# 7. find -type f → chmod PERMISSIONS_FILE_MODE
#
# Unlike the nightly media_shares_permissions.sh, steps 57 are unconditional — see the
# ctime note in PURPOSE above for why that distinction matters.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Unconditional by Design, Unlike the Nightly Job
# media_shares_permissions.sh applies its passes conditionally to protect ctime as an
# age signal for arr orphan collection. This tool deliberately does not: it exists to
# repair paths that are known-wrong, where correctness matters more than preserving a
# clock. That is exactly why it is a targeted manual tool and not a scheduled one.
#
# Permissions Model
# Directories (PERMISSIONS_DIR_MODE, default 755):
# Owner (nobody) — rwx enter, list, create files
# Group (users) — r-x enter and list
# Others — r-x Samba guests can browse
# Files (PERMISSIONS_FILE_MODE, default 664):
# Owner (nobody) — rw read + write
# Group (users) — rw arrs can import and rename
# Others — r Samba guests can read
# No execute bit — media files are never executable
#
# Separate Passes
# Directories and files are chmod'd in separate find passes. A combined pass
# with mode 664 would wrongly strip the execute bit from directories, making
# them untraversable.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Root Required
# chown requires root — exits immediately if not running as root.
#
# Path Existence Check
# Each path is verified before processing — missing paths log an error and
# are skipped rather than silently passing.
#
# Silent on Success
# Only failures and the wrong-owner diagnostic produce visible output.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# master.conf
#
# PERMISSIONS_OWNER
# Owner applied to all paths. (default: nobody:users)
#
# PERMISSIONS_DIR_MODE
# chmod mode for directories. (default: 755)
#
# PERMISSIONS_FILE_MODE
# chmod mode for files. (default: 664)
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# bulk_permissions_repair.sh /path/to/share [/another/path ...]
# Apply ownership and permissions to each specified path.
#
# bulk_permissions_repair.sh /path/to/share --dry-run
# Show wrong-owner count per path. No chown or chmod applied.
#
# bulk_permissions_repair.sh /path/to/share --log
# Verbose output including per-path file counts and modes applied.
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
parse_args "$@"
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root — chown requires root"
exit 1
fi
acquire_lock
# detect_hosts() sets MY_ID — used in summary
detect_hosts
if [[ ${#PARSED_ARGS[@]} -eq 0 ]]; then
error "No paths specified"
error "Usage: bulk_permissions_repair.sh /path/to/share [/another/path] [--dry-run]"
exit 1
fi
log "Dir mode: ${PERMISSIONS_DIR_MODE:-755}"
log "File mode: ${PERMISSIONS_FILE_MODE:-664}"
log "Owner: $PERMISSIONS_OWNER"
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no permissions will be changed"
# ==============================================================================================
# ━━━ Apply Permissions ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_PERMS Permissions Repair — $MY_ID ━━━"
START=$(date +%s)
PASS=()
FAIL=()
TOTAL_WRONG_OWNER=0
for share_path in "${PARSED_ARGS[@]}"; do
[[ -z "$share_path" ]] && continue
echo ""
echo "━━━ $ICON_PERMS $(basename "$share_path") ━━━"
# chown -R below. Paths come straight from the command line, so a spacing typo
# ("/mnt/user /Movies" instead of "/mnt/user/Movies") would hand this a bare top-level
# directory — and chown -R nobody:users on / or /etc breaks the system outright.
# Require at least two path components; that still allows a deliberate whole-share
# repair like /mnt/user while refusing /, /mnt, /etc, /boot and friends.
_bpr_slashes="${share_path//[^\/]/}"
if [[ "$share_path" != /* || "${#_bpr_slashes}" -lt 2 ]]; then
error "$share_path — refusing: expected an absolute path at least 2 levels deep"
FAIL+=("$share_path")
continue
fi
if [[ ! -d "$share_path" ]]; then
error "$share_path — not found"
FAIL+=("$share_path")
continue
fi
# Count files for context — warn level so user knows what they're in for on large shares
FILE_COUNT=$(find "$share_path" -type f 2>/dev/null | wc -l)
DIR_COUNT=$(find "$share_path" -type d 2>/dev/null | wc -l)
SIZE=$(du -sh "$share_path" 2>/dev/null | cut -f1)
warn "$share_path$FILE_COUNT files, $DIR_COUNT dirs ($SIZE)"
# Count files with wrong ownership before fixing — diagnostic
WRONG_OWNER=$(find "$share_path" \( ! -user nobody -o ! -group users \) \
2>/dev/null | wc -l)
if [[ "$WRONG_OWNER" -gt 0 ]]; then
warn "$WRONG_OWNER file(s) with wrong ownership — fixing..."
if [[ "$WRONG_OWNER" -gt 500 ]]; then
warn "High wrong-owner count — check container PUID/PGID settings (should be PUID=99 PGID=100)"
warn "Common culprits: SABnzbd, qBittorrent, slskd"
fi
TOTAL_WRONG_OWNER=$(( TOTAL_WRONG_OWNER + WRONG_OWNER ))
else
log "Ownership already correct — applying mode only"
fi
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would apply:"
warn " chown -R $PERMISSIONS_OWNER $share_path"
warn " find -type d → chmod ${PERMISSIONS_DIR_MODE:-755}"
warn " find -type f → chmod ${PERMISSIONS_FILE_MODE:-664}"
PASS+=("$(basename "$share_path")")
continue
fi
CHOWN_OK=true
CHMOD_DIR_OK=true
CHMOD_FILE_OK=true
# Apply ownership first
log "Applying ownership: $PERMISSIONS_OWNER..."
chown -R "$PERMISSIONS_OWNER" "$share_path" 2>/dev/null || CHOWN_OK=false
# Apply directory permissions — separate pass (dirs need execute bit)
log "Applying directory permissions: ${PERMISSIONS_DIR_MODE:-755}..."
find "$share_path" -type d \
-exec chmod "${PERMISSIONS_DIR_MODE:-755}" {} + 2>/dev/null || CHMOD_DIR_OK=false
# Apply file permissions — no execute bit on media files
log "Applying file permissions: ${PERMISSIONS_FILE_MODE:-664}..."
find "$share_path" -type f \
-exec chmod "${PERMISSIONS_FILE_MODE:-664}" {} + 2>/dev/null || CHMOD_FILE_OK=false
if [[ "$CHOWN_OK" == true && "$CHMOD_DIR_OK" == true && "$CHMOD_FILE_OK" == true ]]; then
echo "$ICON_UNLOCKED $(basename "$share_path") — permissions applied ✅"
PASS+=("$(basename "$share_path")")
else
error "$(basename "$share_path") — repair failed"
error " chown: $CHOWN_OK chmod dirs: $CHMOD_DIR_OK chmod files: $CHMOD_FILE_OK"
FAIL+=("$(basename "$share_path")")
fi
done
END=$(date +%s)
# ==============================================================================================
# ━━━ Summary ━━━
# ==============================================================================================
echo ""
echo "━━━━━ $ICON_SUMMARY PERMISSIONS REPAIR SUMMARY ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_PERMS Dir mode: ${PERMISSIONS_DIR_MODE:-755}"
echo "$ICON_PERMS File mode: ${PERMISSIONS_FILE_MODE:-664}"
echo "$ICON_PERMS Owner: $PERMISSIONS_OWNER"
echo "$ICON_TIME Duration: $(format_duration $(( END - START )))"
echo ""
[[ ${#PASS[@]} -gt 0 ]] && log "Pass: ${PASS[*]}"
[[ ${#FAIL[@]} -gt 0 ]] && echo "$ICON_ERROR Fail: ${FAIL[*]}"
if [[ "$TOTAL_WRONG_OWNER" -gt 0 ]]; then
warn "Total wrong-owner files fixed: $TOTAL_WRONG_OWNER"
fi
echo ""
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — no changes made"
elif [[ ${#FAIL[@]} -gt 0 ]]; then
echo "$ICON_ERROR Status: SOME REPAIRS FAILED"
notify "Permissions repair failed on $(hostname)${FAIL[*]}" \
"Permissions Repair" "warning"
else
echo "$ICON_DONE Status: done — ${#PASS[@]} path(s) repaired"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
[[ ${#FAIL[@]} -gt 0 ]] && exit 1
exit 0