Headers claimed protections the code never had, and several destructive paths had no guard against a collapsed config value.
339 lines
15 KiB
Bash
Executable File
339 lines
15 KiB
Bash
Executable File
#!/bin/bash
|
|
# ==============================================================================================
|
|
# ============================= Media Shares Permissions =======================================
|
|
# ==============================================================================================
|
|
#
|
|
# PURPOSE
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# Apply nobody:users ownership and correct permissions to all media shares.
|
|
# Runs daily as the first job in the maintenance window — arr cleanup depends
|
|
# on correct ownership to rename and delete files.
|
|
#
|
|
# Now a proper daily failsafe: files arrive with wrong ownership from rsync
|
|
# without --chown, manual admin copies, containers with unconfigured PUID/PGID,
|
|
# or unRAID environment resets after updates.
|
|
#
|
|
# ==============================================================================================
|
|
# OPERATIONAL MODEL
|
|
# ==============================================================================================
|
|
#
|
|
# For each share in MEDIA_PERMISSION_SHARES:
|
|
#
|
|
# 1. Path safety and existence
|
|
# → unsafe or missing paths are refused or skipped, never scanned
|
|
#
|
|
# 2. Count wrong ownership (diagnostic)
|
|
# → find ! -user / ! -group — the number reported as "corrected"
|
|
#
|
|
# 3. Ownership pass — only if the count is non-zero
|
|
# → chown PERMISSIONS_OWNER on non-matching entries only
|
|
#
|
|
# 4. Directory mode pass
|
|
# → chmod PERMISSIONS_DIR_MODE on directories not already at that mode
|
|
#
|
|
# 5. File mode pass
|
|
# → chmod PERMISSIONS_FILE_MODE on files not already at that mode
|
|
# → "No such file" errors ignored: volatile dirs (Emby transcodes) race
|
|
#
|
|
# Every pass is conditional by design — see Conditional Passes below.
|
|
#
|
|
# ==============================================================================================
|
|
# DESIGN PRINCIPLES
|
|
# ==============================================================================================
|
|
#
|
|
# Daily Failsafe, Not Enforcer
|
|
# Wrong ownership is a symptom of something else — a misconfigured container,
|
|
# a manual copy, an rsync without --chown. This script corrects the symptom
|
|
# daily rather than hunting the root cause. A persistent high correction count
|
|
# is the signal to investigate the source.
|
|
#
|
|
# Runs First in the Window
|
|
# Arr cleanup scripts depend on correct ownership to rename and delete files.
|
|
# Permissions must be correct before cleanup runs — ordering is not optional.
|
|
#
|
|
# Separate Passes for Directories and Files
|
|
# Directories need execute permission for traversal; files do not. Applying
|
|
# the same mode to both is a common mistake this script avoids by design.
|
|
#
|
|
# ==============================================================================================
|
|
# OPERATIONAL SAFEGUARDS
|
|
# ==============================================================================================
|
|
#
|
|
# Root Enforcement
|
|
# chown to an arbitrary owner requires root.
|
|
#
|
|
# Lock Acquisition
|
|
# acquire_lock "wait" — waits rather than skipping. Share scans are long, and
|
|
# this runs first in the daily window; skipping it would let arr cleanup run
|
|
# against uncorrected ownership.
|
|
#
|
|
# Host Detection
|
|
# detect_hosts() aliases HOST*_MEDIA_PERMISSION_SHARES to this host's shares.
|
|
#
|
|
# Empty Array Guard
|
|
# Exits cleanly if no shares are configured for this host.
|
|
#
|
|
# Share Path Depth Guard
|
|
# Every share must be an absolute path at least three levels deep before it is
|
|
# scanned. A truncated entry like /mnt/user passes an existence check and would
|
|
# chown and chmod every share on the array — which, because chown/chmod restamp
|
|
# ctime, would erase the age signal the arr cleanups depend on across the whole
|
|
# library in a single run.
|
|
#
|
|
# Folder Existence
|
|
# Missing shares are skipped with a warning; remaining shares still process.
|
|
#
|
|
# Separate Passes
|
|
# Directories and files are chmod'd in separate passes — directories need the
|
|
# execute bit for traversal, media files must not have it.
|
|
#
|
|
# Conditional Passes
|
|
# Only entries whose owner or mode is actually wrong are touched. This is not
|
|
# an optimisation: chown/chmod rewrite an inode's ctime even when the value is
|
|
# unchanged, so a blanket pass would restamp every file nightly and destroy
|
|
# ctime as an age signal. The arr cleanups gate orphan deletion on ctime, and
|
|
# mtime cannot substitute — imports preserve the release's original timestamp.
|
|
# Making any pass unconditional silently stops orphan collection.
|
|
#
|
|
# Transcode Race Tolerance
|
|
# "No such file or directory" errors from the file pass are ignored. Volatile
|
|
# directories such as Emby transcodes delete files mid-scan; that is expected,
|
|
# not a permissions failure.
|
|
#
|
|
# Dry Run Support
|
|
# --dry-run counts the dirs, files and ownership entries that would change and
|
|
# modifies nothing.
|
|
#
|
|
# Silent by Default
|
|
# Only failures and diagnostics produce output; a clean run is quiet.
|
|
#
|
|
# Diagnostic — high corrected count on every run means a container has wrong PUID/PGID:
|
|
# Correct values on unRAID: PUID=99 (nobody) PGID=100 (users)
|
|
# Common culprits: SABnzbd, qBittorrent, slskd — check these first
|
|
# Once fixed, this script should correct 0 files per run (pure failsafe)
|
|
#
|
|
# ==============================================================================================
|
|
# CONFIGURATION
|
|
# ==============================================================================================
|
|
#
|
|
# host*.conf
|
|
#
|
|
# HOST*_MEDIA_PERMISSION_SHARES — shares this host applies permissions to
|
|
# Aliased by detect_hosts() — script uses MEDIA_PERMISSION_SHARES
|
|
#
|
|
# master.conf
|
|
#
|
|
# PERMISSIONS_DIR_MODE — directory permissions (default 755)
|
|
# PERMISSIONS_FILE_MODE — file permissions (default 664)
|
|
# PERMISSIONS_OWNER — ownership applied to all files (default nobody:users)
|
|
#
|
|
# ==============================================================================================
|
|
# RUNTIME MODES
|
|
# ==============================================================================================
|
|
#
|
|
# media_shares_permissions.sh — normal run
|
|
# media_shares_permissions.sh --dry-run — preview without making changes
|
|
# media_shares_permissions.sh --log — verbose output
|
|
# media_shares_permissions.sh --status — show config and exit
|
|
#
|
|
# ==============================================================================================
|
|
|
|
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"
|
|
exit 1
|
|
fi
|
|
|
|
|
|
acquire_lock "wait"
|
|
|
|
# detect_hosts() sets MY_ID and aliases HOST*_MEDIA_PERMISSION_SHARES
|
|
detect_hosts
|
|
|
|
# Empty array guard
|
|
if [[ ${#MEDIA_PERMISSION_SHARES[@]} -eq 0 ]]; then
|
|
warn "MEDIA_PERMISSION_SHARES is empty for $MY_ID — nothing to do"
|
|
warn "Check HOST*_MEDIA_PERMISSION_SHARES in host*.conf"
|
|
exit 0
|
|
fi
|
|
|
|
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes will be made"
|
|
|
|
# ==============================================================================================
|
|
# ━━━ Status ━━━
|
|
# ==============================================================================================
|
|
if [[ "$SHOW_STATUS" == true ]]; then
|
|
echo ""
|
|
echo "━━━━━ $ICON_SUMMARY STATUS ━━━━━"
|
|
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_PERMS Shares: ${#MEDIA_PERMISSION_SHARES[@]}"
|
|
echo ""
|
|
for share in "${MEDIA_PERMISSION_SHARES[@]}"; do
|
|
local_status="missing"
|
|
[[ -d "$share" ]] && local_status="exists"
|
|
echo " $share — $local_status"
|
|
done
|
|
echo "$ICON_GEAR Dry Run: $DRY_RUN"
|
|
echo "━━━━━━━━━━━━━━━━━━━━━━━"
|
|
exit 0
|
|
fi
|
|
|
|
# ==============================================================================================
|
|
# ━━━ Apply Permissions ━━━
|
|
# ==============================================================================================
|
|
echo ""
|
|
echo "━━━ $ICON_PERMS Media Permissions — $MY_ID ━━━"
|
|
log "Dir mode: ${PERMISSIONS_DIR_MODE:-755}"
|
|
log "File mode: ${PERMISSIONS_FILE_MODE:-664}"
|
|
log "Owner: $PERMISSIONS_OWNER"
|
|
log "Shares: ${#MEDIA_PERMISSION_SHARES[@]}"
|
|
echo ""
|
|
|
|
START=$(date +%s)
|
|
FAILED=()
|
|
UPDATED=()
|
|
SKIPPED=()
|
|
TOTAL_DIRS_FIXED=0
|
|
TOTAL_FILES_FIXED=0
|
|
|
|
# Split for find's -user/-group predicates, which take them separately
|
|
PERMISSIONS_USER="${PERMISSIONS_OWNER%%:*}"
|
|
PERMISSIONS_GROUP="${PERMISSIONS_OWNER##*:}"
|
|
|
|
for SHARE in "${MEDIA_PERMISSION_SHARES[@]}"; do
|
|
SHARE_NAME=$(basename "$SHARE")
|
|
|
|
# A truncated entry such as /mnt/user passes the -d check below and would chown/chmod
|
|
# every share on the array. Because chown/chmod restamp ctime, that would erase the age
|
|
# signal the arr cleanups gate orphan deletion on — across the whole library, in one run.
|
|
_depth="${SHARE//[^\/]/}"
|
|
if [[ -z "$SHARE" || "$SHARE" != /* || "${#_depth}" -lt 3 ]]; then
|
|
error "Refusing to touch unsafe path: '${SHARE:-empty}' — expected an absolute path at least 3 levels deep"
|
|
notify "Media permissions refused unsafe path on $(hostname): '${SHARE:-empty}'" \
|
|
"Media Permissions" "warning"
|
|
FAILED+=("${SHARE_NAME:-empty}")
|
|
continue
|
|
fi
|
|
|
|
if [[ ! -d "$SHARE" ]]; then
|
|
warn "$SHARE_NAME not found — skipping"
|
|
SKIPPED+=("$SHARE_NAME")
|
|
continue
|
|
fi
|
|
|
|
if [[ "$DRY_RUN" == true ]]; then
|
|
# Count what would be changed without making changes
|
|
DIR_COUNT=$(find "$SHARE" -type d ! -perm "${PERMISSIONS_DIR_MODE:-755}" \
|
|
2>/dev/null | wc -l)
|
|
FILE_COUNT=$(find "$SHARE" -type f ! -perm "${PERMISSIONS_FILE_MODE:-664}" \
|
|
2>/dev/null | wc -l)
|
|
OWNER_COUNT=$(find "$SHARE" \( ! -user "$PERMISSIONS_USER" -o ! -group "$PERMISSIONS_GROUP" \) \
|
|
2>/dev/null | wc -l)
|
|
warn "DRY RUN — $SHARE_NAME: $DIR_COUNT dirs, $FILE_COUNT files, $OWNER_COUNT ownership fixes needed"
|
|
continue
|
|
fi
|
|
|
|
log "Updating $SHARE_NAME..."
|
|
|
|
CHMOD_DIR_OK=true
|
|
CHMOD_FILE_OK=true
|
|
CHOWN_OK=true
|
|
|
|
# Every pass below is conditional — it touches only entries that are actually wrong.
|
|
# This is not just an optimisation. chown/chmod rewrite an inode's ctime even when the
|
|
# value is unchanged, so a blanket pass restamps every file in the share each night and
|
|
# erases ctime as an age signal. The arr cleanups need that signal to tell a file that
|
|
# just landed from one that has sat untracked for days — mtime can't do it, because an
|
|
# import preserves the release's original timestamp (measured 2026-07-27: 400 of 400
|
|
# files imported that week had mtimes over 7 days old, one of them 9613 days).
|
|
|
|
# Count files with wrong ownership before fixing (diagnostic)
|
|
WRONG_OWNER=$(find "$SHARE" \( ! -user "$PERMISSIONS_USER" -o ! -group "$PERMISSIONS_GROUP" \) \
|
|
2>/dev/null | wc -l)
|
|
|
|
# Apply ownership first — affects all files and directories
|
|
if [[ "$WRONG_OWNER" -gt 0 ]]; then
|
|
find "$SHARE" \( ! -user "$PERMISSIONS_USER" -o ! -group "$PERMISSIONS_GROUP" \) \
|
|
-exec chown "$PERMISSIONS_OWNER" {} + 2>/dev/null || CHOWN_OK=false
|
|
fi
|
|
|
|
# Apply directory permissions — separate pass for correctness
|
|
# Directories need execute bit — different from files
|
|
find "$SHARE" -type d ! -perm "${PERMISSIONS_DIR_MODE:-755}" \
|
|
-exec chmod "${PERMISSIONS_DIR_MODE:-755}" {} + \
|
|
2>/dev/null || CHMOD_DIR_OK=false
|
|
|
|
# Apply file permissions — no execute bit on media files
|
|
# Ignore "No such file" errors: race condition with volatile dirs (e.g. Emby transcodes)
|
|
_chmod_errs=$(find "$SHARE" -type f ! -perm "${PERMISSIONS_FILE_MODE:-664}" \
|
|
-exec chmod "${PERMISSIONS_FILE_MODE:-664}" {} + 2>&1 | \
|
|
grep -v "No such file or directory" | grep -c "chmod:" || true)
|
|
[[ "$_chmod_errs" -gt 0 ]] && CHMOD_FILE_OK=false
|
|
|
|
if [[ "$CHMOD_DIR_OK" == true && \
|
|
"$CHMOD_FILE_OK" == true && \
|
|
"$CHOWN_OK" == true ]]; then
|
|
log "$ICON_UNLOCKED $SHARE_NAME — permissions applied"
|
|
UPDATED+=("$SHARE_NAME")
|
|
# Log diagnostic if many files had wrong ownership
|
|
if [[ "$WRONG_OWNER" -gt 0 ]]; then
|
|
warn "$SHARE_NAME — corrected $WRONG_OWNER file(s) with wrong ownership"
|
|
warn "If this is high, check container PUID/PGID settings (should be PUID=99 PGID=100)"
|
|
fi
|
|
TOTAL_DIRS_FIXED=$(( TOTAL_DIRS_FIXED + 1 ))
|
|
TOTAL_FILES_FIXED=$(( TOTAL_FILES_FIXED + WRONG_OWNER ))
|
|
else
|
|
error "$SHARE_NAME — permissions failed"
|
|
error " chown: $CHOWN_OK chmod dirs: $CHMOD_DIR_OK chmod files: $CHMOD_FILE_OK"
|
|
FAILED+=("$SHARE_NAME")
|
|
fi
|
|
done
|
|
|
|
END=$(date +%s)
|
|
|
|
# ==============================================================================================
|
|
# ━━━ Summary ━━━
|
|
# ==============================================================================================
|
|
echo ""
|
|
echo "━━━━━ $ICON_SUMMARY MEDIA PERMISSIONS 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 ""
|
|
[[ ${#UPDATED[@]} -gt 0 ]] && log "Updated: ${#UPDATED[@]} shares"
|
|
[[ ${#SKIPPED[@]} -gt 0 ]] && warn "Skipped: ${SKIPPED[*]} (not found)"
|
|
[[ ${#FAILED[@]} -gt 0 ]] && echo "$ICON_ERROR Failed: ${FAILED[*]}"
|
|
|
|
# Diagnostic — high correction count indicates container PUID/PGID issue
|
|
if [[ "$TOTAL_FILES_FIXED" -gt 50 ]]; then
|
|
warn "$TOTAL_FILES_FIXED files had wrong ownership this run"
|
|
warn "High count suggests a container is not set to PUID=99 PGID=100"
|
|
warn "Common culprits: SABnzbd, qBittorrent, slskd — check container env vars"
|
|
fi
|
|
|
|
if [[ "$DRY_RUN" == true ]]; then
|
|
warn "DRY RUN — no changes made"
|
|
elif [[ ${#FAILED[@]} -gt 0 ]]; then
|
|
echo "$ICON_ERROR Status: SOME SHARES FAILED"
|
|
notify "Media permissions failed on $(hostname) — ${FAILED[*]}" \
|
|
"Media Permissions" "warning"
|
|
else
|
|
echo "$ICON_DONE Status: done — ${#UPDATED[@]} shares updated"
|
|
fi
|
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
|
|
[[ ${#FAILED[@]} -gt 0 ]] && exit 1
|
|
exit 0 |