#!/bin/bash #=============================================================================== # HDD Detection & Setup Script v1.0 # Detects unmounted HDDs, checks state, formats, mounts, creates folder structure # # This script safely prepares external/additional drives for Felhom homeserver: # - Detects unmounted block devices (HDDs and SSDs) # - Shows SMART health, partition tables, existing filesystems # - Formats with ext4 (only after explicit confirmation + safety checks) # - Mounts by UUID in /etc/fstab with proper options # - Creates standard Felhom media + storage folder structure # # Usage: # sudo ./hdd-setup.sh [OPTIONS] # # Options: # --scan Scan and report only (no changes) # --mount-point PATH Pre-set mount point (skip prompt) # --skip-smart Skip SMART health check # --skip-folders Skip folder structure creation # --dry-run Show what would be done without making changes # --debug Enable bash debug tracing (set -x) # -h, --help Show this help message # # Safety: # - Never formats a drive with existing filesystems without triple confirmation # - Shows all existing data/partitions before any destructive action # - Writes fstab backup before modifications # - Uses nofail mount option to prevent boot failures # - All destructive operations require typing "YES" (not just y/n) # #=============================================================================== set -euo pipefail #------------------------------------------------------------------------------- # Configuration #------------------------------------------------------------------------------- SCRIPT_VERSION="1.0.1" # Default values SCAN_ONLY=false PRESET_MOUNT_POINT="" SKIP_SMART=false SKIP_FOLDERS=false DRY_RUN=false DEBUG_MODE=false # Folder structure owner (UID:GID for Docker containers, typically 1000:1000) FOLDER_UID=1000 FOLDER_GID=1000 # Standard Felhom folder structure declare -a MEDIA_DIRS=( "media/downloads/complete" "media/downloads/incomplete" "media/movies" "media/series" "media/music" "media/books" ) declare -a BACKUP_DIRS=( "backups" ) declare -a USER_DIRS=( "Dokumentumok" ) declare -a APPDATA_DIRS=( "appdata" ) #------------------------------------------------------------------------------- # Colors and logging (matching docker-setup.sh conventions) #------------------------------------------------------------------------------- RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' CYAN='\033[0;36m' BOLD='\033[1m' NC='\033[0m' log_info() { echo -e "${GREEN}[INFO]${NC} $1"; } log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } log_error() { echo -e "${RED}[ERROR]${NC} $1"; } log_step() { echo -e "${BLUE}[STEP]${NC} $1"; } log_success() { echo -e "${GREEN}[OK]${NC} $1"; } log_skip() { echo -e "${CYAN}[SKIP]${NC} $1"; } log_debug() { if [[ "$DEBUG_MODE" == true ]]; then echo -e "${CYAN}[DEBUG]${NC} $1"; fi; } log_danger() { echo -e "${RED}${BOLD}[DANGER]${NC} $1"; } #------------------------------------------------------------------------------- # Error handling #------------------------------------------------------------------------------- on_error() { echo "" log_error "Script failed at line $1. Collecting diagnostics..." echo "--- Block Devices ---" lsblk -o NAME,SIZE,TYPE,FSTYPE,MOUNTPOINT 2>/dev/null || true echo "--- /etc/fstab ---" cat /etc/fstab 2>/dev/null || true echo "--- Mount status ---" mount 2>/dev/null | grep -E "^/dev/" || true } trap 'on_error $LINENO' ERR # Cleanup trap for temp files TEMP_FILES=() LOCK_FILE="/tmp/.hdd_setup.lock" cleanup() { rm -f /tmp/.hdd_setup_part_dev 2>/dev/null || true rm -f "$LOCK_FILE" 2>/dev/null || true for f in "${TEMP_FILES[@]}"; do rm -f "$f" 2>/dev/null || true done } trap cleanup EXIT #------------------------------------------------------------------------------- # Helpers #------------------------------------------------------------------------------- print_banner() { echo "" echo -e "${BOLD}${BLUE}-==================================================================¬${NC}" echo -e "${BOLD}${BLUE}¦ HDD Detection & Setup Script v${SCRIPT_VERSION} ¦${NC}" echo -e "${BOLD}${BLUE}L==================================================================-${NC}" echo "" } print_help() { cat << 'EOF' HDD Detection & Setup Script v1.0 Safely detects, formats, mounts, and prepares external drives for Felhom homeserver. USAGE: sudo ./hdd-setup.sh [OPTIONS] OPTIONS: --scan Scan and report only (no changes made) --mount-point PATH Pre-set mount point (e.g., /mnt/hdd_1) --skip-smart Skip SMART health check --skip-folders Skip folder structure creation --dry-run Show what would be done without making changes --debug Enable verbose debug output -h, --help Show this help WHAT THIS SCRIPT DOES: 1. Detects unmounted block devices 2. Shows disk info (size, partitions, SMART health) 3. Lets you select a disk to set up 4. Formats with ext4 (with safety confirmations) 5. Mounts via UUID in /etc/fstab (with nofail) 6. Creates Felhom folder structure FOLDER STRUCTURE CREATED: / ├── Dokumentumok/ ├── media/ │ ├── downloads/complete/ │ ├── downloads/incomplete/ │ ├── movies/ │ ├── series/ │ ├── music/ │ └── books/ ├── appdata/ (app data — created per-app on deploy) └── backups/ (managed by felhom-controller) SAFETY: - Drives with existing data require typing "YES" to format - fstab is backed up before any modification - Uses 'nofail' mount option (system boots even if drive missing) - --scan mode for non-destructive inspection EOF exit 0 } parse_args() { while [[ $# -gt 0 ]]; do case "$1" in --scan) SCAN_ONLY=true; shift ;; --mount-point) PRESET_MOUNT_POINT="$2"; shift 2 ;; --skip-smart) SKIP_SMART=true; shift ;; --skip-folders) SKIP_FOLDERS=true; shift ;; --dry-run) DRY_RUN=true; shift ;; --debug) DEBUG_MODE=true; shift ;; -h|--help) print_help ;; *) log_error "Unknown option: $1"; print_help ;; esac done } check_root() { if [[ $EUID -ne 0 ]]; then log_error "This script must be run as root (use sudo)" exit 1 fi } install_dependencies() { local missing=() # smartmontools for SMART data if ! command -v smartctl &> /dev/null && [[ "$SKIP_SMART" == false ]]; then missing+=("smartmontools") fi # parted for partition inspection if ! command -v parted &> /dev/null; then missing+=("parted") fi # util-linux should be present, but check for blkid/lsblk if ! command -v blkid &> /dev/null; then missing+=("util-linux") fi if [[ ${#missing[@]} -gt 0 ]]; then log_info "Installing missing dependencies: ${missing[*]}" if [[ "$DRY_RUN" == true ]]; then log_info "[DRY RUN] Would install: ${missing[*]}" return fi apt-get update -qq apt-get install -qq -y "${missing[@]}" > /dev/null 2>&1 log_success "Dependencies installed" fi } #------------------------------------------------------------------------------- # Disk detection #------------------------------------------------------------------------------- # Get the device name of the root filesystem (e.g., "sda") get_root_disk() { local root_dev root_dev=$(findmnt -n -o SOURCE / 2>/dev/null | head -1) # Handle LVM: /dev/mapper/vg-root -> find underlying PV if [[ "$root_dev" == /dev/mapper/* ]]; then if command -v pvs &> /dev/null; then root_dev=$(pvs --noheadings -o pv_name 2>/dev/null | head -1 | tr -d ' ') fi fi # Handle partitions: /dev/sda2 -> sda, /dev/nvme0n1p2 -> nvme0n1 local disk_name disk_name=$(lsblk -no PKNAME "$root_dev" 2>/dev/null | head -1) if [[ -z "$disk_name" ]]; then # Fallback: strip partition number disk_name=$(echo "$root_dev" | sed 's|/dev/||' | sed 's/[0-9]*$//' | sed 's/p$//') fi echo "$disk_name" } # Get list of disks that are NOT the system disk and NOT mounted get_candidate_disks() { local root_disk root_disk=$(get_root_disk) log_debug "Root disk detected as: $root_disk" local candidates=() # Iterate over whole-disk block devices (not partitions) while IFS= read -r line; do local name size type name=$(echo "$line" | awk '{print $1}') size=$(echo "$line" | awk '{print $2}') type=$(echo "$line" | awk '{print $3}') # Skip if this IS the root disk if [[ "$name" == "$root_disk" ]]; then log_debug "Skipping $name (root disk)" continue fi # Skip if type is not "disk" if [[ "$type" != "disk" ]]; then log_debug "Skipping $name (type: $type)" continue fi # Skip very small devices (<1GB) - likely USB sticks with boot media, etc. local size_bytes size_bytes=$(lsblk -bno SIZE "/dev/$name" 2>/dev/null | head -1) if [[ -n "$size_bytes" ]] && [[ "$size_bytes" -lt 1073741824 ]]; then log_debug "Skipping $name (too small: $size)" continue fi # Skip loop, ram, zram devices if [[ "$name" =~ ^(loop|ram|zram) ]]; then log_debug "Skipping $name (virtual device)" continue fi # Check if ANY partition of this disk is mounted local any_mounted=false while IFS= read -r part_line; do local part_name part_mount part_name=$(echo "$part_line" | awk '{print $1}') part_mount=$(echo "$part_line" | awk '{print $7}') if [[ -n "$part_mount" ]]; then any_mounted=true log_debug "Skipping $name (partition $part_name mounted at $part_mount)" break fi done < <(lsblk -lno NAME,MAJ:MIN,RM,SIZE,RO,TYPE,MOUNTPOINT "/dev/$name" 2>/dev/null | tail -n +2) # Also check if the disk itself is mounted (whole-disk filesystem) local disk_mount disk_mount=$(lsblk -lno MOUNTPOINT "/dev/$name" 2>/dev/null | head -1) if [[ -n "$disk_mount" ]]; then any_mounted=true log_debug "Skipping $name (disk mounted at $disk_mount)" fi if [[ "$any_mounted" == true ]]; then continue fi candidates+=("$name") done < <(lsblk -lno NAME,SIZE,TYPE 2>/dev/null | grep ' disk$') echo "${candidates[@]}" } #------------------------------------------------------------------------------- # Disk inspection #------------------------------------------------------------------------------- print_disk_info() { local disk="$1" local dev="/dev/$disk" echo "" echo -e "${BOLD}${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" echo -e "${BOLD} Disk: /dev/$disk${NC}" echo -e "${BOLD}${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" # Basic info local size model serial rotational transport size=$(lsblk -dno SIZE "$dev" 2>/dev/null | tr -d ' ') model=$(lsblk -dno MODEL "$dev" 2>/dev/null | sed 's/^[[:space:]]*//' | sed 's/[[:space:]]*$//') serial=$(lsblk -dno SERIAL "$dev" 2>/dev/null | sed 's/^[[:space:]]*//' | sed 's/[[:space:]]*$//') rotational=$(cat /sys/block/"$disk"/queue/rotational 2>/dev/null || echo "?") transport=$(lsblk -dno TRAN "$dev" 2>/dev/null | tr -d ' ') local disk_type="Unknown" if [[ "$rotational" == "1" ]]; then disk_type="HDD (rotational)" elif [[ "$rotational" == "0" ]]; then disk_type="SSD (non-rotational)" fi echo -e " ${BOLD}Size:${NC} $size" echo -e " ${BOLD}Model:${NC} ${model:-Unknown}" echo -e " ${BOLD}Serial:${NC} ${serial:-Unknown}" echo -e " ${BOLD}Type:${NC} $disk_type" echo -e " ${BOLD}Transport:${NC} ${transport:-Unknown}" # Partition table echo "" echo -e " ${BOLD}Partition layout:${NC}" local part_output part_output=$(lsblk -o NAME,SIZE,TYPE,FSTYPE,LABEL,MOUNTPOINT "$dev" 2>/dev/null) echo "$part_output" | sed 's/^/ /' # Check for existing filesystems on whole disk and partitions echo "" echo -e " ${BOLD}Filesystem detection:${NC}" local has_data=false # Check whole disk local whole_fs whole_fs=$(blkid -o value -s TYPE "$dev" 2>/dev/null || echo "") if [[ -n "$whole_fs" ]]; then local whole_label whole_label=$(blkid -o value -s LABEL "$dev" 2>/dev/null || echo "") echo -e " ${YELLOW}$dev: filesystem=$whole_fs label=${whole_label:-}${NC}" has_data=true fi # Check each partition while IFS= read -r part; do [[ -z "$part" ]] && continue local part_dev="/dev/$part" local part_fs part_label part_uuid part_fs=$(blkid -o value -s TYPE "$part_dev" 2>/dev/null || echo "") part_label=$(blkid -o value -s LABEL "$part_dev" 2>/dev/null || echo "") part_uuid=$(blkid -o value -s UUID "$part_dev" 2>/dev/null || echo "") if [[ -n "$part_fs" ]]; then echo -e " ${YELLOW}$part_dev: filesystem=$part_fs label=${part_label:-} uuid=${part_uuid:-}${NC}" has_data=true else echo -e " $part_dev: ${GREEN}no filesystem detected${NC}" fi done < <(lsblk -lno NAME "$dev" 2>/dev/null | tail -n +2) if [[ "$has_data" == false ]]; then echo -e " ${GREEN}No filesystems detected (clean disk)${NC}" fi # SMART health if [[ "$SKIP_SMART" == false ]] && command -v smartctl &> /dev/null; then echo "" echo -e " ${BOLD}SMART Health:${NC}" local smart_output smart_output=$(smartctl -H "$dev" 2>/dev/null || echo "SMART not available") if echo "$smart_output" | grep -q "PASSED"; then echo -e " ${GREEN}SMART overall: PASSED${NC}" elif echo "$smart_output" | grep -q "FAILED"; then echo -e " ${RED}${BOLD}SMART overall: FAILED - DO NOT USE THIS DISK${NC}" else echo -e " ${YELLOW}SMART: Not available (USB enclosures often don't support SMART)${NC}" fi # Show key SMART attributes if available local reallocated power_on temp reallocated=$(smartctl -A "$dev" 2>/dev/null | grep -i "Reallocated_Sector" | awk '{print $NF}' || echo "") power_on=$(smartctl -A "$dev" 2>/dev/null | grep -i "Power_On_Hours" | awk '{print $NF}' || echo "") temp=$(smartctl -A "$dev" 2>/dev/null | grep -i "Temperature_Celsius" | awk '{print $NF}' || echo "") if [[ -n "$reallocated" ]]; then if [[ "$reallocated" -gt 0 ]] 2>/dev/null; then echo -e " ${YELLOW}Reallocated sectors: $reallocated (some wear detected)${NC}" else echo -e " Reallocated sectors: $reallocated" fi fi [[ -n "$power_on" ]] && echo " Power-on hours: $power_on" [[ -n "$temp" ]] && echo " Temperature: ${temp}°C" fi # Return whether disk has data (for caller to use) if [[ "$has_data" == true ]]; then return 1 else return 0 fi } #------------------------------------------------------------------------------- # Disk formatting #------------------------------------------------------------------------------- # Check if a disk has any filesystem signatures anywhere disk_has_data() { local disk="$1" local dev="/dev/$disk" # Check whole disk if blkid "$dev" &>/dev/null; then return 0 # has data fi # Check all partitions while IFS= read -r part; do [[ -z "$part" ]] && continue if blkid "/dev/$part" &>/dev/null; then return 0 # has data fi done < <(lsblk -lno NAME "$dev" 2>/dev/null | tail -n +2) return 1 # no data } # Check if disk is referenced anywhere in fstab disk_in_fstab() { local disk="$1" local dev="/dev/$disk" # Check by device path if grep -q "$dev" /etc/fstab 2>/dev/null; then return 0 fi # Check by UUID of any partition while IFS= read -r part; do [[ -z "$part" ]] && continue local part_uuid part_uuid=$(blkid -o value -s UUID "/dev/$part" 2>/dev/null || echo "") if [[ -n "$part_uuid" ]] && grep -q "$part_uuid" /etc/fstab 2>/dev/null; then return 0 fi done < <(lsblk -lno NAME "$dev" 2>/dev/null | tail -n +2) # Check whole disk UUID local disk_uuid disk_uuid=$(blkid -o value -s UUID "$dev" 2>/dev/null || echo "") if [[ -n "$disk_uuid" ]] && grep -q "$disk_uuid" /etc/fstab 2>/dev/null; then return 0 fi return 1 } format_disk() { local disk="$1" local dev="/dev/$disk" echo "" log_step "Preparing to format /dev/$disk" # Safety check: Is the disk part of a RAID array? if command -v mdadm &>/dev/null; then if mdadm --examine "$dev" &>/dev/null || mdadm --examine "${dev}"* &>/dev/null 2>&1; then log_error "Disk /dev/$disk appears to be part of a RAID array!" log_error "Remove it from the array first with: mdadm --zero-superblock $dev" return 1 fi fi # Check for RAID signatures via blkid if blkid "$dev" 2>/dev/null | grep -qi "linux_raid_member"; then log_error "Disk /dev/$disk has a RAID superblock! Cannot format." return 1 fi # Safety check: Is the disk used by LVM? if command -v pvs &>/dev/null; then if pvs "$dev" &>/dev/null 2>&1 || pvs "${dev}"* &>/dev/null 2>&1; then log_error "Disk /dev/$disk is an LVM physical volume!" log_error "Remove it first with: pvremove $dev" return 1 fi fi # Safety check: Is the disk or any partition currently in use? if lsof "$dev"* 2>/dev/null | grep -q .; then log_error "Disk /dev/$disk has open file handles! Cannot proceed." log_error "Another process is using this disk." return 1 fi # Safety check: Is it in fstab? if disk_in_fstab "$disk"; then log_warn "This disk is referenced in /etc/fstab!" grep -E "(${dev}|$(blkid -o value -s UUID "$dev" 2>/dev/null || echo 'NOUUID'))" /etc/fstab 2>/dev/null | sed 's/^/ /' echo "" log_warn "Proceeding will remove the old fstab entry." fi local has_existing_data=false if disk_has_data "$disk"; then has_existing_data=true fi if [[ "$has_existing_data" == true ]]; then echo "" log_danger "═══════════════════════════════════════════════════════════════" log_danger " THIS DISK CONTAINS DATA! Formatting will DESTROY everything." log_danger "═══════════════════════════════════════════════════════════════" echo "" echo -e " Disk: ${BOLD}/dev/$disk${NC}" echo -e " Existing filesystems:" blkid "$dev"* 2>/dev/null | sed 's/^/ /' || true echo "" # First confirmation echo -e "${RED}${BOLD}Type the disk name (e.g., sdb) to confirm you want to DESTROY ALL DATA:${NC}" read -r confirm_name if [[ "$confirm_name" != "$disk" ]]; then log_info "Disk name did not match. Aborting format." return 1 fi # Second confirmation echo -e "${RED}${BOLD}Type YES (uppercase) to confirm formatting /dev/$disk:${NC}" read -r confirm_yes if [[ "$confirm_yes" != "YES" ]]; then log_info "Confirmation not received. Aborting format." return 1 fi else echo "" echo "This disk appears empty (no filesystems detected)." read -p "Format /dev/$disk with ext4? [y/N] " -n 1 -r echo if [[ ! $REPLY =~ ^[Yy]$ ]]; then log_info "Skipping format." return 1 fi fi if [[ "$DRY_RUN" == true ]]; then log_info "[DRY RUN] Would format /dev/$disk with ext4" return 0 fi log_info "Wiping existing partition table on /dev/$disk..." wipefs -a "$dev" 2>/dev/null || true log_info "Creating GPT partition table..." parted -s "$dev" mklabel gpt log_info "Creating single ext4 partition spanning entire disk..." parted -s "$dev" mkpart primary ext4 0% 100% # Wait for kernel to pick up partition sleep 2 partprobe "$dev" 2>/dev/null || true sleep 1 # Determine partition device name (sdb1 or nvme0n1p1) local part_dev="" # Try common naming patterns if [[ -b "${dev}1" ]]; then part_dev="${dev}1" elif [[ -b "${dev}p1" ]]; then part_dev="${dev}p1" else # Wait a bit more and retry sleep 3 partprobe "$dev" 2>/dev/null || true if [[ -b "${dev}1" ]]; then part_dev="${dev}1" elif [[ -b "${dev}p1" ]]; then part_dev="${dev}p1" else log_error "Could not find partition device after partitioning!" log_error "Expected ${dev}1 or ${dev}p1" lsblk "$dev" return 1 fi fi log_info "Formatting ${part_dev} as ext4..." mkfs.ext4 -F -L "felhom_data" "$part_dev" log_success "Disk formatted successfully" echo " Partition: $part_dev" echo " Filesystem: ext4" echo " Label: felhom_data" # Return the partition device for mounting echo "$part_dev" > /tmp/.hdd_setup_part_dev return 0 } #------------------------------------------------------------------------------- # Mount point selection and mounting #------------------------------------------------------------------------------- suggest_mount_point() { # Find next available /mnt/hdd_N local n=1 while [[ -d "/mnt/hdd_${n}" ]] && mountpoint -q "/mnt/hdd_${n}" 2>/dev/null; do ((n++)) done # If dir exists but not mounted, it might be a leftover — still suggest it if [[ -d "/mnt/hdd_${n}" ]] && [[ -z "$(ls -A /mnt/hdd_${n} 2>/dev/null)" ]]; then echo "/mnt/hdd_${n}" elif [[ ! -d "/mnt/hdd_${n}" ]]; then echo "/mnt/hdd_${n}" else # Directory exists and has content, try next ((n++)) echo "/mnt/hdd_${n}" fi } select_mount_point() { local mount_point="" if [[ -n "$PRESET_MOUNT_POINT" ]]; then mount_point="$PRESET_MOUNT_POINT" log_info "Using pre-set mount point: $mount_point" >&2 else local suggested suggested=$(suggest_mount_point) echo "" >&2 log_step "Select mount point" >&2 echo "" >&2 echo " Suggested: $suggested" >&2 echo "" >&2 read -rp " Enter mount point [${suggested}]: " user_mount mount_point="${user_mount:-$suggested}" fi # Validate mount point if [[ ! "$mount_point" =~ ^/ ]]; then log_error "Mount point must be an absolute path (start with /)" >&2 return 1 fi # Check if something is already mounted there if mountpoint -q "$mount_point" 2>/dev/null; then log_error "$mount_point is already a mount point!" >&2 mount | grep "$mount_point" >&2 return 1 fi # Check if directory exists and has content if [[ -d "$mount_point" ]] && [[ -n "$(ls -A "$mount_point" 2>/dev/null)" ]]; then log_warn "$mount_point exists and is not empty!" >&2 ls -la "$mount_point" >&2 echo "" >&2 log_warn "Mounting here will HIDE existing content (it won't be deleted, but inaccessible while mounted)." >&2 read -p "Continue with this mount point? [y/N] " -n 1 -r echo >&2 if [[ ! $REPLY =~ ^[Yy]$ ]]; then return 1 fi fi echo "$mount_point" } mount_disk() { local part_dev="$1" local mount_point="$2" echo "" log_step "Mounting $part_dev at $mount_point" # Get UUID local uuid uuid=$(blkid -o value -s UUID "$part_dev" 2>/dev/null) if [[ -z "$uuid" ]]; then log_error "Could not determine UUID for $part_dev" return 1 fi log_info "Partition UUID: $uuid" if [[ "$DRY_RUN" == true ]]; then log_info "[DRY RUN] Would create $mount_point" log_info "[DRY RUN] Would add to /etc/fstab: UUID=$uuid $mount_point ext4 defaults,noatime,nofail,x-systemd.device-timeout=10 0 2" log_info "[DRY RUN] Would mount $part_dev at $mount_point" return 0 fi # Create mount point mkdir -p "$mount_point" # Backup fstab local fstab_backup fstab_backup="/etc/fstab.backup.$(date +%Y%m%d_%H%M%S)" cp /etc/fstab "$fstab_backup" log_info "fstab backed up to: $fstab_backup" # Remove any existing entry for this UUID (in case of re-setup) if grep -q "$uuid" /etc/fstab 2>/dev/null; then log_warn "Removing existing fstab entry for UUID=$uuid" sed -i "\|$uuid|d" /etc/fstab fi # Add fstab entry # nofail: system boots even if drive is missing # x-systemd.device-timeout=10: don't wait forever during boot # noatime: skip updating access timestamps (better performance) local fstab_entry="UUID=$uuid $mount_point ext4 defaults,noatime,nofail,x-systemd.device-timeout=10 0 2" { echo "" echo "# Felhom data drive - added by hdd-setup.sh on $(date '+%Y-%m-%d %H:%M:%S')" echo "$fstab_entry" } >> /etc/fstab log_info "Added to /etc/fstab:" echo " $fstab_entry" # Verify fstab syntax (multiple methods - findmnt segfaults on some Debian 13 builds) local fstab_ok=false # Method 1: findmnt --verify (preferred, but may segfault on trixie) local verify_exit=0 findmnt --verify --tab-file /etc/fstab &>/dev/null || verify_exit=$? if [[ $verify_exit -eq 0 ]]; then fstab_ok=true log_debug "fstab verified via findmnt" elif [[ $verify_exit -eq 139 ]] || [[ $verify_exit -gt 128 ]]; then log_debug "findmnt segfaulted (exit $verify_exit), falling back to mount --fake" fi # Method 2: mount --fake -a (dry-run mount test) if [[ "$fstab_ok" == false ]]; then if mount --fake -a &>/dev/null; then fstab_ok=true log_debug "fstab verified via mount --fake" fi fi # Method 3: Basic sanity check on the entry we just wrote if [[ "$fstab_ok" == false ]]; then # Verify our entry has all required fields (UUID, mountpoint, fstype, options, dump, pass) local field_count field_count=$(echo "$fstab_entry" | awk '{print NF}') if [[ "$field_count" -eq 6 ]] && [[ "$fstab_entry" == UUID=* ]]; then fstab_ok=true log_debug "fstab verified via field count sanity check" fi fi if [[ "$fstab_ok" == false ]]; then log_error "fstab verification failed! Restoring backup..." cp "$fstab_backup" /etc/fstab log_info "fstab restored from backup" return 1 fi log_success "fstab syntax verified" # Mount mount "$mount_point" if mountpoint -q "$mount_point"; then log_success "Disk mounted at $mount_point" # Show mounted disk info df -h "$mount_point" | sed 's/^/ /' else log_error "Mount failed! Check dmesg for errors." dmesg | tail -5 return 1 fi } #------------------------------------------------------------------------------- # Folder structure creation #------------------------------------------------------------------------------- create_folder_structure() { local mount_point="$1" echo "" log_step "Creating Felhom folder structure at $mount_point" echo "" echo -e " ${BOLD}Planned structure:${NC}" echo " $mount_point/" for dir in "${USER_DIRS[@]}"; do echo " ├── $dir/" done for dir in "${MEDIA_DIRS[@]}"; do echo " ├── $dir/" done for dir in "${APPDATA_DIRS[@]}"; do echo " ├── $dir/" done for dir in "${BACKUP_DIRS[@]}"; do echo " └── $dir/" done echo "" echo " Owner: ${FOLDER_UID}:${FOLDER_GID} (standard Docker user)" echo "" if [[ "$DRY_RUN" == true ]]; then log_info "[DRY RUN] Would create the above folder structure" return 0 fi read -p "Create this folder structure? [Y/n] " -n 1 -r echo if [[ $REPLY =~ ^[Nn]$ ]]; then log_skip "Folder structure creation skipped" return 0 fi for dir in "${USER_DIRS[@]}" "${MEDIA_DIRS[@]}" "${APPDATA_DIRS[@]}" "${BACKUP_DIRS[@]}"; do mkdir -p "${mount_point}/${dir}" log_debug "Created: ${mount_point}/${dir}" done # Set ownership recursively chown -R "${FOLDER_UID}:${FOLDER_GID}" "$mount_point" # Set permissions: owner rwx, group rwx, others r-x # This allows Docker containers running as 1000:1000 to read/write chmod -R 775 "$mount_point" # Verify local created_count created_count=$(find "$mount_point" -type d | wc -l) log_success "Folder structure created ($created_count directories)" echo "" echo -e " ${BOLD}Result:${NC}" # Use find to show the tree structure (works without 'tree' command) (cd "$mount_point" && find . -type d -maxdepth 3 | sort | sed 's|^\.||' | sed 's|/| |g' | sed 's/^/ /') } #------------------------------------------------------------------------------- # Existing partition handling #------------------------------------------------------------------------------- # Handle a disk that already has a usable ext4 partition handle_existing_partition() { local disk="$1" local dev="/dev/$disk" echo "" log_step "Checking for usable existing partitions on /dev/$disk" local usable_parts=() local part_info=() # Check whole disk for ext4 local whole_fs whole_fs=$(blkid -o value -s TYPE "$dev" 2>/dev/null || echo "") if [[ "$whole_fs" == "ext4" ]]; then usable_parts+=("$dev") local whole_label whole_uuid whole_label=$(blkid -o value -s LABEL "$dev" 2>/dev/null || echo "") whole_uuid=$(blkid -o value -s UUID "$dev" 2>/dev/null || echo "") part_info+=("$dev fs=ext4 label=$whole_label uuid=$whole_uuid") fi # Check partitions while IFS= read -r part; do [[ -z "$part" ]] && continue local part_dev="/dev/$part" local part_fs part_fs=$(blkid -o value -s TYPE "$part_dev" 2>/dev/null || echo "") if [[ "$part_fs" == "ext4" ]]; then usable_parts+=("$part_dev") local p_label p_uuid p_label=$(blkid -o value -s LABEL "$part_dev" 2>/dev/null || echo "") p_uuid=$(blkid -o value -s UUID "$part_dev" 2>/dev/null || echo "") part_info+=("$part_dev fs=ext4 label=$p_label uuid=$p_uuid") fi done < <(lsblk -lno NAME "$dev" 2>/dev/null | tail -n +2) if [[ ${#usable_parts[@]} -eq 0 ]]; then return 1 # No usable ext4 partitions fi echo "" echo -e " ${GREEN}Found usable ext4 partition(s):${NC}" for info in "${part_info[@]}"; do echo " $info" done echo "" echo "Options:" echo " 1) Use existing ext4 partition (no format, no data loss)" echo " 2) Wipe and reformat the entire disk" echo " 3) Cancel" echo "" read -rp "Choose [1/2/3]: " choice case "$choice" in 1) # Use the first (or only) usable ext4 partition local selected_part="${usable_parts[0]}" if [[ ${#usable_parts[@]} -gt 1 ]]; then echo "" echo "Multiple ext4 partitions found. Select one:" for i in "${!usable_parts[@]}"; do echo " $((i+1))) ${part_info[$i]}" done read -rp "Choose [1-${#usable_parts[@]}]: " part_choice local idx=$((part_choice - 1)) if [[ $idx -ge 0 && $idx -lt ${#usable_parts[@]} ]]; then selected_part="${usable_parts[$idx]}" else log_error "Invalid selection" return 1 fi fi log_info "Using existing partition: $selected_part" # Run fsck before mounting log_info "Running filesystem check on $selected_part..." if ! fsck.ext4 -n "$selected_part" 2>/dev/null; then log_warn "Filesystem has issues. Run 'fsck.ext4 -y $selected_part' manually to fix." read -p "Try auto-fix now? [y/N] " -n 1 -r echo if [[ $REPLY =~ ^[Yy]$ ]]; then fsck.ext4 -y "$selected_part" || true fi else log_success "Filesystem check passed" fi echo "$selected_part" > /tmp/.hdd_setup_part_dev return 0 ;; 2) # Will proceed to format return 1 ;; 3) log_info "Cancelled." exit 0 ;; *) log_error "Invalid choice" return 1 ;; esac } #------------------------------------------------------------------------------- # Main flow #------------------------------------------------------------------------------- main() { parse_args "$@" check_root # Logging LOG_FILE="/var/log/hdd-setup.log" mkdir -p /var/log 2>/dev/null || true exec > >(tee -a "$LOG_FILE") 2>&1 if [[ "$DEBUG_MODE" == true ]]; then set -x fi print_banner # Prevent concurrent runs if [[ -f "$LOCK_FILE" ]]; then local lock_pid lock_pid=$(cat "$LOCK_FILE" 2>/dev/null || echo "") if [[ -n "$lock_pid" ]] && kill -0 "$lock_pid" 2>/dev/null; then log_error "Another instance is already running (PID: $lock_pid)" log_error "If this is incorrect, remove $LOCK_FILE" exit 1 fi fi echo $$ > "$LOCK_FILE" if [[ "$DRY_RUN" == true ]]; then echo -e "${YELLOW}DRY RUN MODE - no changes will be made${NC}" echo "" fi install_dependencies #--------------------------------------------------------------------------- # Phase 1: Detect candidate disks #--------------------------------------------------------------------------- log_step "Phase 1: Scanning for unmounted disks..." local root_disk root_disk=$(get_root_disk) log_info "System disk: /dev/$root_disk (will be excluded)" local candidates_str candidates_str=$(get_candidate_disks) if [[ -z "$candidates_str" ]]; then echo "" log_warn "No unmounted disks found!" echo "" echo " All detected disks:" lsblk -o NAME,SIZE,TYPE,FSTYPE,MOUNTPOINT | sed 's/^/ /' echo "" echo " Possible reasons:" echo " - All disks are already mounted" echo " - External drive not connected/detected" echo " - Drive may need a different driver" echo "" echo " Troubleshooting:" echo " dmesg | tail -20 # Check kernel messages" echo " lsusb # Check USB devices" echo " lsblk # Full block device list" exit 0 fi # Convert to array read -ra candidates <<< "$candidates_str" echo "" log_info "Found ${#candidates[@]} unmounted disk(s)" #--------------------------------------------------------------------------- # Phase 2: Show disk information #--------------------------------------------------------------------------- log_step "Phase 2: Inspecting disks..." local disk_has_data_map=() for disk in "${candidates[@]}"; do local has_data=false print_disk_info "$disk" || has_data=true disk_has_data_map+=("$has_data") done if [[ "$SCAN_ONLY" == true ]]; then echo "" echo -e "${BOLD}${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" echo -e "${BOLD} Scan complete. Use without --scan to set up a disk.${NC}" echo -e "${BOLD}${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" exit 0 fi #--------------------------------------------------------------------------- # Phase 3: Select disk #--------------------------------------------------------------------------- echo "" log_step "Phase 3: Select a disk to set up" echo "" if [[ ${#candidates[@]} -eq 1 ]]; then echo " Only one unmounted disk found: /dev/${candidates[0]}" read -p " Set up this disk? [Y/n] " -n 1 -r echo if [[ $REPLY =~ ^[Nn]$ ]]; then log_info "Cancelled." exit 0 fi selected_disk="${candidates[0]}" else echo " Available disks:" for i in "${!candidates[@]}"; do local d="${candidates[$i]}" local s s=$(lsblk -dno SIZE "/dev/$d" 2>/dev/null | tr -d ' ') local m m=$(lsblk -dno MODEL "/dev/$d" 2>/dev/null | sed 's/^[[:space:]]*//' | sed 's/[[:space:]]*$//') echo " $((i+1))) /dev/$d ($s, ${m:-Unknown})" done echo "" read -rp " Select disk [1-${#candidates[@]}]: " disk_choice local idx=$((disk_choice - 1)) if [[ $idx -lt 0 || $idx -ge ${#candidates[@]} ]]; then log_error "Invalid selection" exit 1 fi selected_disk="${candidates[$idx]}" fi log_info "Selected: /dev/$selected_disk" #--------------------------------------------------------------------------- # Phase 4: Format or use existing #--------------------------------------------------------------------------- log_step "Phase 4: Prepare filesystem" local part_dev="" # Check if disk already has a usable ext4 partition if handle_existing_partition "$selected_disk"; then # User chose to use existing partition part_dev=$(cat /tmp/.hdd_setup_part_dev 2>/dev/null) rm -f /tmp/.hdd_setup_part_dev else # Need to format if format_disk "$selected_disk"; then part_dev=$(cat /tmp/.hdd_setup_part_dev 2>/dev/null) rm -f /tmp/.hdd_setup_part_dev else log_error "Format failed or was cancelled." exit 1 fi fi if [[ -z "$part_dev" ]] && [[ "$DRY_RUN" == false ]]; then log_error "No partition device determined. Cannot proceed." exit 1 fi # For dry run, simulate a partition device if [[ "$DRY_RUN" == true ]] && [[ -z "$part_dev" ]]; then part_dev="/dev/${selected_disk}1" fi #--------------------------------------------------------------------------- # Phase 5: Mount #--------------------------------------------------------------------------- log_step "Phase 5: Mount disk" local mount_point mount_point=$(select_mount_point) if [[ -z "$mount_point" ]]; then log_error "No mount point selected. Aborting." exit 1 fi mount_disk "$part_dev" "$mount_point" #--------------------------------------------------------------------------- # Phase 6: Create folder structure #--------------------------------------------------------------------------- if [[ "$SKIP_FOLDERS" == false ]]; then log_step "Phase 6: Create folder structure" create_folder_structure "$mount_point" fi #--------------------------------------------------------------------------- # Summary #--------------------------------------------------------------------------- echo "" echo -e "${BOLD}${GREEN}-==================================================================¬${NC}" echo -e "${BOLD}${GREEN}¦ HDD Setup Complete! ¦${NC}" echo -e "${BOLD}${GREEN}L==================================================================-${NC}" echo "" echo -e " ${BOLD}Disk:${NC} /dev/$selected_disk" echo -e " ${BOLD}Partition:${NC} $part_dev" if [[ "$DRY_RUN" == false ]]; then local final_uuid final_uuid=$(blkid -o value -s UUID "$part_dev" 2>/dev/null || echo "N/A") echo -e " ${BOLD}UUID:${NC} $final_uuid" fi echo -e " ${BOLD}Mount point:${NC} $mount_point" echo -e " ${BOLD}Filesystem:${NC} ext4" echo -e " ${BOLD}fstab:${NC} Configured (nofail)" echo "" if [[ "$DRY_RUN" == false ]]; then echo -e " ${BOLD}Disk usage:${NC}" df -h "$mount_point" | sed 's/^/ /' echo "" fi echo -e " ${BOLD}Docker volume mount examples:${NC}" echo " # Media stack (Sonarr/Radarr/Plex)" echo " volumes:" echo " - ${mount_point}/media:/data/media" echo "" echo " # Immich photo storage" echo " volumes:" echo " - ${mount_point}/appdata/immich:/usr/src/app/upload" echo "" echo " # Nextcloud data" echo " volumes:" echo " - ${mount_point}/appdata/nextcloud:/var/www/html/data" echo "" echo -e " ${BOLD}Verify mount survives reboot:${NC}" echo " sudo mount -a # Test fstab now" echo " sudo reboot # Full test (when convenient)" echo "" echo -e " ${BOLD}Quick check commands:${NC}" echo " lsblk # See all block devices" echo " df -h ${mount_point} # Disk usage" echo " ls -la ${mount_point} # Folder structure" echo "" } main "$@"