diff --git a/ubuntu-post-install.sh b/ubuntu-post-install.sh
index 04f4789..b260202 100644
--- a/ubuntu-post-install.sh
+++ b/ubuntu-post-install.sh
@@ -2,12 +2,91 @@
# Ubuntu 24.04 Post-Installation Script
# Run with: sudo bash post-install.sh
+# This script is rerunnable - it detects existing installations
+
+# ============================================================================
+# COMMAND-LINE ARGUMENT PARSING
+# ============================================================================
+
+DRY_RUN=false
+UNATTENDED=false
+LOG_FILE="/var/log/post-install.log"
+WHIPTAIL_USED=false
+
+show_help() {
+ echo "Ubuntu 24.04 Post-Installation Script"
+ echo ""
+ echo "Usage: sudo ./post-install.sh [OPTIONS]"
+ echo ""
+ echo "Options:"
+ echo " --dry-run Preview what would be installed without making changes"
+ echo " --unattended Run with default options (no prompts)"
+ echo " Defaults: skip key generation, no SSH imports,"
+ echo " install Docker, skip VPNs/Remote Desktop/Backup"
+ echo " --restore Disaster recovery mode - restore from backup"
+ echo " --help Show this help message"
+ echo ""
+ echo "Examples:"
+ echo " sudo ./post-install.sh # Interactive mode"
+ echo " sudo ./post-install.sh --dry-run # Preview installations"
+ echo " sudo ./post-install.sh --unattended # Automated install"
+ echo " sudo ./post-install.sh --restore # Disaster recovery"
+ echo ""
+ exit 0
+}
+
+RESTORE_MODE=false
+
+for arg in "$@"; do
+ case $arg in
+ --dry-run)
+ DRY_RUN=true
+ ;;
+ --unattended)
+ UNATTENDED=true
+ ;;
+ --restore)
+ RESTORE_MODE=true
+ ;;
+ --help|-h)
+ show_help
+ ;;
+ *)
+ echo "Unknown option: $arg"
+ echo "Use --help for usage information"
+ exit 1
+ ;;
+ esac
+done
+
+# ============================================================================
+# LOGGING SETUP
+# ============================================================================
+
+# Create log file and tee all output
+exec > >(tee -a "$LOG_FILE") 2>&1
+echo ""
+echo "=== Post-Install Log Started: $(date) ===" >> "$LOG_FILE"
echo "=== Ubuntu 24.04 Post-Installation Script ==="
echo ""
+if [ "$DRY_RUN" = true ]; then
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo "DRY RUN MODE - No changes will be made"
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo ""
+fi
+
+if [ "$UNATTENDED" = true ]; then
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo "UNATTENDED MODE - Using default options"
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo ""
+fi
+
# Check if running as root
-if [ "$EUID" -ne 0 ]; then
+if [ "$EUID" -ne 0 ]; then
echo "Please run as root (use sudo)"
exit 1
fi
@@ -15,13 +94,2867 @@ fi
# Get the actual user (not root)
ACTUAL_USER="${SUDO_USER:-$USER}"
ACTUAL_HOME=$(getent passwd "$ACTUAL_USER" | cut -d: -f6)
+DOCKER_DIR="$ACTUAL_HOME/docker"
echo "Note: Script will continue even if individual packages fail to install"
+echo "Log file: $LOG_FILE"
+echo ""
+
+# ============================================================================
+# INSTALLATION MODE SELECTOR
+# ============================================================================
+
+INSTALL_MODE="normal"
+
+# Disaster recovery function
+run_disaster_recovery() {
+ echo ""
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo "DISASTER RECOVERY MODE"
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo ""
+ echo "This will restore your system and Docker apps from a Kopia backup."
+ echo ""
+ echo "Requirements:"
+ echo " • Backup drive connected (with kopia-repo folder)"
+ echo " • Kopia password (saved in .env or you remember it)"
+ echo ""
+
+ # Step 1: Install core utilities
+ echo "Step 1: Installing core utilities"
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo ""
+ echo "Installing essential packages..."
+ apt-get update
+ apt-get install -y \
+ net-tools \
+ openssh-server \
+ git \
+ curl \
+ wget \
+ htop \
+ ncdu \
+ tree \
+ zip \
+ unzip \
+ whiptail \
+ 2>/dev/null || echo " ⚠ Some packages may have failed"
+
+ # Install Kopia for backup management
+ echo "Installing Kopia..."
+ if ! command -v kopia &> /dev/null; then
+ curl -s https://kopia.io/signing-key | gpg --dearmor -o /usr/share/keyrings/kopia-keyring.gpg 2>/dev/null
+ echo "deb [signed-by=/usr/share/keyrings/kopia-keyring.gpg] https://packages.kopia.io/apt/ stable main" | tee /etc/apt/sources.list.d/kopia.list
+ apt-get update
+ apt-get install -y kopia 2>/dev/null || echo " ⚠ Kopia install failed"
+ fi
+
+ # Start SSH
+ systemctl enable ssh 2>/dev/null || true
+ systemctl start ssh 2>/dev/null || true
+ echo "✓ Core utilities installed"
+ echo ""
+
+ # Step 2: Find/mount backup drive
+ echo "Step 2: Locate backup drive"
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo ""
+ echo "Available block devices:"
+ lsblk -o NAME,SIZE,FSTYPE,MOUNTPOINT,LABEL 2>/dev/null || lsblk
+ echo ""
+
+ # Check if drives are already mounted
+ if [ -d "$ACTUAL_HOME/drives" ]; then
+ echo "Existing mount points in ~/drives/:"
+ ls -la "$ACTUAL_HOME/drives/" 2>/dev/null || echo " (none)"
+ echo ""
+ fi
+
+ read -p "Enter path to backup drive or Kopia repo (e.g., /dev/sdb1 or ~/drives/backup1): " BACKUP_PATH
+
+ # If it's a device, mount it
+ if [[ "$BACKUP_PATH" == /dev/* ]]; then
+ MOUNT_POINT="$ACTUAL_HOME/drives/restore-backup"
+ mkdir -p "$MOUNT_POINT" 2>/dev/null
+ echo "Mounting $BACKUP_PATH to $MOUNT_POINT..."
+ mount "$BACKUP_PATH" "$MOUNT_POINT" || { echo "Failed to mount. Check device path."; return 1; }
+ BACKUP_PATH="$MOUNT_POINT"
+ fi
+
+ # Expand ~ to home directory
+ BACKUP_PATH="${BACKUP_PATH/#\~/$ACTUAL_HOME}"
+
+ # Look for kopia-repo
+ KOPIA_REPO=""
+ if [ -d "$BACKUP_PATH/kopia-repo" ]; then
+ KOPIA_REPO="$BACKUP_PATH/kopia-repo"
+ elif [ -d "$BACKUP_PATH/kopia.repository" ] || [ -f "$BACKUP_PATH/kopia.repository.f" ]; then
+ KOPIA_REPO="$BACKUP_PATH"
+ else
+ echo ""
+ echo "Looking for Kopia repository..."
+ FOUND_REPO=$(find "$BACKUP_PATH" -maxdepth 3 -name "kopia.repository*" -type f 2>/dev/null | head -1)
+ if [ -n "$FOUND_REPO" ]; then
+ KOPIA_REPO=$(dirname "$FOUND_REPO")
+ fi
+ fi
+
+ if [ -z "$KOPIA_REPO" ] || [ ! -d "$KOPIA_REPO" ]; then
+ echo "❌ Could not find Kopia repository at $BACKUP_PATH"
+ echo " Look for a folder containing 'kopia.repository' files"
+ return 1
+ fi
+
+ echo "✓ Found Kopia repository at: $KOPIA_REPO"
+ echo ""
+
+ # Step 3: Get Kopia password
+ echo "Step 3: Kopia password"
+ echo "━━━━━━━━━━━━━━━━━━━━━━"
+ echo ""
+
+ # Try to find password in backed-up .env
+ if [ -f "$BACKUP_PATH/kopia/.env" ]; then
+ KOPIA_PASS=$(grep "KOPIA_PASSWORD" "$BACKUP_PATH/kopia/.env" 2>/dev/null | cut -d= -f2)
+ if [ -n "$KOPIA_PASS" ]; then
+ echo "Found password in backup. Use this? (y/n)"
+ read -p "> " USE_FOUND_PASS
+ [ "$USE_FOUND_PASS" != "y" ] && KOPIA_PASS=""
+ fi
+ fi
+
+ if [ -z "$KOPIA_PASS" ]; then
+ read -s -p "Enter Kopia repository password: " KOPIA_PASS
+ echo ""
+ fi
+
+ # Step 4: Install Docker if needed
+ echo ""
+ echo "Step 4: Ensure Docker is installed"
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ if ! command -v docker &> /dev/null; then
+ echo "Installing Docker..."
+ apt-get update
+ apt-get install -y ca-certificates curl gnupg
+ install -m 0755 -d /etc/apt/keyrings
+ curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
+ chmod a+r /etc/apt/keyrings/docker.gpg
+ echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null
+ apt-get update
+ apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
+ usermod -aG docker "$ACTUAL_USER"
+ echo "✓ Docker installed"
+ else
+ echo "✓ Docker already installed"
+ fi
+ echo ""
+
+ # Step 5: List snapshots and let user choose
+ echo "Step 5: Select snapshot to restore"
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo ""
+ echo "Connecting to repository and listing snapshots..."
+ echo ""
+
+ # Use docker to run kopia
+ SNAPSHOT_LIST=$(docker run --rm \
+ -v "$KOPIA_REPO:/repository" \
+ -e KOPIA_PASSWORD="$KOPIA_PASS" \
+ kopia/kopia:latest \
+ snapshot list --all 2>&1)
+
+ if echo "$SNAPSHOT_LIST" | grep -q "invalid password"; then
+ echo "❌ Invalid password"
+ return 1
+ fi
+
+ echo "$SNAPSHOT_LIST"
+ echo ""
+ read -p "Enter snapshot ID to restore (or 'latest' for most recent): " SNAPSHOT_ID
+
+ if [ "$SNAPSHOT_ID" = "latest" ]; then
+ SNAPSHOT_ID=$(echo "$SNAPSHOT_LIST" | grep -oE "^[a-f0-9]+" | tail -1)
+ echo "Using latest snapshot: $SNAPSHOT_ID"
+ fi
+
+ # Step 6: Restore to temp location
+ echo ""
+ echo "Step 6: Restoring from backup"
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo ""
+
+ RESTORE_TEMP="$ACTUAL_HOME/docker-restore-temp"
+ mkdir -p "$RESTORE_TEMP"
+
+ echo "Restoring to $RESTORE_TEMP..."
+ docker run --rm \
+ -v "$KOPIA_REPO:/repository" \
+ -v "$RESTORE_TEMP:/restore" \
+ -e KOPIA_PASSWORD="$KOPIA_PASS" \
+ kopia/kopia:latest \
+ restore "$SNAPSHOT_ID" /restore
+
+ if [ $? -ne 0 ]; then
+ echo "❌ Restore failed"
+ return 1
+ fi
+ echo "✓ Snapshot restored to temp location"
+ echo ""
+
+ # Step 7: Detect and select services to restore
+ echo "Step 7: Select services to restore"
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo ""
+
+ mkdir -p "$DOCKER_DIR"
+ ensure_docker_dir_ownership "$DOCKER_DIR"
+
+ # Find all docker-compose.yml files in restored backup
+ declare -A SERVICE_DIRS
+ SERVICES_FOUND=()
+ for compose_file in $(find "$RESTORE_TEMP" -name "docker-compose.yml" -o -name "compose.yml" 2>/dev/null); do
+ SERVICE_DIR=$(dirname "$compose_file")
+ SERVICE_NAME=$(basename "$SERVICE_DIR")
+ SERVICES_FOUND+=("$SERVICE_NAME")
+ SERVICE_DIRS["$SERVICE_NAME"]="$SERVICE_DIR"
+ done
+
+ if [ ${#SERVICES_FOUND[@]} -eq 0 ]; then
+ echo "No Docker services found in backup"
+ else
+ echo "Found ${#SERVICES_FOUND[@]} services in backup."
+ echo ""
+
+ # Check if whiptail is available for nice UI
+ if command -v whiptail &> /dev/null; then
+ # Build whiptail checklist arguments
+ CHECKLIST_ARGS=()
+ for svc in "${SERVICES_FOUND[@]}"; do
+ CHECKLIST_ARGS+=("$svc" "" "ON")
+ done
+
+ # Show checklist - returns selected items
+ SELECTED=$(whiptail --title "Select Services to Restore" \
+ --checklist "Use SPACE to select/deselect, ENTER to confirm:" \
+ 20 60 12 \
+ "${CHECKLIST_ARGS[@]}" \
+ 3>&1 1>&2 2>&3)
+
+ # Parse selected services (whiptail returns quoted strings)
+ SELECTED_SERVICES=()
+ for item in $SELECTED; do
+ # Remove quotes
+ svc=$(echo "$item" | tr -d '"')
+ SELECTED_SERVICES+=("$svc")
+ done
+ else
+ # Fallback: text-based selection
+ echo "Select services to restore:"
+ echo " [A] All services"
+ echo " [N] None (skip restore)"
+ echo " [S] Select individually"
+ echo ""
+ read -p "Choice (A/N/S) [A]: " RESTORE_CHOICE
+
+ case "${RESTORE_CHOICE^^}" in
+ N)
+ SELECTED_SERVICES=()
+ ;;
+ S)
+ SELECTED_SERVICES=()
+ for svc in "${SERVICES_FOUND[@]}"; do
+ read -p " Restore $svc? (y/n) [y]: " RESTORE_SVC
+ if [ "$RESTORE_SVC" != "n" ] && [ "$RESTORE_SVC" != "N" ]; then
+ SELECTED_SERVICES+=("$svc")
+ fi
+ done
+ ;;
+ *)
+ SELECTED_SERVICES=("${SERVICES_FOUND[@]}")
+ ;;
+ esac
+ fi
+
+ # Restore selected services
+ echo ""
+ if [ ${#SELECTED_SERVICES[@]} -eq 0 ]; then
+ echo "No services selected for restore."
+ else
+ echo "Restoring ${#SELECTED_SERVICES[@]} services..."
+ echo ""
+ for svc in "${SELECTED_SERVICES[@]}"; do
+ SERVICE_DIR="${SERVICE_DIRS[$svc]}"
+ TARGET_DIR="$DOCKER_DIR/$svc"
+
+ echo " Restoring $svc..."
+
+ # Copy entire service directory
+ cp -r "$SERVICE_DIR" "$TARGET_DIR" 2>/dev/null || true
+
+ # Fix ownership
+ chown -R "$ACTUAL_USER:$ACTUAL_USER" "$TARGET_DIR" 2>/dev/null || true
+
+ echo " ✓ $svc restored to $TARGET_DIR"
+ done
+ fi
+ fi
+
+ # Step 8: Start services
+ echo ""
+ echo "Step 8: Starting services"
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo ""
+
+ # Count restored services
+ RESTORED_COUNT=0
+ for dir in "$DOCKER_DIR"/*/; do
+ if [ -f "$dir/docker-compose.yml" ] || [ -f "$dir/compose.yml" ]; then
+ ((RESTORED_COUNT++))
+ fi
+ done
+
+ if [ $RESTORED_COUNT -eq 0 ]; then
+ echo "No services to start."
+ else
+ echo "Start restored services?"
+ echo " [A] All services"
+ echo " [S] Select which to start"
+ echo " [N] None (start manually later)"
+ echo ""
+ read -p "Choice (A/S/N) [A]: " START_CHOICE
+
+ case "${START_CHOICE^^}" in
+ N)
+ echo "Services not started. Start manually with:"
+ echo " cd ~/docker/{service} && docker compose up -d"
+ ;;
+ S)
+ for dir in "$DOCKER_DIR"/*/; do
+ if [ -f "$dir/docker-compose.yml" ] || [ -f "$dir/compose.yml" ]; then
+ SERVICE_NAME=$(basename "$dir")
+ read -p " Start $SERVICE_NAME? (y/n) [y]: " START_SVC
+ if [ "$START_SVC" != "n" ] && [ "$START_SVC" != "N" ]; then
+ echo " Starting $SERVICE_NAME..."
+ (cd "$dir" && docker compose up -d 2>/dev/null) || echo " ⚠ Failed to start $SERVICE_NAME"
+ fi
+ fi
+ done
+ ;;
+ *)
+ for dir in "$DOCKER_DIR"/*/; do
+ if [ -f "$dir/docker-compose.yml" ] || [ -f "$dir/compose.yml" ]; then
+ SERVICE_NAME=$(basename "$dir")
+ echo " Starting $SERVICE_NAME..."
+ (cd "$dir" && docker compose up -d 2>/dev/null) || echo " ⚠ Failed to start $SERVICE_NAME"
+ fi
+ done
+ ;;
+ esac
+ fi
+
+ # Cleanup
+ echo ""
+ read -p "Remove temporary restore files? (y/n): " CLEANUP
+ if [ "$CLEANUP" = "y" ]; then
+ rm -rf "$RESTORE_TEMP"
+ echo "✓ Cleaned up temp files"
+ fi
+
+ # Step 9: Reconnect Kopia for future backups
+ echo ""
+ echo "Step 9: Setting up future backups"
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo ""
+ echo "Reconnecting Kopia to backup repository for future backups..."
+
+ if command -v kopia &> /dev/null && [ -n "$KOPIA_REPO" ]; then
+ # Reconnect to the repository
+ if kopia repository connect filesystem --path="$KOPIA_REPO" --password="$KOPIA_PASSWORD" 2>/dev/null; then
+ echo "✓ Kopia reconnected to repository"
+ echo " Repository: $KOPIA_REPO"
+
+ # Verify backup scripts exist
+ if [ -f "$DOCKER_DIR/kopia/backup-containers.sh" ]; then
+ chmod +x "$DOCKER_DIR/kopia/backup-containers.sh" 2>/dev/null || true
+ echo "✓ Backup script ready: $DOCKER_DIR/kopia/backup-containers.sh"
+ else
+ echo " ⚠ Backup script not found - run normal install to set up"
+ fi
+ else
+ echo " ⚠ Could not reconnect to repository"
+ echo " Run manually: kopia repository connect filesystem --path=\"$KOPIA_REPO\""
+ fi
+ else
+ echo " ⚠ Kopia not available or repository not found"
+ echo " Run the normal install script to set up backups"
+ fi
+ echo ""
+
+ echo ""
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo "DISASTER RECOVERY COMPLETE"
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo ""
+ echo "Services restored to: $DOCKER_DIR"
+ echo ""
+ echo "Backups: $(command -v kopia &>/dev/null && kopia repository status &>/dev/null && echo "✓ Connected" || echo "⚠ Not connected")"
+ echo " Run backups: $DOCKER_DIR/kopia/backup-containers.sh"
+ echo ""
+ echo "To check running containers:"
+ echo " docker ps"
+ echo ""
+ echo "To view logs:"
+ echo " docker compose logs -f"
+ echo ""
+ echo "⚠️ Some services may need manual configuration:"
+ echo " • Frigate: Edit config/config.yml with camera URLs"
+ echo " • Caddy: Update Caddyfile with your domain"
+ echo " • ddclient: Edit config/ddclient.conf with DNS credentials"
+ echo ""
+
+ return 0
+}
+
+# Migration function - import existing Docker containers
+run_migration() {
+ echo ""
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo "MIGRATION MODE"
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo ""
+ echo "This will migrate existing Docker containers to this script's structure."
+ echo "Containers will be copied (not moved) and versions preserved."
+ echo ""
+
+ # Step 1: Get source Docker directory
+ echo "Step 1: Locate existing containers"
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo ""
+
+ # Try to auto-detect Docker directories
+ DETECTED_DIRS=()
+
+ # Check common locations
+ for check_dir in /var/docker /opt/docker "$HOME/docker" /home/*/docker; do
+ if [ -d "$check_dir" ] && [ "$(find "$check_dir" -maxdepth 2 -name 'docker-compose.yml' -o -name 'compose.yml' 2>/dev/null | head -1)" ]; then
+ DETECTED_DIRS+=("$check_dir")
+ fi
+ done
+
+ # Check mounted drives: ~/drives/*, /mnt/*, /media/*
+ for mount_base in "$HOME/drives" "$HOME_DIR/drives" /mnt /media; do
+ if [ -d "$mount_base" ]; then
+ for mount_dir in "$mount_base"/*; do
+ if [ -d "$mount_dir/docker" ]; then
+ check_dir="$mount_dir/docker"
+ if [ "$(find "$check_dir" -maxdepth 2 -name 'docker-compose.yml' -o -name 'compose.yml' 2>/dev/null | head -1)" ]; then
+ DETECTED_DIRS+=("$check_dir")
+ fi
+ fi
+ done
+ fi
+ done
+
+ if [ ${#DETECTED_DIRS[@]} -gt 0 ]; then
+ echo "Auto-detected Docker directories:"
+ for i in "${!DETECTED_DIRS[@]}"; do
+ dir="${DETECTED_DIRS[$i]}"
+ count=$(find "$dir" -maxdepth 2 \( -name 'docker-compose.yml' -o -name 'compose.yml' \) 2>/dev/null | wc -l)
+ echo " [$((i+1))] $dir ($count containers)"
+ done
+ echo ""
+ echo "Enter a number to select, or type a custom path:"
+ else
+ echo "No Docker directories auto-detected."
+ echo ""
+ echo "Common locations:"
+ echo " • ~/docker"
+ echo " • ~/drives/primary/docker"
+ echo " • /var/docker"
+ echo " • /opt/docker"
+ echo " • /mnt/data/docker"
+ echo ""
+ echo "Enter the full path to your Docker directory:"
+ fi
+ echo ""
+ read -p "Source directory: " SOURCE_DOCKER_DIR
+
+ # Check if user entered a number (to select from detected list)
+ if [[ "$SOURCE_DOCKER_DIR" =~ ^[0-9]+$ ]] && [ ${#DETECTED_DIRS[@]} -gt 0 ]; then
+ idx=$((SOURCE_DOCKER_DIR - 1))
+ if [ $idx -ge 0 ] && [ $idx -lt ${#DETECTED_DIRS[@]} ]; then
+ SOURCE_DOCKER_DIR="${DETECTED_DIRS[$idx]}"
+ echo "Selected: $SOURCE_DOCKER_DIR"
+ fi
+ fi
+
+ # Expand ~ if used
+ SOURCE_DOCKER_DIR="${SOURCE_DOCKER_DIR/#\~/$HOME}"
+
+ if [ ! -d "$SOURCE_DOCKER_DIR" ]; then
+ echo "❌ Directory not found: $SOURCE_DOCKER_DIR"
+ return 1
+ fi
+
+ # Step 2: Scan for containers
+ echo ""
+ echo "Step 2: Scanning for containers"
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo ""
+
+ CONTAINERS_FOUND=()
+ CONTAINER_PATHS=()
+
+ while IFS= read -r compose_file; do
+ if [ -n "$compose_file" ]; then
+ container_dir=$(dirname "$compose_file")
+ container_name=$(basename "$container_dir")
+ CONTAINERS_FOUND+=("$container_name")
+ CONTAINER_PATHS+=("$container_dir")
+ fi
+ done < <(find "$SOURCE_DOCKER_DIR" -maxdepth 2 \( -name 'docker-compose.yml' -o -name 'compose.yml' \) 2>/dev/null)
+
+ if [ ${#CONTAINERS_FOUND[@]} -eq 0 ]; then
+ echo "❌ No Docker containers found in $SOURCE_DOCKER_DIR"
+ return 1
+ fi
+
+ echo "Found ${#CONTAINERS_FOUND[@]} container(s):"
+ for i in "${!CONTAINERS_FOUND[@]}"; do
+ container="${CONTAINERS_FOUND[$i]}"
+ path="${CONTAINER_PATHS[$i]}"
+ size=$(du -sh "$path" 2>/dev/null | cut -f1)
+ echo " [$((i+1))] $container ($size)"
+ done
+ echo ""
+
+ # Step 3: Select containers to migrate
+ echo "Step 3: Select containers to migrate"
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo ""
+
+ SELECTED_CONTAINERS=()
+
+ if command -v whiptail &> /dev/null && [ ${#CONTAINERS_FOUND[@]} -gt 3 ]; then
+ # Use whiptail for many containers
+ CHECKLIST_ARGS=()
+ for container in "${CONTAINERS_FOUND[@]}"; do
+ CHECKLIST_ARGS+=("$container" "" "ON")
+ done
+
+ SELECTED=$(whiptail --title "Select Containers to Migrate" \
+ --checklist "Use SPACE to select/deselect, ENTER to confirm:" \
+ 20 60 12 \
+ "${CHECKLIST_ARGS[@]}" \
+ 3>&1 1>&2 2>&3)
+
+ if [ $? -eq 0 ] && [ -n "$SELECTED" ]; then
+ # Parse whiptail output
+ for container in $SELECTED; do
+ # Remove quotes
+ container="${container//\"/}"
+ SELECTED_CONTAINERS+=("$container")
+ done
+ fi
+ else
+ # Text-based selection
+ echo "Select containers to migrate:"
+ echo " [A] All containers"
+ echo " [S] Select individually"
+ echo " [N] None (cancel)"
+ echo ""
+ read -p "Choice (A/S/N) [A]: " MIGRATE_CHOICE
+
+ case "${MIGRATE_CHOICE^^}" in
+ N)
+ echo "Migration cancelled."
+ return 0
+ ;;
+ S)
+ for container in "${CONTAINERS_FOUND[@]}"; do
+ read -p " Migrate $container? (y/n) [y]: " MIGRATE_THIS
+ if [ "$MIGRATE_THIS" != "n" ] && [ "$MIGRATE_THIS" != "N" ]; then
+ SELECTED_CONTAINERS+=("$container")
+ fi
+ done
+ ;;
+ *)
+ SELECTED_CONTAINERS=("${CONTAINERS_FOUND[@]}")
+ ;;
+ esac
+ fi
+
+ if [ ${#SELECTED_CONTAINERS[@]} -eq 0 ]; then
+ echo "No containers selected."
+ return 0
+ fi
+
+ echo ""
+ echo "Will migrate ${#SELECTED_CONTAINERS[@]} container(s):"
+ for container in "${SELECTED_CONTAINERS[@]}"; do
+ echo " • $container"
+ done
+ echo ""
+
+ # Step 4: Stop running containers (optional)
+ echo "Step 4: Container status"
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo ""
+ echo "Should running containers be stopped before migration?"
+ echo " • Stopping ensures clean copy of data/databases"
+ echo " • Not stopping may result in inconsistent state"
+ echo ""
+ read -p "Stop containers before copying? (y/n) [y]: " STOP_CONTAINERS
+
+ if [ "$STOP_CONTAINERS" != "n" ] && [ "$STOP_CONTAINERS" != "N" ]; then
+ echo "Stopping containers..."
+ for container in "${SELECTED_CONTAINERS[@]}"; do
+ for i in "${!CONTAINERS_FOUND[@]}"; do
+ if [ "${CONTAINERS_FOUND[$i]}" = "$container" ]; then
+ container_dir="${CONTAINER_PATHS[$i]}"
+ if [ -f "$container_dir/docker-compose.yml" ] || [ -f "$container_dir/compose.yml" ]; then
+ echo " Stopping $container..."
+ (cd "$container_dir" && docker compose down 2>/dev/null) || true
+ fi
+ fi
+ done
+ done
+ echo ""
+ fi
+
+ # Step 5: Decide migration method
+ echo "Step 5: Migration method"
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo ""
+
+ # Check if source is on a different drive (mounted drive, not home on OS)
+ SOURCE_ON_EXTERNAL=false
+ if [[ "$SOURCE_DOCKER_DIR" == /mnt/* ]] || [[ "$SOURCE_DOCKER_DIR" == /media/* ]] || [[ "$SOURCE_DOCKER_DIR" == */drives/* ]]; then
+ SOURCE_ON_EXTERNAL=true
+ fi
+
+ MIGRATION_METHOD="copy"
+ if [ "$SOURCE_ON_EXTERNAL" = true ]; then
+ echo "Source is on a mounted drive: $SOURCE_DOCKER_DIR"
+ echo ""
+ echo "How would you like to migrate?"
+ echo ""
+ echo " [C] Copy - Copy containers to ~/docker on OS drive"
+ echo " Best for: old OS drive mounted temporarily"
+ echo ""
+ echo " [S] Symlink - Create ~/docker as symlink to source location"
+ echo " Best for: data drive you'll keep using"
+ echo ""
+ echo " [U] Use in-place - Use source location directly, no copy"
+ echo " Best for: already on your data drive"
+ echo ""
+ read -p "Method (C/S/U) [C]: " MIGRATION_METHOD
+
+ case "${MIGRATION_METHOD^^}" in
+ S)
+ MIGRATION_METHOD="symlink"
+ ;;
+ U)
+ MIGRATION_METHOD="use"
+ ;;
+ *)
+ MIGRATION_METHOD="copy"
+ ;;
+ esac
+ else
+ echo "Source: $SOURCE_DOCKER_DIR"
+ echo "Target: $DOCKER_DIR"
+ echo ""
+ echo "Will copy containers to $DOCKER_DIR"
+ fi
+
+ echo ""
+
+ # Handle different migration methods
+ case "$MIGRATION_METHOD" in
+ symlink)
+ echo "Creating symlink: $DOCKER_DIR → $SOURCE_DOCKER_DIR"
+ echo ""
+
+ # Check if target exists
+ if [ -e "$DOCKER_DIR" ] || [ -L "$DOCKER_DIR" ]; then
+ echo " ⚠ $DOCKER_DIR already exists"
+ read -p " Remove and create symlink? (y/n) [n]: " REMOVE_EXISTING
+ if [ "$REMOVE_EXISTING" = "y" ] || [ "$REMOVE_EXISTING" = "Y" ]; then
+ rm -rf "$DOCKER_DIR"
+ else
+ echo " Cancelled."
+ return 1
+ fi
+ fi
+
+ ln -s "$SOURCE_DOCKER_DIR" "$DOCKER_DIR"
+ chown -h "$ACTUAL_USER:$ACTUAL_USER" "$DOCKER_DIR"
+
+ echo "✓ Symlink created"
+ echo " Containers stay at: $SOURCE_DOCKER_DIR"
+ echo " Accessible via: $DOCKER_DIR → $SOURCE_DOCKER_DIR"
+ MIGRATED_COUNT=${#SELECTED_CONTAINERS[@]}
+ ;;
+
+ use)
+ echo "Using source location directly"
+ echo ""
+ DOCKER_DIR="$SOURCE_DOCKER_DIR"
+ echo "✓ DOCKER_DIR set to: $DOCKER_DIR"
+ echo " No files copied - containers remain in place"
+ MIGRATED_COUNT=${#SELECTED_CONTAINERS[@]}
+ ;;
+
+ copy|*)
+ echo "Copying containers to $DOCKER_DIR"
+ echo ""
+
+ # Create target directory
+ mkdir -p "$DOCKER_DIR"
+ chown "$ACTUAL_USER:$ACTUAL_USER" "$DOCKER_DIR"
+
+ MIGRATED_COUNT=0
+ for container in "${SELECTED_CONTAINERS[@]}"; do
+ for i in "${!CONTAINERS_FOUND[@]}"; do
+ if [ "${CONTAINERS_FOUND[$i]}" = "$container" ]; then
+ source_dir="${CONTAINER_PATHS[$i]}"
+ target_dir="$DOCKER_DIR/$container"
+
+ # Check if already exists
+ if [ -d "$target_dir" ]; then
+ echo " ⚠ $container already exists at $target_dir"
+ read -p " Overwrite? (y/n) [n]: " OVERWRITE
+ if [ "$OVERWRITE" != "y" ] && [ "$OVERWRITE" != "Y" ]; then
+ echo " Skipping $container"
+ continue
+ fi
+ rm -rf "$target_dir"
+ fi
+
+ echo " Copying $container..."
+ cp -a "$source_dir" "$target_dir"
+ chown -R "$ACTUAL_USER:$ACTUAL_USER" "$target_dir"
+ ((MIGRATED_COUNT++))
+ echo " ✓ $container copied"
+ fi
+ done
+ done
+ ;;
+ esac
+
+ echo ""
+ echo "✓ Migrated $MIGRATED_COUNT container(s)"
+ echo ""
+
+ # Step 6: Update volume paths
+ echo "Step 6: Update volume paths"
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo ""
+ echo "Scanning docker-compose files for volume mounts that may need updating..."
+ echo ""
+
+ # Find all absolute paths in volume mounts (excluding relative paths like ./ or named volumes)
+ PATHS_FOUND=()
+ PATHS_FILES=()
+
+ for container in "${SELECTED_CONTAINERS[@]}"; do
+ compose_file="$DOCKER_DIR/$container/docker-compose.yml"
+ [ -f "$compose_file" ] || compose_file="$DOCKER_DIR/$container/compose.yml"
+ [ -f "$compose_file" ] || continue
+
+ # Extract volume mount paths (lines with : that look like /path:/container/path)
+ while IFS= read -r line; do
+ # Match absolute paths in volume mounts (starting with /)
+ if [[ "$line" =~ ^[[:space:]]*-[[:space:]]*(/[^:]+): ]]; then
+ host_path="${BASH_REMATCH[1]}"
+ # Skip if it's a relative path or standard paths
+ if [[ "$host_path" != "./"* ]] && [[ "$host_path" != "/etc/"* ]] && [[ "$host_path" != "/var/run/"* ]]; then
+ # Check if this path doesn't exist on current system
+ if [ ! -e "$host_path" ]; then
+ # Avoid duplicates
+ if [[ ! " ${PATHS_FOUND[*]} " =~ " ${host_path} " ]]; then
+ PATHS_FOUND+=("$host_path")
+ PATHS_FILES+=("$compose_file")
+ fi
+ fi
+ fi
+ fi
+ done < "$compose_file"
+ done
+
+ if [ ${#PATHS_FOUND[@]} -eq 0 ]; then
+ echo "✓ No volume paths need updating (all paths exist or are relative)"
+ echo ""
+ else
+ echo "Found ${#PATHS_FOUND[@]} volume path(s) that don't exist on this system:"
+ echo ""
+
+ # Default new base path
+ DEFAULT_NEW_BASE="$HOME_DIR/drives/primary"
+
+ for i in "${!PATHS_FOUND[@]}"; do
+ old_path="${PATHS_FOUND[$i]}"
+ compose_file="${PATHS_FILES[$i]}"
+ container_name=$(basename "$(dirname "$compose_file")")
+
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo "Container: $container_name"
+ echo "Old path: $old_path"
+
+ # Suggest a new path based on the old path structure
+ # Extract the last part of the path for suggestion
+ path_tail=$(basename "$old_path")
+ suggested_path="$DEFAULT_NEW_BASE/$path_tail"
+
+ echo "Suggested: $suggested_path"
+ echo ""
+ echo "Options:"
+ echo " [Enter] Accept suggested path"
+ echo " [S] Skip - keep original path"
+ echo " [path] Enter custom path"
+ echo ""
+ read -p "New path: " NEW_PATH_INPUT
+
+ case "$NEW_PATH_INPUT" in
+ ""|" ")
+ new_path="$suggested_path"
+ ;;
+ [Ss])
+ echo " Skipping - keeping original path"
+ continue
+ ;;
+ *)
+ new_path="${NEW_PATH_INPUT/#\~/$HOME_DIR}"
+ ;;
+ esac
+
+ # Update the compose file
+ echo " Updating: $old_path → $new_path"
+
+ # Escape paths for sed (handle slashes)
+ old_escaped=$(printf '%s\n' "$old_path" | sed 's/[[\.*^$()+?{|]/\\&/g; s/\//\\\//g')
+ new_escaped=$(printf '%s\n' "$new_path" | sed 's/[[\.*^$()+?{|]/\\&/g; s/\//\\\//g')
+
+ sed -i "s|$old_path|$new_path|g" "$compose_file"
+
+ # Create directory if it doesn't exist
+ if [ ! -d "$new_path" ]; then
+ echo " Creating directory: $new_path"
+ mkdir -p "$new_path" 2>/dev/null || echo " ⚠ Could not create (may need manual creation)"
+ chown -R "$ACTUAL_USER:$ACTUAL_USER" "$new_path" 2>/dev/null || true
+ fi
+
+ echo " ✓ Updated"
+ echo ""
+ done
+
+ echo "✓ Volume paths updated"
+ echo ""
+ fi
+
+ # Step 7: Start migrated containers (optional)
+ echo "Step 7: Start containers"
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo ""
+ read -p "Start migrated containers? (y/n) [y]: " START_MIGRATED
+
+ if [ "$START_MIGRATED" != "n" ] && [ "$START_MIGRATED" != "N" ]; then
+ for container in "${SELECTED_CONTAINERS[@]}"; do
+ target_dir="$DOCKER_DIR/$container"
+ if [ -d "$target_dir" ]; then
+ echo " Starting $container..."
+ (cd "$target_dir" && docker compose up -d 2>/dev/null) || echo " ⚠ Failed to start $container"
+ fi
+ done
+ echo ""
+ fi
+
+ # Step 8: Offer additional services
+ echo ""
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo "MIGRATION COMPLETE"
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo ""
+ echo "Containers migrated to: $DOCKER_DIR"
+ echo ""
+ echo "Would you like to install additional services not in your migration?"
+ echo "This will continue with the normal install process where you can"
+ echo "choose which additional apps to install (Immich, Frigate, etc.)"
+ echo ""
+ read -p "Continue to install additional services? (y/n) [y]: " INSTALL_MORE
+
+ if [ "$INSTALL_MORE" = "n" ] || [ "$INSTALL_MORE" = "N" ]; then
+ echo ""
+ echo "Migration complete. You can run this script again to install more services."
+ echo ""
+ echo "To check running containers:"
+ echo " docker ps"
+ echo ""
+ return 0
+ fi
+
+ # Return to continue with normal install
+ INSTALL_MODE="post-migration"
+ return 0
+}
+
+# ============================================================================
+# LOCAL AI STACK INSTALLER
+# ============================================================================
+
+install_ai_stack() {
+ echo ""
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo "LOCAL AI STACK INSTALLATION"
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo ""
+ echo "Installs the full local AI stack:"
+ echo " • Ollama (LLM engine) • Open WebUI (chat interface)"
+ echo " • RAG server (document search) • MCP server (Claude integration)"
+ echo " • Kiwix (offline wiki) • Gitea (self-hosted git)"
+ echo " • SearXNG (private search) • InvokeAI (image generation)"
+ echo " • Portainer (Docker management)"
+ echo ""
+
+ # Ensure Docker is available
+ if ! command -v docker &>/dev/null; then
+ echo "Docker is required but not installed."
+ echo "Run option [N] first to install system services (includes Docker)."
+ echo ""
+ read -p "Press Enter to continue..." _dummy
+ return 1
+ fi
+
+ # --- Storage selection ------------------------------------------------
+ echo ""
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo "STORAGE FOR AI MODELS & WIKI FILES"
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo ""
+ echo "Ollama models (7B=4GB, 14B=9GB) and Kiwix ZIM files (Wikipedia"
+ echo "~46GB, Gutenberg ~60GB) can live on a dedicated data drive."
+ echo ""
+ echo "Currently mounted drives:"
+ echo ""
+ df -h --output=target,size,avail 2>/dev/null \
+ | tail -n +2 \
+ | grep -v -E '^(/sys|/proc|/dev|/run|/snap|/boot|efi|tmpfs|udev)' \
+ | grep -v ' /$' \
+ | awk '{printf " %-38s %-8s %s free\n", $1, $2, $3}' \
+ | head -12
+ echo ""
+
+ AI_EXTRA_DRIVE=""
+ AI_MODEL_FOLDER="ollama-models"
+ AI_ZIM_FOLDER="zims"
+
+ read -p "Store models/ZIMs on a specific drive? (y/n) [n]: " _USE_DRIVE
+ if [ "$_USE_DRIVE" = "y" ] || [ "$_USE_DRIVE" = "Y" ]; then
+ read -p " Drive path (e.g., $ACTUAL_HOME/drives/primary): " AI_EXTRA_DRIVE
+ AI_EXTRA_DRIVE="${AI_EXTRA_DRIVE/#\~/$ACTUAL_HOME}"
+
+ if [ -n "$AI_EXTRA_DRIVE" ] && [ -d "$AI_EXTRA_DRIVE" ]; then
+ read -p " Folder name for Ollama models [ollama-models]: " _MODEL_F
+ read -p " Folder name for Kiwix ZIM files [zims]: " _ZIM_F
+ AI_MODEL_FOLDER="${_MODEL_F:-ollama-models}"
+ AI_ZIM_FOLDER="${_ZIM_F:-zims}"
+
+ mkdir -p "$AI_EXTRA_DRIVE/$AI_MODEL_FOLDER" "$AI_EXTRA_DRIVE/$AI_ZIM_FOLDER"
+ chown -R "$ACTUAL_USER:$ACTUAL_USER" \
+ "$AI_EXTRA_DRIVE/$AI_MODEL_FOLDER" \
+ "$AI_EXTRA_DRIVE/$AI_ZIM_FOLDER" 2>/dev/null || true
+
+ echo ""
+ echo " ✓ Models → $AI_EXTRA_DRIVE/$AI_MODEL_FOLDER"
+ echo " ✓ ZIMs → $AI_EXTRA_DRIVE/$AI_ZIM_FOLDER"
+ else
+ echo " ⚠ Path not found — using defaults under ~/docker/ai-stack/"
+ AI_EXTRA_DRIVE=""
+ fi
+ fi
+
+ # --- Write embedded local-ai-setup.sh to temp file and run it --------
+ echo ""
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo "Running AI stack installer as $ACTUAL_USER ..."
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo ""
+ AI_SETUP_SCRIPT=$(mktemp /tmp/local-ai-setup.XXXXXX.sh)
+ cat > "$AI_SETUP_SCRIPT" << 'LOCAL_AI_SETUP_EOF'
+#!/usr/bin/env bash
+# Local AI Stack — single script, new install or update
+# Usage: ./local-ai-setup.sh [--force] [--no-pull]
+set -euo pipefail
+
+RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
+CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m'
+info() { echo -e "${CYAN}[..]${NC} $*"; }
+ok() { echo -e "${GREEN}[OK]${NC} $*"; }
+warn() { echo -e "${YELLOW}[!!]${NC} $*"; }
+section() { echo -e "\n${BOLD}━━━ $* ━━━${NC}"; }
+
+FORCE=false; NO_PULL=false
+for a in "$@"; do [[ "$a" == "--force" ]] && FORCE=true; [[ "$a" == "--no-pull" ]] && NO_PULL=true; done
+
+BASE="$HOME/docker/ai-stack"
+LOCAL_IP=$(ip route get 1.1.1.1 2>/dev/null | grep -oP 'src \K\S+' || hostname -I | awk '{print $1}')
+[[ -z "$LOCAL_IP" ]] && read -rp "Enter LAN IP: " LOCAL_IP
+
+IS_UPDATE=false; [[ -f "$BASE/docker-compose.yml" ]] && IS_UPDATE=true
+
+# ── detect VRAM and set models accordingly ────────────────────────────────────
+VRAM_GB=$(nvidia-smi --query-gpu=memory.total --format=csv,noheader,nounits 2>/dev/null \
+ | head -1 | awk '{printf "%d", $1/1024}' 2>/dev/null || echo "0")
+
+if [[ "$VRAM_GB" -ge 14 ]]; then
+ CHAT_MODEL="qwen2.5:14b"; CODE_MODEL="qwen2.5-coder:14b"
+ CTX=32768; TIER="16GB — 14B models + 32k context"
+elif [[ "$VRAM_GB" -ge 8 ]]; then
+ CHAT_MODEL="qwen2.5:14b"; CODE_MODEL="qwen2.5-coder:7b"
+ CTX=16384; TIER="8-16GB — 14B chat, 7B code, 16k context"
+elif [[ "$VRAM_GB" -ge 4 ]]; then
+ CHAT_MODEL="qwen2.5:7b"; CODE_MODEL="qwen2.5-coder:7b"
+ CTX=8192; TIER="6GB — 7B models, 8k context"
+else
+ CHAT_MODEL="qwen2.5:7b"; CODE_MODEL="qwen2.5-coder:7b"
+ CTX=4096; TIER="CPU-only — 7B models, 4k context"
+fi
+EMBED_MODEL="nomic-embed-text"
+
+section "Local AI Stack — $($IS_UPDATE && echo UPDATE || echo NEW INSTALL)"
+info "Base : $BASE"
+info "IP : $LOCAL_IP"
+info "GPU : ${VRAM_GB}GB VRAM → $TIER"
+
+write_if_new() {
+ local dest="$1"; local body; body=$(cat)
+ if [[ ! -f "$dest" ]] || $FORCE; then
+ printf '%s\n' "$body" > "$dest"; ok "Wrote $(basename "$dest")"
+ else
+ info "Kept $(basename "$dest") (--force to overwrite)"
+ fi
+}
+
+# ── prereqs (new install only) ────────────────────────────────────────────────
+if ! $IS_UPDATE; then
+ section "Prerequisites"
+ if ! command -v docker &>/dev/null; then
+ info "Installing Docker..."
+ curl -fsSL https://get.docker.com | sh
+ sudo usermod -aG docker "$USER"
+ warn "Run: newgrp docker (or log out/in)"
+ else
+ ok "Docker: $(docker --version | cut -d' ' -f3)"
+ fi
+
+ if command -v nvidia-smi &>/dev/null && ! dpkg -l 2>/dev/null | grep -q nvidia-container-toolkit; then
+ info "Installing NVIDIA Container Toolkit..."
+ dist=$(. /etc/os-release && echo "$ID$VERSION_ID")
+ curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey \
+ | sudo gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg
+ curl -fsSL "https://nvidia.github.io/libnvidia-container/$dist/libnvidia-container.list" \
+ | sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' \
+ | sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list
+ sudo apt-get update -qq && sudo apt-get install -y nvidia-container-toolkit
+ sudo nvidia-ctk runtime configure --runtime=docker && sudo systemctl restart docker
+ ok "NVIDIA Container Toolkit installed"
+ fi
+ command -v rg &>/dev/null || sudo apt-get install -y ripgrep
+fi
+
+# ── directories ───────────────────────────────────────────────────────────────
+section "Directories"
+for d in papers repos workspace index searxng invokeai-data invokeai-outputs kiwix gitea portainer-data logs; do
+ mkdir -p "$BASE/$d"
+done
+ok "Ready under $BASE"
+
+# =============================================================================
+section "Writing server.py (RAG)"
+# =============================================================================
+write_if_new "$BASE/server.py" << 'PY'
+import ast, fnmatch, hashlib, json, logging, os, re, subprocess, threading, time
+from pathlib import Path
+from typing import Any
+import chromadb, httpx
+from chromadb.utils.embedding_functions import OllamaEmbeddingFunction
+from fastapi import FastAPI, HTTPException, Request, BackgroundTasks
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.responses import StreamingResponse
+from pydantic import BaseModel
+
+logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
+log = logging.getLogger("rag")
+
+OLLAMA_URL = os.getenv("OLLAMA_URL", "http://ollama:11434")
+CHROMA_URL = os.getenv("CHROMA_URL", "http://chromadb:8000")
+EMBED_MODEL = os.getenv("EMBED_MODEL", "nomic-embed-text")
+CHAT_MODEL = os.getenv("CHAT_MODEL", "qwen2.5:14b")
+PAPERS_DIR = Path(os.getenv("PAPERS_DIR", "/papers"))
+REPOS_DIR = Path(os.getenv("REPOS_DIR", "/repos"))
+TOP_K = int(os.getenv("TOP_K", "6"))
+
+CODE_EXTS = {".py",".js",".ts",".tsx",".jsx",".go",".rs",".java",".c",".cpp",
+ ".h",".cs",".rb",".sh",".yaml",".yml",".toml",".sql",".md"}
+SKIP_DIRS = {"node_modules",".git","__pycache__","dist","build",".venv","venv","target"}
+MAX_BYTES = 400_000
+
+app = FastAPI(title="RAG Server")
+app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
+
+def _embed_fn():
+ return OllamaEmbeddingFunction(url=f"{OLLAMA_URL}/api/embeddings", model_name=EMBED_MODEL)
+
+def _chroma():
+ host, port = CHROMA_URL.replace("http://","").split(":")
+ return chromadb.HttpClient(host=host, port=int(port))
+
+def get_col(name):
+ return _chroma().get_or_create_collection(name, embedding_function=_embed_fn())
+
+def _doc_id(text, key):
+ return hashlib.md5(f"{key}|{text[:200]}".encode()).hexdigest()
+
+def _sliding(text, size=1000, overlap=150):
+ chunks, i = [], 0
+ while i < len(text):
+ chunks.append(text[i:i+size]); i += size - overlap
+ return [c for c in chunks if c.strip()]
+
+def _chunk_python(src):
+ try: tree = ast.parse(src)
+ except SyntaxError: return []
+ lines = src.splitlines(); out = []
+ for node in ast.iter_child_nodes(tree):
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
+ out.append((node.name, "\n".join(lines[node.lineno-1:node.end_lineno])[:4000]))
+ return out
+
+def _chunk_file(path, src):
+ if path.suffix == ".py":
+ pairs = _chunk_python(src)
+ if pairs: return pairs
+ pat = re.compile(r'(?:^|\n)(?=(?:export\s+)?(?:async\s+)?(?:function|class)|^func |^type |^impl |^pub fn |^fn )', re.M)
+ parts = [p.strip() for p in pat.split(src) if p.strip()]
+ if len(parts) > 1: return [(f"s{i}", p[:4000]) for i,p in enumerate(parts)]
+ return [(f"c{i}", c) for i,c in enumerate(_sliding(src, 1200, 200))]
+
+def ingest_file(col, fpath, repo=""):
+ if fpath.stat().st_size > MAX_BYTES: return
+ if any(fnmatch.fnmatch(fpath.name, p) for p in ("*.min.js","*.map","package-lock.json","yarn.lock")): return
+ try: src = fpath.read_text(encoding="utf-8", errors="ignore")
+ except: return
+ if not src.strip(): return
+ rel = str(fpath)
+ pairs = _chunk_file(fpath, src) if fpath.suffix in CODE_EXTS else \
+ [(f"c{i}",c) for i,c in enumerate(_sliding(src))]
+ ids,docs,metas = [],[],[]
+ for label,chunk in pairs:
+ if not chunk.strip(): continue
+ ids.append(_doc_id(chunk, rel+label)); docs.append(chunk)
+ metas.append({"source":rel,"label":label,"repo":repo,"lang":fpath.suffix.lstrip(".")})
+ if ids: col.upsert(ids=ids, documents=docs, metadatas=metas)
+
+def ingest_dir(col, directory, repo=""):
+ count = 0
+ for f in directory.rglob("*"):
+ if not f.is_file() or any(p in f.parts for p in SKIP_DIRS): continue
+ ingest_file(col, f, repo); count += 1
+ log.info("Indexed %d files from %s", count, directory); return count
+
+def ingest_pdfs(col):
+ try: import pypdf
+ except ImportError: return 0
+ n = 0
+ for pdf in PAPERS_DIR.glob("*.pdf"):
+ try:
+ text = "\n".join(p.extract_text() or "" for p in pypdf.PdfReader(str(pdf)).pages)
+ for i,chunk in enumerate(_sliding(text)):
+ col.upsert(ids=[_doc_id(chunk,str(pdf)+str(i))], documents=[chunk],
+ metadatas=[{"source":str(pdf),"label":f"p{i}","repo":"","lang":"pdf"}])
+ n += 1
+ except Exception as e: log.warning("PDF %s: %s", pdf.name, e)
+ return n
+
+def _startup():
+ for _ in range(40):
+ try:
+ r = httpx.get(f"{OLLAMA_URL}/api/tags", timeout=5)
+ if any(EMBED_MODEL in m["name"] for m in r.json().get("models",[])): break
+ except: pass
+ log.info("Waiting for embed model..."); time.sleep(5)
+ code_col = get_col("code"); papers_col = get_col("papers")
+ for d in REPOS_DIR.iterdir():
+ if d.is_dir(): ingest_dir(code_col, d, d.name)
+ ingest_pdfs(papers_col)
+ for f in PAPERS_DIR.glob("*.txt"): ingest_file(papers_col, f)
+ log.info("Startup index done")
+
+@app.on_event("startup")
+async def on_startup(): threading.Thread(target=_startup, daemon=True).start()
+
+@app.get("/health")
+async def health():
+ try:
+ cc = _chroma()
+ return {"status":"ok",
+ "code": cc.get_collection("code", embedding_function=_embed_fn()).count(),
+ "papers": cc.get_collection("papers", embedding_function=_embed_fn()).count()}
+ except Exception as e: return {"status":"error","detail":str(e)}
+
+class RepoReq(BaseModel):
+ url: str; name: str = ""; branch: str = "main"
+
+@app.post("/ingest/repo")
+async def ingest_repo(req: RepoReq):
+ name = req.name or req.url.rstrip("/").split("/")[-1].removesuffix(".git")
+ dest = REPOS_DIR / name
+ try:
+ if dest.exists(): subprocess.run(["git","pull"], cwd=dest, check=True, timeout=120)
+ else: subprocess.run(["git","clone","--depth=1","-b",req.branch,req.url,str(dest)], check=True, timeout=300)
+ except subprocess.CalledProcessError as e: raise HTTPException(400, str(e))
+ return {"status":"ok","repo":name,"files":ingest_dir(get_col("code"),dest,name)}
+
+@app.post("/ingest/papers")
+async def trigger_papers(bg: BackgroundTasks):
+ bg.add_task(ingest_pdfs, get_col("papers")); return {"status":"queued"}
+
+async def _webhook(payload):
+ repo = payload.get("repository") or {}
+ url = repo.get("clone_url") or repo.get("html_url",""); name = repo.get("name","unknown")
+ if not url: return {"status":"ignored"}
+ dest = REPOS_DIR / name
+ if dest.exists(): subprocess.run(["git","pull"], cwd=dest, timeout=120)
+ else: subprocess.run(["git","clone","--depth=1",url,str(dest)], timeout=300)
+ return {"status":"ok","repo":name,"files":ingest_dir(get_col("code"),dest,name)}
+
+@app.post("/webhook/gitea")
+async def wh_gitea(r: Request): return await _webhook(await r.json())
+@app.post("/webhook/github")
+async def wh_github(r: Request): return await _webhook(await r.json())
+
+def _ctx(query, cols):
+ parts = []
+ for cn in cols:
+ try:
+ col = get_col(cn)
+ if col.count() == 0: continue
+ res = col.query(query_texts=[query], n_results=min(TOP_K, col.count()))
+ for doc,meta in zip(res["documents"][0], res["metadatas"][0]):
+ parts.append(f"### {meta.get('source','')}:{meta.get('label','')}\n```{meta.get('lang','')}\n{doc}\n```")
+ except Exception as e: log.warning("col %s: %s", cn, e)
+ return "\n\n".join(parts)
+
+class ChatReq(BaseModel):
+ model: str = CHAT_MODEL; messages: list[dict[str,Any]]
+ stream: bool = False; collections: list[str] = ["code","papers"]
+
+@app.post("/v1/chat/completions")
+async def chat(req: ChatReq):
+ query = next((m["content"] for m in reversed(req.messages) if m.get("role")=="user"), "")
+ ctx = _ctx(query, req.collections)
+ msgs = [{"role":"system","content":f"You are a coding assistant. Use context below.\n\n## Context\n{ctx}"}] + req.messages
+ payload = {"model":req.model,"messages":msgs,"stream":req.stream}
+ if req.stream:
+ async def gen():
+ async with httpx.AsyncClient(timeout=300) as c:
+ async with c.stream("POST",f"{OLLAMA_URL}/v1/chat/completions",json=payload) as r:
+ async for chunk in r.aiter_bytes(): yield chunk
+ return StreamingResponse(gen(), media_type="text/event-stream")
+ async with httpx.AsyncClient(timeout=300) as c:
+ r = await c.post(f"{OLLAMA_URL}/v1/chat/completions", json=payload)
+ return r.json()
+
+if __name__ == "__main__":
+ import uvicorn; uvicorn.run("server:app", host="0.0.0.0", port=8001, reload=False)
+PY
+
+# =============================================================================
+section "Writing mcp_server.py"
+# =============================================================================
+write_if_new "$BASE/mcp_server.py" << 'PY'
+import json, os, re, subprocess
+from pathlib import Path
+import httpx
+from mcp.server.fastmcp import FastMCP
+
+WORKSPACE = Path(os.getenv("WORKSPACE_DIR", "/workspace"))
+REPOS_DIR = Path(os.getenv("REPOS_DIR", "/repos"))
+GITEA_URL = os.getenv("GITEA_URL", "http://gitea:3000")
+GITEA_TOKEN = os.getenv("GITEA_TOKEN", "")
+GITHUB_TOKEN = os.getenv("GITHUB_TOKEN","")
+RAG_URL = os.getenv("RAG_URL", "http://rag-server:8001")
+
+mcp = FastMCP("local-dev-tools")
+
+@mcp.tool()
+def bash(command: str, cwd: str = "") -> str:
+ """Run a shell command. Default cwd is /workspace."""
+ work = Path(cwd) if cwd else WORKSPACE
+ work.mkdir(parents=True, exist_ok=True)
+ try:
+ r = subprocess.run(command, shell=True, cwd=work, timeout=120, capture_output=True, text=True)
+ out = r.stdout + (f"\n[stderr]\n{r.stderr}" if r.stderr else "")
+ if r.returncode != 0: out += f"\n[exit {r.returncode}]"
+ return out or "(no output)"
+ except subprocess.TimeoutExpired: return "[timeout]"
+ except Exception as e: return f"[error] {e}"
+
+@mcp.tool()
+def read_file(path: str) -> str:
+ """Read a file. Absolute or relative to /workspace."""
+ p = Path(path) if Path(path).is_absolute() else WORKSPACE / path
+ if not p.exists(): return f"[not found] {p}"
+ if p.stat().st_size > 500_000: return f"[too large: {p.stat().st_size//1024}KB]"
+ return p.read_text(encoding="utf-8", errors="replace")
+
+@mcp.tool()
+def write_file(path: str, content: str) -> str:
+ """Write content to a file. Relative to /workspace."""
+ p = Path(path) if Path(path).is_absolute() else WORKSPACE / path
+ p.parent.mkdir(parents=True, exist_ok=True)
+ p.write_text(content, encoding="utf-8")
+ return f"Wrote {len(content)} chars to {p}"
+
+@mcp.tool()
+def list_files(path: str = "", pattern: str = "**/*") -> str:
+ """List files matching a glob pattern."""
+ base = Path(path) if path else WORKSPACE
+ if not base.exists(): return f"[not found] {base}"
+ files = sorted(str(f.relative_to(base)) for f in base.glob(pattern) if f.is_file())
+ return "\n".join(files[:500]) or "(empty)"
+
+@mcp.tool()
+def search_code(query: str, path: str = "", glob: str = "", case_sensitive: bool = False) -> str:
+ """Search file contents with ripgrep."""
+ base = path or str(WORKSPACE)
+ cmd = ["rg", "--line-number", "--no-heading"]
+ if not case_sensitive: cmd.append("-i")
+ if glob: cmd += ["-g", glob]
+ cmd += [query, base]
+ try:
+ r = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
+ lines = r.stdout.strip().splitlines()
+ if len(lines) > 200: lines = lines[:200] + [f"...({len(r.stdout.splitlines())-200} more)"]
+ return "\n".join(lines) or "(no matches)"
+ except FileNotFoundError:
+ r = subprocess.run(["grep","-rn",query,base], capture_output=True, text=True, timeout=30)
+ return r.stdout[:8000] or "(no matches)"
+
+@mcp.tool()
+def fetch_url(url: str, extract_text: bool = True) -> str:
+ """Fetch the content of a URL."""
+ try:
+ r = httpx.get(url, timeout=30, follow_redirects=True, headers={"User-Agent":"Mozilla/5.0"})
+ content = r.text
+ if extract_text:
+ content = re.sub(r'', '', content, flags=re.DOTALL)
+ content = re.sub(r'', '', content, flags=re.DOTALL)
+ content = re.sub(r'<[^>]+>', '', content)
+ content = re.sub(r'\n{3,}', '\n\n', content).strip()
+ return content[:20000]
+ except Exception as e: return f"[error] {e}"
+
+def _git(args, repo=""):
+ cwd = Path(repo) if repo else WORKSPACE
+ r = subprocess.run(["git"]+args, cwd=cwd, capture_output=True, text=True, timeout=60)
+ return (r.stdout + r.stderr).strip() or "(no output)"
+
+@mcp.tool()
+def git_status(repo: str = "") -> str:
+ """Show git status."""
+ return _git(["status","--short"], repo)
+
+@mcp.tool()
+def git_diff(repo: str = "", cached: bool = False) -> str:
+ """Show git diff."""
+ flag = ["--cached"] if cached else []
+ return _git(["diff","--stat"]+flag, repo) + "\n\n" + _git(["diff"]+flag, repo)
+
+@mcp.tool()
+def git_log(repo: str = "", n: int = 10) -> str:
+ """Show last n commits."""
+ return _git(["log",f"-{n}","--oneline","--decorate"], repo)
+
+@mcp.tool()
+def git_commit(message: str, repo: str = "", add_all: bool = True) -> str:
+ """Stage all and commit."""
+ if add_all: _git(["add","-A"], repo)
+ return _git(["commit","-m",message], repo)
+
+@mcp.tool()
+def git_checkout(branch: str, repo: str = "", create: bool = False) -> str:
+ """Checkout or create a branch."""
+ return _git(["checkout","-b",branch] if create else ["checkout",branch], repo)
+
+def _gitea(method, path, body=None):
+ if not GITEA_TOKEN: return {"error":"GITEA_TOKEN not set in .env"}
+ r = httpx.request(method, f"{GITEA_URL}/api/v1{path}",
+ json=body, headers={"Authorization":f"token {GITEA_TOKEN}"}, timeout=30)
+ try: return r.json()
+ except: return {"status":r.status_code,"text":r.text}
+
+@mcp.tool()
+def gitea_list_repos() -> str:
+ """List your Gitea repos."""
+ d = _gitea("GET", "/repos/search?limit=50")
+ if "error" in d: return d["error"]
+ return "\n".join(f"{r['full_name']} — {r.get('description','')}" for r in d.get("data",[]))
+
+@mcp.tool()
+def gitea_create_repo(name: str, private: bool = True, description: str = "") -> str:
+ """Create a Gitea repo."""
+ r = _gitea("POST","/user/repos",{"name":name,"private":private,"description":description,"auto_init":True,"default_branch":"main"})
+ return r.get("html_url") or str(r)
+
+@mcp.tool()
+def gitea_create_issue(repo: str, title: str, body: str = "") -> str:
+ """Create a Gitea issue (owner/repo)."""
+ r = _gitea("POST",f"/repos/{repo}/issues",{"title":title,"body":body})
+ return r.get("html_url") or str(r)
+
+@mcp.tool()
+def github_api(method: str, endpoint: str, body: str = "") -> str:
+ """Call GitHub REST API. endpoint e.g. /repos/owner/repo/issues"""
+ if not GITHUB_TOKEN: return "GITHUB_TOKEN not set in .env"
+ r = httpx.request(method.upper(), f"https://api.github.com{endpoint}",
+ json=json.loads(body) if body else None,
+ headers={"Authorization":f"Bearer {GITHUB_TOKEN}","Accept":"application/vnd.github+json"},
+ timeout=30)
+ try: return json.dumps(r.json(), indent=2)
+ except: return r.text
+
+@mcp.tool()
+def ingest_repo(url: str, name: str = "", branch: str = "main") -> str:
+ """Clone a repo and index it in RAG."""
+ r = httpx.post(f"{RAG_URL}/ingest/repo", json={"url":url,"name":name,"branch":branch}, timeout=300)
+ return r.text
+
+@mcp.tool()
+def rag_health() -> str:
+ """Check RAG server status."""
+ try: return httpx.get(f"{RAG_URL}/health", timeout=10).text
+ except Exception as e: return f"RAG unreachable: {e}"
+
+if __name__ == "__main__":
+ import uvicorn
+ uvicorn.run(mcp.get_asgi_app(), host="0.0.0.0", port=8002)
+PY
+
+# =============================================================================
+section "Requirements"
+# =============================================================================
+cat > "$BASE/requirements.txt" << 'REQ'
+fastapi
+uvicorn[standard]
+httpx
+pydantic
+chromadb
+pypdf
+python-multipart
+REQ
+
+cat > "$BASE/mcp_requirements.txt" << 'REQ'
+mcp[cli]
+fastapi
+uvicorn[standard]
+httpx
+REQ
+ok "requirements.txt + mcp_requirements.txt"
+
+# =============================================================================
+section "SearXNG Config"
+# =============================================================================
+write_if_new "$BASE/searxng/settings.yml" << SEARXNG
+use_default_settings: true
+general:
+ instance_name: "Local Search"
+server:
+ secret_key: "$(openssl rand -hex 32)"
+ limiter: false
+search:
+ safe_search: 0
+ default_lang: "en"
+ formats: [html, json]
+SEARXNG
+
+# =============================================================================
+section ".env (tokens — never overwritten)"
+# =============================================================================
+if [[ ! -f "$BASE/.env" ]]; then
+ cat > "$BASE/.env" << ENV
+# Local AI Stack — edit to add your API tokens
+GITEA_TOKEN=your-gitea-token-here
+GITHUB_TOKEN=your-github-token-here
+ENV
+ ok "Created .env"
+else
+ info "Kept .env"
+fi
+
+# =============================================================================
+section "Docker Compose"
+# =============================================================================
+cat > "$BASE/docker-compose.yml" << COMPOSE
+# Local AI Stack — 2026-03-20
+# GPU: OLLAMA_NUM_GPU=999 uses all available VRAM automatically (V100/RTX/any)
+# Context: OLLAMA_NUM_CTX= set by detected VRAM (GB)
+
+services:
+
+ ollama:
+ image: ollama/ollama:latest
+ container_name: ollama
+ restart: unless-stopped
+ ports: ["0.0.0.0:11434:11434"]
+ volumes: [ollama-models:/root/.ollama]
+ environment:
+ - OLLAMA_NUM_GPU=999
+ - OLLAMA_NUM_CTX=
+ - OLLAMA_KEEP_ALIVE=24h
+ - OLLAMA_MAX_LOADED_MODELS=1
+ deploy:
+ resources:
+ reservations:
+ devices:
+ - driver: nvidia
+ count: all
+ capabilities: [gpu]
+ healthcheck:
+ test: ["CMD","ollama","list"]
+ interval: 30s; timeout: 10s; retries: 5
+
+ open-webui:
+ image: ghcr.io/open-webui/open-webui:main
+ container_name: open-webui
+ restart: unless-stopped
+ ports: ["0.0.0.0:3000:8080"]
+ volumes: [open-webui-data:/app/backend/data]
+ environment:
+ - OLLAMA_BASE_URL=http://ollama:11434
+ - OPENAI_API_BASE_URL=http://rag-server:8001/v1
+ - OPENAI_API_KEY=local-rag
+ - ENABLE_OPENAI_API=true
+ - ENABLE_RAG_WEB_SEARCH=true
+ - RAG_WEB_SEARCH_ENGINE=searxng
+ - SEARXNG_QUERY_URL=http://searxng:8080/search?q=&format=json
+ - WEBUI_AUTH=false
+ depends_on:
+ ollama: {condition: service_healthy}
+
+ chromadb:
+ image: chromadb/chroma:latest
+ container_name: chromadb
+ restart: unless-stopped
+ ports: ["0.0.0.0:8000:8000"]
+ volumes: [$BASE/index:/chroma/chroma]
+ environment:
+ - IS_PERSISTENT=TRUE
+ - ANONYMIZED_TELEMETRY=FALSE
+ healthcheck:
+ test: ["CMD-SHELL","wget -qO- http://localhost:8000/api/v2/heartbeat || exit 1"]
+ interval: 15s; timeout: 5s; retries: 5
+
+ rag-server:
+ image: python:3.11-slim
+ container_name: rag-server
+ restart: unless-stopped
+ ports: ["0.0.0.0:8001:8001"]
+ volumes:
+ - $BASE/papers:/papers
+ - $BASE/repos:/repos
+ - $BASE/index:/index
+ - $BASE/server.py:/app/server.py
+ - $BASE/requirements.txt:/app/requirements.txt
+ working_dir: /app
+ environment:
+ - OLLAMA_URL=http://ollama:11434
+ - CHROMA_URL=http://chromadb:8000
+ - EMBED_MODEL=nomic-embed-text
+ - CHAT_MODEL=
+ command: >
+ bash -c "apt-get update -qq && apt-get install -y --no-install-recommends git &&
+ pip install --no-cache-dir -r requirements.txt &&
+ uvicorn server:app --host 0.0.0.0 --port 8001"
+ depends_on:
+ chromadb: {condition: service_healthy}
+ ollama: {condition: service_healthy}
+
+ mcp-server:
+ image: python:3.11-slim
+ container_name: mcp-server
+ restart: unless-stopped
+ ports: ["0.0.0.0:8002:8002"]
+ volumes:
+ - $BASE/workspace:/workspace
+ - $BASE/repos:/repos
+ - $BASE/mcp_server.py:/app/mcp_server.py
+ - $BASE/mcp_requirements.txt:/app/mcp_requirements.txt
+ working_dir: /app
+ env_file: $BASE/.env
+ environment:
+ - WORKSPACE_DIR=/workspace
+ - REPOS_DIR=/repos
+ - GITEA_URL=http://gitea:3000
+ - RAG_URL=http://rag-server:8001
+ command: >
+ bash -c "apt-get update -qq && apt-get install -y --no-install-recommends git ripgrep &&
+ pip install --no-cache-dir -r mcp_requirements.txt &&
+ python mcp_server.py"
+ depends_on: [rag-server]
+
+ searxng:
+ image: searxng/searxng:latest
+ container_name: searxng
+ restart: unless-stopped
+ ports: ["0.0.0.0:8888:8080"]
+ volumes: [$BASE/searxng:/etc/searxng]
+ cap_drop: [ALL]
+ cap_add: [CHOWN, SETGID, SETUID]
+
+ kiwix:
+ image: ghcr.io/kiwix/kiwix-serve:latest
+ container_name: kiwix
+ restart: unless-stopped
+ ports: ["0.0.0.0:8181:8080"]
+ volumes: [$BASE/kiwix:/data]
+ command: "*.zim"
+
+ gitea:
+ image: gitea/gitea:latest
+ container_name: gitea
+ restart: unless-stopped
+ ports: ["0.0.0.0:3001:3000","0.0.0.0:2222:22"]
+ volumes:
+ - $BASE/gitea:/data
+ - /etc/timezone:/etc/timezone:ro
+ - /etc/localtime:/etc/localtime:ro
+ environment:
+ - USER_UID=1000
+ - USER_GID=1000
+ - GITEA__database__DB_TYPE=sqlite3
+ - GITEA__database__PATH=/data/gitea/gitea.db
+ - GITEA__webhook__ALLOWED_HOST_LIST=rag-server,mcp-server
+
+ invokeai:
+ image: ghcr.io/invoke-ai/invokeai:latest
+ container_name: invokeai
+ restart: unless-stopped
+ ports: ["0.0.0.0:9090:9090"]
+ volumes:
+ - invokeai-models:/invokeai/models
+ - $BASE/invokeai-outputs:/invokeai/outputs
+ - $BASE/invokeai-data:/invokeai/databases
+ environment:
+ - INVOKEAI_HOST=0.0.0.0
+ - INVOKEAI_PORT=9090
+ - INVOKEAI_PRECISION=float16
+ deploy:
+ resources:
+ reservations:
+ devices:
+ - driver: nvidia
+ count: all
+ capabilities: [gpu]
+
+ portainer:
+ image: portainer/portainer-ce:latest
+ container_name: portainer
+ restart: unless-stopped
+ ports: ["0.0.0.0:9000:9000","0.0.0.0:9443:9443"]
+ volumes:
+ - /var/run/docker.sock:/var/run/docker.sock
+ - $BASE/portainer-data:/data
+
+volumes:
+ ollama-models:
+ open-webui-data:
+ invokeai-models:
+COMPOSE
+ok "docker-compose.yml"
+
+# =============================================================================
+section "Firewall"
+# =============================================================================
+if command -v ufw &>/dev/null && [[ ! -f "$BASE/.ufw-done" ]] || $FORCE; then
+ read -rp " LAN subnet [192.168.1.0/24]: " LAN; LAN="${LAN:-192.168.1.0/24}"
+ [[ "$LAN" =~ /[0-9]+$ ]] || LAN="${LAN}/24"
+ for pc in "3000:Open WebUI" "11434:Ollama" "8001:RAG" "8002:MCP" \
+ "8000:ChromaDB" "8888:SearXNG" "8181:Kiwix" \
+ "3001:Gitea" "2222:Gitea SSH" "9090:InvokeAI" \
+ "9000:Portainer" "9443:Portainer S"; do
+ sudo ufw allow from "$LAN" to any port "${pc%%:*}" proto tcp comment "${pc##*:}" >/dev/null
+ done
+ sudo ufw reload >/dev/null; ok "UFW rules set for $LAN"; touch "$BASE/.ufw-done"
+fi
+
+# =============================================================================
+section "Helper Scripts"
+# =============================================================================
+cat > "$BASE/start.sh" << STARTSH
+#!/bin/bash
+cd "$BASE"
+docker compose pull --quiet 2>/dev/null
+docker compose up -d
+echo ""
+echo " Open WebUI → http://$LOCAL_IP:3000"
+echo " InvokeAI → http://$LOCAL_IP:9090"
+echo " SearXNG → http://$LOCAL_IP:8888"
+echo " Kiwix → http://$LOCAL_IP:8181 (run kiwix_download.sh first)"
+echo " Gitea → http://$LOCAL_IP:3001"
+echo " RAG → http://$LOCAL_IP:8001/health"
+echo " MCP SSE → http://$LOCAL_IP:8002/sse"
+echo " Portainer → https://$LOCAL_IP:9443"
+echo ""
+echo " claude mcp add local http://$LOCAL_IP:8002/sse"
+STARTSH
+chmod +x "$BASE/start.sh"
+
+cat > "$BASE/stop.sh" << STOPSH
+#!/bin/bash
+cd "$BASE" && docker compose down
+STOPSH
+chmod +x "$BASE/stop.sh"
+
+cat > "$BASE/status.sh" << 'STATUSSH'
+#!/bin/bash
+echo "=== GPU ===" && nvidia-smi --query-gpu=name,memory.used,memory.total \
+ --format=csv,noheader 2>/dev/null || echo "(no GPU)"
+echo "" && echo "=== Containers ===" && docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
+echo "" && echo "=== Ollama ===" && docker exec ollama ollama ps 2>/dev/null || echo "(not running)"
+echo "" && echo "=== RAG ===" && curl -s http://localhost:8001/health | python3 -m json.tool 2>/dev/null
+STATUSSH
+chmod +x "$BASE/status.sh"
+
+cat > "$BASE/pull-models.sh" << PULLSH
+#!/bin/bash
+echo "Waiting for Ollama..."
+until docker exec ollama ollama list &>/dev/null; do sleep 3; done
+
+echo "Embed model (RAG — required)..."
+docker exec ollama ollama pull $EMBED_MODEL
+
+echo "Fast chat model..."
+docker exec ollama ollama pull qwen2.5:7b
+
+echo "Smart chat model..."
+docker exec ollama ollama pull $CHAT_MODEL
+
+echo "Code model..."
+docker exec ollama ollama pull $CODE_MODEL
+
+echo "Reasoning model (DeepSeek-R1 14B — optional)..."
+read -rp "Pull DeepSeek-R1:14b for planning/reasoning? [y/N]: " DR
+[[ "\${DR,,}" == "y" ]] && docker exec ollama ollama pull deepseek-r1:14b
+
+echo "" && docker exec ollama ollama list
+PULLSH
+chmod +x "$BASE/pull-models.sh"
+ok "start.sh stop.sh status.sh pull-models.sh"
+
+# =============================================================================
+section "Systemd"
+# =============================================================================
+sudo tee /etc/systemd/system/local-ai.service >/dev/null << SYSD
+[Unit]
+Description=Local AI Stack
+After=docker.service network-online.target
+Requires=docker.service
+
+[Service]
+Type=oneshot
+RemainAfterExit=yes
+User=$USER
+WorkingDirectory=$BASE
+ExecStart=/bin/bash $BASE/start.sh
+ExecStop=/bin/bash $BASE/stop.sh
+TimeoutStartSec=300
+
+[Install]
+WantedBy=multi-user.target
+SYSD
+sudo systemctl daemon-reload && sudo systemctl enable local-ai.service
+ok "Systemd: local-ai.service enabled"
+
+# =============================================================================
+section "Starting Stack"
+# =============================================================================
+cd "$BASE"
+info "Pulling images..."
+docker compose pull --quiet
+docker compose up -d
+ok "Stack running"
+
+if ! $IS_UPDATE && ! $NO_PULL; then
+ echo ""
+ read -rp "Pull Ollama models now? (~15-30 min) [Y/n]: " DO_PULL
+ [[ "${DO_PULL,,}" != "n" ]] && bash "$BASE/pull-models.sh"
+fi
+
+# =============================================================================
+echo ""
+echo -e "${GREEN}${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
+echo -e "${GREEN}${BOLD} Done! GPU: ${VRAM_GB}GB → $TIER${NC}"
+echo -e "${GREEN}${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
+echo ""
+echo -e " ${CYAN}Open WebUI${NC} → http://$LOCAL_IP:3000"
+echo -e " ${CYAN}InvokeAI${NC} → http://$LOCAL_IP:9090"
+echo -e " ${CYAN}SearXNG${NC} → http://$LOCAL_IP:8888"
+echo -e " ${CYAN}Kiwix${NC} → http://$LOCAL_IP:8181"
+echo -e " ${CYAN}Gitea${NC} → http://$LOCAL_IP:3001"
+echo -e " ${CYAN}RAG${NC} → http://$LOCAL_IP:8001/health"
+echo -e " ${CYAN}MCP SSE${NC} → http://$LOCAL_IP:8002/sse"
+echo -e " ${CYAN}Portainer${NC} → https://$LOCAL_IP:9443"
+echo ""
+echo -e " ${YELLOW}Add MCP to Claude Code:${NC}"
+echo " claude mcp add local http://$LOCAL_IP:8002/sse"
+echo ""
+echo -e " ${YELLOW}Tokens:${NC} $BASE/.env"
+echo -e " ${YELLOW}PDFs:${NC} $BASE/papers/"
+echo -e " ${YELLOW}Workspace:${NC} $BASE/workspace/"
+echo -e " ${YELLOW}ZIMs:${NC} ./kiwix_download.sh"
+echo ""
+echo -e " ${YELLOW}Save Claude usage:${NC} use local 14B for boilerplate, docs,"
+echo " simple fixes. Use Claude Sonnet 4.6 for hard bugs,"
+echo " multi-file refactoring, architecture decisions."
+echo ""
+LOCAL_AI_SETUP_EOF
+ chmod +x "$AI_SETUP_SCRIPT"
+ sudo -u "$ACTUAL_USER" bash "$AI_SETUP_SCRIPT"
+ AI_EXIT=$?
+ rm -f "$AI_SETUP_SCRIPT"
+
+ # --- Patch docker-compose.yml for alternate drive --------------------
+ if [ -n "$AI_EXTRA_DRIVE" ] && [ "$AI_EXIT" -eq 0 ]; then
+ COMPOSE="$ACTUAL_HOME/docker/ai-stack/docker-compose.yml"
+ if [ -f "$COMPOSE" ]; then
+ echo ""
+ echo "Patching docker-compose.yml for alternate storage..."
+ cp "$COMPOSE" "$COMPOSE.bak"
+ MODEL_PATH="$AI_EXTRA_DRIVE/$AI_MODEL_FOLDER"
+ ZIM_PATH="$AI_EXTRA_DRIVE/$AI_ZIM_FOLDER"
+ AI_BASE="$ACTUAL_HOME/docker/ai-stack"
+
+ # Ollama: swap Docker-managed volume for host path
+ sed -i "s|ollama-models:/root/.ollama|${MODEL_PATH}:/root/.ollama|g" "$COMPOSE"
+ sed -i '/^ ollama-models:$/d' "$COMPOSE"
+
+ # Kiwix: add external ZIM mount if single-line volume style
+ if grep -q "\[$AI_BASE/kiwix:/data\]" "$COMPOSE" 2>/dev/null; then
+ sed -i "s|\[$AI_BASE/kiwix:/data\]|volumes:\n - $AI_BASE/kiwix:/data\n - $ZIM_PATH:/data/external|" "$COMPOSE"
+ fi
+ chown "$ACTUAL_USER:$ACTUAL_USER" "$COMPOSE"
+
+ echo "✓ Backup saved → docker-compose.yml.bak"
+ echo ""
+ echo " Ollama models → $MODEL_PATH"
+ echo " Kiwix ZIMs → $ZIM_PATH (served at /data/external in Kiwix)"
+ echo ""
+ echo "Restart to apply:"
+ echo " cd ~/docker/ai-stack && docker compose down && docker compose up -d"
+ fi
+ fi
+
+ echo ""
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ [ "$AI_EXIT" -eq 0 ] \
+ && echo "✓ Local AI stack installed successfully" \
+ || echo "⚠ Installer exited with code $AI_EXIT — check output above"
+ echo ""
+ read -p "Press Enter to continue..." _dummy
+ return $AI_EXIT
+}
+
+# ============================================================================
+# KIWIX ZIM DOWNLOADER
+# ============================================================================
+
+download_zims() {
+ echo ""
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo "KIWIX ZIM DOWNLOAD"
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo ""
+ echo "Downloads offline wiki files (background, resumable with -c flag):"
+ echo " • Wikipedia (no images) ~46GB • Wikisource ~4GB"
+ echo " • Project Gutenberg ~60GB • Stack Overflow ~3GB"
+ echo " • TED Talks ~5GB • iFixit ~2GB"
+ echo " • + 12 more collections (Wiktionary, Wikibooks, DevDocs, etc.)"
+ echo ""
+ echo " Total estimate: ~130GB — ensure you have enough disk space!"
+ echo ""
+
+ # Ensure wget is available
+ if ! command -v wget &>/dev/null; then
+ echo "Installing wget..."
+ apt-get install -y wget 2>/dev/null || true
+ fi
+
+ # Optional custom download destination
+ echo "Default ZIM storage: $ACTUAL_HOME/docker/ai-stack/kiwix/"
+ echo ""
+ echo "Currently mounted drives:"
+ df -h --output=target,size,avail 2>/dev/null \
+ | tail -n +2 \
+ | grep -v -E '^(/sys|/proc|/dev|/run|/snap|/boot|efi|tmpfs|udev)' \
+ | grep -v ' /$' \
+ | awk '{printf " %-38s %-8s %s free\n", $1, $2, $3}' \
+ | head -10
+ echo ""
+
+ read -p "Download ZIMs to a different location? (y/n) [n]: " _CUSTOM_ZIM
+ if [ "$_CUSTOM_ZIM" = "y" ] || [ "$_CUSTOM_ZIM" = "Y" ]; then
+ read -p " ZIM download path: " _ZIM_PATH
+ _ZIM_PATH="${_ZIM_PATH/#\~/$ACTUAL_HOME}"
+ if [ -n "$_ZIM_PATH" ]; then
+ mkdir -p "$_ZIM_PATH"
+ chown "$ACTUAL_USER:$ACTUAL_USER" "$_ZIM_PATH" 2>/dev/null || true
+ export KIWIX_DIR="$_ZIM_PATH"
+ echo " ✓ ZIMs will download to: $_ZIM_PATH"
+ fi
+ fi
+
+ echo ""
+ echo "Running ZIM downloader as $ACTUAL_USER ..."
+ echo ""
+ ZIM_SCRIPT=$(mktemp /tmp/kiwix_download.XXXXXX.sh)
+ cat > "$ZIM_SCRIPT" << 'KIWIX_DOWNLOAD_EOF'
+#!/bin/bash
+# =============================================================================
+# Kiwix ZIM Download Script
+# Downloads all ZIM files to ~/docker/ai-stack/kiwix/
+# Finds latest version of each file automatically
+# Usage: bash kiwix-download.sh
+# =============================================================================
+
+KIWIX_DIR="$HOME/docker/ai-stack/kiwix"
+MIRROR="https://ftp.fau.de/kiwix/zim"
+MIRROR2="https://download.kiwix.org/zim" # fallback for files missing on fau.de
+LOG="$HOME/docker/ai-stack/logs/kiwix-download.log"
+mkdir -p "$KIWIX_DIR" "$(dirname "$LOG")"
+
+G='\033[0;32m'; Y='\033[1;33m'; C='\033[0;36m'; N='\033[0m'
+ok() { echo -e "${G}[OK]${N} $1" | tee -a "$LOG"; }
+inf() { echo -e "${C}[..]${N} $1" | tee -a "$LOG"; }
+wrn() { echo -e "${Y}[!!]${N} $1" | tee -a "$LOG"; }
+
+# Find latest version of a ZIM file from mirror
+# Usage: latest_zim "base_url" "pattern"
+latest_zim() {
+ local base_url="$1"
+ local pattern="$2"
+ curl -s "$base_url/" | \
+ grep -oP "${pattern}_[\d-]+\.zim" | \
+ sort -u | tail -1
+}
+
+# Download a ZIM file if not already present
+# Usage: download_zim "category" "filename" "description" "size" ["mirror"]
+download_zim() {
+ local category="$1"
+ local filename="$2"
+ local description="$3"
+ local size="$4"
+ local mirror="${5:-$MIRROR}"
+ local url="$mirror/$category/$filename"
+ local dest="$KIWIX_DIR/$filename"
+
+ if [[ -f "$dest" ]]; then
+ ok "$description already downloaded — skipping"
+ return
+ fi
+
+ inf "Queuing: $description (~$size)"
+ inf " URL: $url"
+
+ nohup wget -c "$url" -O "$dest" \
+ >> "$LOG" 2>&1 &
+ echo $! >> "$KIWIX_DIR/.download_pids"
+ ok "Started download PID $! — $description"
+}
+
+echo "" | tee -a "$LOG"
+echo "=== Kiwix Download Started: $(date) ===" | tee -a "$LOG"
+echo "" | tee -a "$LOG"
+echo -e "${C}Finding latest versions...${N}"
+echo ""
+
+# ── Find latest filenames ──────────────────────────────────────────────────
+inf "Checking Wikipedia..."
+WIKI=$(latest_zim "$MIRROR/wikipedia" "wikipedia_en_all_nopic")
+
+inf "Checking Wiktionary..."
+WIKT=$(latest_zim "$MIRROR/wiktionary" "wiktionary_en_all_nopic")
+
+inf "Checking Wikiquote..."
+WIKQ=$(latest_zim "$MIRROR/wikiquote" "wikiquote_en_all_nopic")
+
+inf "Checking Wikisource..."
+WIKS=$(latest_zim "$MIRROR/wikisource" "wikisource_en_all_nopic")
+
+inf "Checking Wikibooks..."
+WIKB=$(latest_zim "$MIRROR/wikibooks" "wikibooks_en_all_nopic")
+
+inf "Checking Wikivoyage..."
+WIKV=$(latest_zim "$MIRROR/wikivoyage" "wikivoyage_en_all_nopic")
+
+inf "Checking Wikiversity..."
+WIKUNI=$(latest_zim "$MIRROR/wikiversity" "wikiversity_en_all_nopic")
+
+inf "Checking WikiNews..."
+WIKNEWS=$(latest_zim "$MIRROR/wikinews" "wikinews_en_all_nopic")
+
+inf "Checking Vikidia (kids K-8)..."
+VIKIDIA=$(latest_zim "$MIRROR/vikidia" "vikidia_en_all_nopic")
+
+# Stack Overflow: domain-style filename in stack_exchange/
+inf "Checking Stack Overflow..."
+SO=$(latest_zim "$MIRROR/stack_exchange" "stackoverflow.com_en_all")
+
+# Arch Wiki: _maxi is part of the base name, not the date stamp
+inf "Checking Arch Wiki..."
+ARCH=$(latest_zim "$MIRROR/other" "archlinux_en_all_maxi")
+
+# TED: lives in ted/ folder, mul_youth variant
+inf "Checking TED Talks..."
+TED=$(latest_zim "$MIRROR/ted" "ted_mul_youth")
+
+# PhET: own folder
+inf "Checking PhET Simulations..."
+PHET=$(latest_zim "$MIRROR/phet" "phet_en_all")
+
+# DevDocs: variant is zig not all
+inf "Checking DevDocs..."
+DEVDOCS=$(latest_zim "$MIRROR/devdocs" "devdocs_en_zig")
+
+inf "Checking FreeCodeCamp..."
+FCC=$(latest_zim "$MIRROR/freecodecamp" "freecodecamp_en_all")
+
+inf "Checking iFixit..."
+IFIX=$(latest_zim "$MIRROR/ifixit" "ifixit_en_all")
+
+# LibreTexts: own folder, workforce variant (no _all on this mirror)
+inf "Checking LibreTexts..."
+LIBRE=$(latest_zim "$MIRROR/libretexts" "libretexts.org_en_workforce")
+
+# Gutenberg: en_all exists on download.kiwix.org, not ftp.fau.de
+inf "Checking Project Gutenberg..."
+GUT=$(latest_zim "$MIRROR2/gutenberg" "gutenberg_en_all")
+
+echo ""
+echo -e "${C}═══════════════════════════════════════════════════${N}"
+echo -e "${C} Download Queue${N}"
+echo -e "${C}═══════════════════════════════════════════════════${N}"
+echo ""
+echo " Wikipedia (no images) ${WIKI:-NOT FOUND} ~46GB"
+echo " Wiktionary ${WIKT:-NOT FOUND} ~2GB"
+echo " Wikiquote ${WIKQ:-NOT FOUND} ~300MB"
+echo " Wikisource ${WIKS:-NOT FOUND} ~4GB"
+echo " Wikibooks ${WIKB:-NOT FOUND} ~500MB"
+echo " Wikivoyage ${WIKV:-NOT FOUND} ~200MB"
+echo " Wikiversity ${WIKUNI:-NOT FOUND} ~500MB"
+echo " WikiNews ${WIKNEWS:-NOT FOUND} ~300MB"
+echo " Vikidia (kids K-8) ${VIKIDIA:-NOT FOUND} ~66MB"
+echo " Stack Overflow ${SO:-NOT FOUND} ~3GB"
+echo " Arch Linux Wiki ${ARCH:-NOT FOUND} ~30MB"
+echo " TED Talks ${TED:-NOT FOUND} ~5GB"
+echo " PhET Simulations ${PHET:-NOT FOUND} ~500MB"
+echo " DevDocs ${DEVDOCS:-NOT FOUND} ~1GB"
+echo " FreeCodeCamp ${FCC:-NOT FOUND} ~small"
+echo " iFixit (repair guides) ${IFIX:-NOT FOUND} ~2GB"
+echo " LibreTexts (textbooks) ${LIBRE:-NOT FOUND} ~varies"
+echo " Project Gutenberg ${GUT:-NOT FOUND} ~60GB"
+echo ""
+echo -e "${Y} Total estimate: ~130GB — make sure you have space!${N}"
+echo ""
+
+df -h "$KIWIX_DIR" | tail -1 | awk '{print " Available disk space: " $4}'
+echo ""
+
+read -rp " Proceed with all downloads? (Y/n): " CONFIRM
+[[ "${CONFIRM,,}" == "n" ]] && exit 0
+
+# ── Start downloads ────────────────────────────────────────────────────────
+echo ""
+rm -f "$KIWIX_DIR/.download_pids"
+
+[[ -n "$WIKI" ]] && download_zim "wikipedia" "$WIKI" "Wikipedia (no images)" "46GB"
+[[ -n "$WIKT" ]] && download_zim "wiktionary" "$WIKT" "Wiktionary" "2GB"
+[[ -n "$WIKQ" ]] && download_zim "wikiquote" "$WIKQ" "Wikiquote" "300MB"
+[[ -n "$WIKS" ]] && download_zim "wikisource" "$WIKS" "Wikisource" "4GB"
+[[ -n "$WIKB" ]] && download_zim "wikibooks" "$WIKB" "Wikibooks" "500MB"
+[[ -n "$WIKV" ]] && download_zim "wikivoyage" "$WIKV" "Wikivoyage" "200MB"
+[[ -n "$WIKUNI" ]] && download_zim "wikiversity" "$WIKUNI" "Wikiversity" "500MB"
+[[ -n "$WIKNEWS" ]] && download_zim "wikinews" "$WIKNEWS" "WikiNews" "300MB"
+[[ -n "$VIKIDIA" ]] && download_zim "vikidia" "$VIKIDIA" "Vikidia (kids K-8)" "66MB"
+[[ -n "$SO" ]] && download_zim "stack_exchange" "$SO" "Stack Overflow" "3GB"
+[[ -n "$ARCH" ]] && download_zim "other" "$ARCH" "Arch Linux Wiki" "30MB"
+[[ -n "$TED" ]] && download_zim "ted" "$TED" "TED Talks" "5GB"
+[[ -n "$PHET" ]] && download_zim "phet" "$PHET" "PhET Simulations" "500MB"
+[[ -n "$DEVDOCS" ]] && download_zim "devdocs" "$DEVDOCS" "DevDocs" "1GB"
+[[ -n "$FCC" ]] && download_zim "freecodecamp" "$FCC" "FreeCodeCamp" "small"
+[[ -n "$IFIX" ]] && download_zim "ifixit" "$IFIX" "iFixit (repair guides)" "2GB"
+[[ -n "$LIBRE" ]] && download_zim "libretexts" "$LIBRE" "LibreTexts (textbooks)" "varies"
+[[ -n "$GUT" ]] && download_zim "gutenberg" "$GUT" "Project Gutenberg" "60GB" "$MIRROR2"
+
+echo ""
+echo -e "${G}════════════════════════════════════════════════════${N}"
+echo -e "${G} All downloads started in background${N}"
+echo -e "${G}════════════════════════════════════════════════════${N}"
+echo ""
+echo " Monitor progress:"
+echo " tail -f $LOG"
+echo ""
+echo " Check file sizes growing:"
+echo " watch -n 60 'ls -lh $KIWIX_DIR/'"
+echo ""
+echo " Check active downloads:"
+echo " jobs -l"
+echo " ps aux | grep wget"
+echo ""
+echo " Once all downloads complete, restart Kiwix:"
+echo " cd ~/docker/ai-stack"
+echo " docker compose up -d kiwix"
+echo ""
+echo -e "${Y} NOTE: Downloads resume automatically if interrupted (-c flag)${N}"
+KIWIX_DOWNLOAD_EOF
+ chmod +x "$ZIM_SCRIPT"
+ sudo -u "$ACTUAL_USER" -E bash "$ZIM_SCRIPT"
+ rm -f "$ZIM_SCRIPT"
+ echo ""
+ read -p "Press Enter to continue..." _dummy
+}
+
+# ============================================================================
+# MODE SELECTOR
+# ============================================================================
+
+# Check for restore mode flag
+if [ "$RESTORE_MODE" = true ]; then
+ run_disaster_recovery
+ exit $?
+fi
+
+# Interactive mode selector (if not unattended)
+if [ "$UNATTENDED" != true ]; then
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo "INSTALLATION MODE"
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo ""
+ echo " [N] Normal install - Fresh install or modify existing services"
+ echo " [M] Migration - Import existing Docker containers"
+ echo " [R] Disaster recovery - Restore from Kopia backup"
+ echo " [A] Local AI stack - Ollama, Open WebUI, RAG, MCP, Kiwix..."
+ echo " [Z] Download ZIMs - Offline Wikipedia, Stack Overflow & more"
+ echo ""
+ read -p "Select mode (N/M/R/A/Z) [N]: " MODE_SELECT
+
+ case "${MODE_SELECT^^}" in
+ R)
+ run_disaster_recovery
+ exit $?
+ ;;
+ M)
+ run_migration
+ # If migration returns 0 and INSTALL_MODE is post-migration, continue
+ if [ $? -ne 0 ]; then
+ exit 1
+ fi
+ if [ "$INSTALL_MODE" != "post-migration" ]; then
+ exit 0
+ fi
+ echo ""
+ echo "Continuing with additional service installation..."
+ echo ""
+ ;;
+ A)
+ install_ai_stack
+ exit $?
+ ;;
+ Z)
+ download_zims
+ exit $?
+ ;;
+ *)
+ INSTALL_MODE="normal"
+ ;;
+ esac
+ echo ""
+fi
+
+# ============================================================================
+# DRIVE SETUP (Runs before everything else)
+# ============================================================================
+
+setup_drives() {
+ echo ""
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo "DRIVE SETUP"
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo ""
+ echo "This script uses ~/drives/ for data storage:"
+ echo " ~/drives/primary - Main data drive (media, photos, etc.)"
+ echo " ~/drives/backup1 - Backup drive(s)"
+ echo ""
+
+ # Create base drives directory
+ mkdir -p "$ACTUAL_HOME/drives"
+ chown "$ACTUAL_USER:$ACTUAL_USER" "$ACTUAL_HOME/drives"
+
+ # Check for existing mounts
+ if mount | grep -q "$ACTUAL_HOME/drives/"; then
+ echo "Existing mounted drives:"
+ df -h | grep "$ACTUAL_HOME/drives" | awk '{print " " $6 " (" $2 " total, " $4 " free)"}'
+ echo ""
+ prompt_yn "Configure additional drives? (y/n):" "n" SETUP_DRIVES
+ else
+ echo "No drives currently mounted in ~/drives/"
+ echo ""
+ prompt_yn "Set up drives now? (y/n):" "y" SETUP_DRIVES
+ fi
+
+ if [ "$SETUP_DRIVES" != "y" ] && [ "$SETUP_DRIVES" != "Y" ]; then
+ echo "Skipping drive setup. You can run this script again later."
+ return 0
+ fi
+
+ echo ""
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo "AVAILABLE BLOCK DEVICES"
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo ""
+ lsblk -o NAME,SIZE,TYPE,FSTYPE,MOUNTPOINT,LABEL
+ echo ""
+
+ # Detect drives without partition tables (new drives)
+ echo "Checking for new/unpartitioned drives..."
+ NEW_DRIVES=()
+ for disk in /dev/sd? /dev/nvme?n?; do
+ [ -b "$disk" ] || continue
+ # Check if disk has no partitions
+ if ! lsblk -n "$disk" | grep -q "part"; then
+ # Check if it has no partition table
+ if ! blkid "$disk" &>/dev/null; then
+ size=$(lsblk -n -d -o SIZE "$disk" 2>/dev/null)
+ NEW_DRIVES+=("$disk ($size)")
+ fi
+ fi
+ done
+
+ if [ ${#NEW_DRIVES[@]} -gt 0 ]; then
+ echo ""
+ echo "⚠️ Found unpartitioned drives (no filesystem):"
+ for drive in "${NEW_DRIVES[@]}"; do
+ echo " • $drive"
+ done
+ echo ""
+ prompt_yn "Would you like to partition and format any of these? (y/n):" "n" FORMAT_DRIVES
+
+ if [ "$FORMAT_DRIVES" = "y" ] || [ "$FORMAT_DRIVES" = "Y" ]; then
+ for drive_info in "${NEW_DRIVES[@]}"; do
+ drive=$(echo "$drive_info" | cut -d' ' -f1)
+ echo ""
+ prompt_yn "Format $drive_info as ext4? (ALL DATA WILL BE ERASED) (y/n):" "n" FORMAT_THIS
+
+ if [ "$FORMAT_THIS" = "y" ] || [ "$FORMAT_THIS" = "Y" ]; then
+ echo " Creating partition table on $drive..."
+ parted -s "$drive" mklabel gpt
+ parted -s "$drive" mkpart primary ext4 0% 100%
+
+ # Wait for partition to appear
+ sleep 2
+
+ # Format the partition
+ part="${drive}1"
+ [ -b "${drive}p1" ] && part="${drive}p1" # nvme drives
+
+ if [ -b "$part" ]; then
+ echo " Formatting $part as ext4..."
+ mkfs.ext4 -F "$part"
+ echo " ✓ Formatted $part"
+ else
+ echo " ⚠ Could not find partition after creation"
+ fi
+ fi
+ done
+ echo ""
+ echo "Updated block devices:"
+ lsblk -o NAME,SIZE,TYPE,FSTYPE,MOUNTPOINT,LABEL
+ echo ""
+ fi
+ fi
+
+ # Configure mount points
+ echo ""
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo "CONFIGURE MOUNT POINTS"
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo ""
+
+ # Primary drive
+ echo "PRIMARY DRIVE (for media, photos, docker data):"
+ prompt_text " Mount point name [primary]:" "primary" PRIMARY_NAME
+ PRIMARY_NAME="${PRIMARY_NAME:-primary}"
+ mkdir -p "$ACTUAL_HOME/drives/$PRIMARY_NAME"
+
+ # Check if already mounted
+ if mount | grep -q "$ACTUAL_HOME/drives/$PRIMARY_NAME"; then
+ echo " ✓ Already mounted"
+ else
+ echo " Enter device (e.g., /dev/sdb1) or leave blank to skip:"
+ read -p " Device for $PRIMARY_NAME: " PRIMARY_DEV
+
+ if [ -n "$PRIMARY_DEV" ] && [ -b "$PRIMARY_DEV" ]; then
+ # Add to fstab
+ PRIMARY_UUID=$(blkid -s UUID -o value "$PRIMARY_DEV" 2>/dev/null)
+ if [ -n "$PRIMARY_UUID" ]; then
+ # Check if not already in fstab
+ if ! grep -q "$PRIMARY_UUID" /etc/fstab 2>/dev/null; then
+ cp /etc/fstab /etc/fstab.backup-$(date +%Y%m%d-%H%M%S) 2>/dev/null || true
+ echo "UUID=$PRIMARY_UUID $ACTUAL_HOME/drives/$PRIMARY_NAME auto defaults,nofail 0 2" >> /etc/fstab
+ echo " ✓ Added to fstab"
+ fi
+ fi
+ fi
+ fi
+
+ # Backup drives
+ echo ""
+ echo "BACKUP DRIVES (optional, for rsync backups):"
+ prompt_text " How many backup drives? [0-4, default: 0]:" "0" NUM_BACKUPS
+ NUM_BACKUPS="${NUM_BACKUPS:-0}"
+
+ # Validate number
+ case $NUM_BACKUPS in
+ 0|1|2|3|4) ;;
+ *) NUM_BACKUPS=0 ;;
+ esac
+
+ declare -a BACKUP_NAMES
+ declare -a BACKUP_DEVS
+ for i in $(seq 1 $NUM_BACKUPS); do
+ echo ""
+ prompt_text " Backup drive $i name [backup$i]:" "backup$i" "BACKUP_NAME"
+ BACKUP_NAMES[$i]="${BACKUP_NAME:-backup$i}"
+ mkdir -p "$ACTUAL_HOME/drives/${BACKUP_NAMES[$i]}"
+
+ if ! mount | grep -q "$ACTUAL_HOME/drives/${BACKUP_NAMES[$i]}"; then
+ read -p " Device for ${BACKUP_NAMES[$i]}: " "BACKUP_DEV"
+ BACKUP_DEVS[$i]="$BACKUP_DEV"
+
+ if [ -n "$BACKUP_DEV" ] && [ -b "$BACKUP_DEV" ]; then
+ BACKUP_UUID=$(blkid -s UUID -o value "$BACKUP_DEV" 2>/dev/null)
+ if [ -n "$BACKUP_UUID" ]; then
+ if ! grep -q "$BACKUP_UUID" /etc/fstab 2>/dev/null; then
+ echo "UUID=$BACKUP_UUID $ACTUAL_HOME/drives/${BACKUP_NAMES[$i]} auto defaults,nofail 0 2" >> /etc/fstab
+ echo " ✓ Added ${BACKUP_NAMES[$i]} to fstab"
+ fi
+ fi
+ fi
+ else
+ echo " ✓ Already mounted"
+ fi
+ done
+
+ # Set permissions
+ chown -R "$ACTUAL_USER:$ACTUAL_USER" "$ACTUAL_HOME/drives"
+
+ # Mount all from fstab
+ echo ""
+ echo "Mounting drives from fstab..."
+ mount -a 2>/dev/null || true
+
+ echo ""
+ echo "Current mounts in ~/drives/:"
+ df -h | grep "$ACTUAL_HOME/drives" | awk '{print " " $6 " - " $2 " total, " $4 " free"}' || echo " (none)"
+ echo ""
+
+ # Export drive names for later use
+ export PRIMARY_DRIVE_NAME="$PRIMARY_NAME"
+ export BACKUP_DRIVE_COUNT="$NUM_BACKUPS"
+ for i in $(seq 1 $NUM_BACKUPS); do
+ export "BACKUP_DRIVE_${i}_NAME=${BACKUP_NAMES[$i]}"
+ done
+
+ echo "✓ Drive setup complete"
+ echo ""
+}
+
+# Run drive setup for normal installs (not migration/recovery)
+if [ "$INSTALL_MODE" = "normal" ] && [ "$UNATTENDED" != true ]; then
+ setup_drives
+fi
+
+# ============================================================================
+# SOFTWARE DETECTION FUNCTIONS
+# ============================================================================
+
+is_docker_installed() {
+ command -v docker &> /dev/null && systemctl is-active --quiet docker 2>/dev/null
+}
+
+is_samba_installed() {
+ command -v smbd &> /dev/null && systemctl is-active --quiet smbd 2>/dev/null
+}
+
+is_netbird_installed() {
+ command -v netbird &> /dev/null
+}
+
+is_rustdesk_installed() {
+ command -v rustdesk &> /dev/null || dpkg -l rustdesk &> /dev/null
+}
+
+is_rclone_installed() {
+ command -v rclone &> /dev/null
+}
+
+is_rsync_installed() {
+ command -v rsync &> /dev/null
+}
+
+is_ufw_installed() {
+ command -v ufw &> /dev/null
+}
+
+is_wireguard_installed() {
+ command -v wg &> /dev/null
+}
+
+is_tailscale_installed() {
+ command -v tailscale &> /dev/null
+}
+
+is_teamviewer_installed() {
+ command -v teamviewer &> /dev/null || dpkg -l teamviewer &> /dev/null 2>&1
+}
+
+is_meshcentral_installed() {
+ # MeshCentral agent is typically installed as meshagent
+ command -v meshagent &> /dev/null || [ -f /usr/local/mesh_services/meshagent/meshagent ]
+}
+
+# ============================================================================
+# DRY-RUN AND UNATTENDED HELPER FUNCTIONS
+# ============================================================================
+
+# Run a command, but skip it in dry-run mode
+run_cmd() {
+ if [ "$DRY_RUN" = true ]; then
+ echo "[DRY-RUN] Would execute: $*"
+ return 0
+ else
+ "$@"
+ fi
+}
+
+# Ensure Docker directories are owned by the actual user (not root)
+# Usage: ensure_docker_dir_ownership /path/to/dir [additional_paths...]
+ensure_docker_dir_ownership() {
+ if [ "$DRY_RUN" = true ]; then
+ echo "[DRY-RUN] Would set ownership of $* to $ACTUAL_USER:$ACTUAL_USER"
+ return 0
+ fi
+
+ for dir in "$@"; do
+ if [ -d "$dir" ]; then
+ chown -R "$ACTUAL_USER:$ACTUAL_USER" "$dir" 2>/dev/null || true
+ fi
+ done
+}
+
+# Generate a secure random password with alphanumeric characters only (no special chars)
+# Usage: generate_password [length]
+# Default length: 32
+generate_password() {
+ local length="${1:-32}"
+ openssl rand -base64 48 | tr -dc 'a-zA-Z0-9' | head -c "$length"
+}
+
+# Validate password for Keycloak (alphanumeric only, minimum length)
+# Usage: validate_password "password" [min_length]
+# Returns 0 if valid, 1 if invalid
+validate_password() {
+ local password="$1"
+ local min_length="${2:-12}"
+
+ # Check length
+ if [ ${#password} -lt "$min_length" ]; then
+ echo " ⚠ Password must be at least $min_length characters long"
+ return 1
+ fi
+
+ # Check for special characters (not allowed for Keycloak)
+ if echo "$password" | grep -q '[^a-zA-Z0-9]'; then
+ echo " ⚠ Password must contain only letters and numbers (no special characters)"
+ return 1
+ fi
+
+ return 0
+}
+
+# Prompt for yes/no, with unattended default
+# Usage: prompt_yn "Question?" "default" VARNAME
+# default can be "y" or "n"
+prompt_yn() {
+ local question="$1"
+ local default="$2"
+ local varname="$3"
+
+ if [ "$UNATTENDED" = true ]; then
+ eval "$varname='$default'"
+ echo "$question [auto: $default]"
+ return
+ fi
+
+ read -p "$question " response
+ eval "$varname='$response'"
+}
+
+# Prompt for text input, with unattended default
+# Usage: prompt_text "Question?" "default" VARNAME
+prompt_text() {
+ local question="$1"
+ local default="$2"
+ local varname="$3"
+
+ if [ "$UNATTENDED" = true ]; then
+ eval "$varname='$default'"
+ echo "$question [auto: $default]"
+ return
+ fi
+
+ read -p "$question " response
+ eval "$varname='$response'"
+}
+
+# ============================================================================
+# CADDY CONFIGURATION HELPER
+# ============================================================================
+configure_caddy_for_service() {
+ local SERVICE_NAME="$1"
+ local SERVICE_PORT="$2"
+ local DEFAULT_SUBDOMAIN="$3"
+ local EXTRA_CONFIG="${4:-}" # Optional extra Caddy directives
+
+ # Check if Caddy is installed
+ if [ ! -d "$DOCKER_DIR/caddy" ]; then
+ return 0 # Caddy not installed, skip configuration
+ fi
+
+ echo ""
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo " CADDY REVERSE PROXY CONFIGURATION"
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo ""
+ echo "Caddy is installed. You can configure reverse proxy for $SERVICE_NAME."
+ echo ""
+
+ local CONFIGURE_CADDY=""
+ prompt_yn "Configure Caddy reverse proxy for $SERVICE_NAME? (y/n):" "n" CONFIGURE_CADDY
+
+ if [ "$CONFIGURE_CADDY" != "y" ] && [ "$CONFIGURE_CADDY" != "Y" ]; then
+ echo " Skipping Caddy configuration."
+ echo " Access $SERVICE_NAME at: http://localhost:$SERVICE_PORT"
+ return 0
+ fi
+
+ # Get domain/subdomain
+ echo ""
+ echo "Enter the full domain for $SERVICE_NAME:"
+ echo " Examples: $DEFAULT_SUBDOMAIN.example.com, $DEFAULT_SUBDOMAIN.yourdomain.com"
+ echo ""
+ local SERVICE_DOMAIN=""
+ prompt_text "Domain:" "" SERVICE_DOMAIN
+
+ if [ -z "$SERVICE_DOMAIN" ]; then
+ echo " ⚠ No domain provided, skipping Caddy configuration."
+ return 0
+ fi
+
+ # Backup Caddyfile
+ local CADDY_DIR="$DOCKER_DIR/caddy"
+ local CADDYFILE="$CADDY_DIR/Caddyfile"
+ local BACKUP_FILE="$CADDY_DIR/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)"
+
+ if [ -f "$CADDYFILE" ]; then
+ echo " Backing up Caddyfile to: $(basename $BACKUP_FILE)"
+ cp "$CADDYFILE" "$BACKUP_FILE"
+ else
+ echo " Creating new Caddyfile"
+ touch "$CADDYFILE"
+ fi
+
+ # Check if service already configured
+ if grep -q "^${SERVICE_DOMAIN}" "$CADDYFILE" 2>/dev/null; then
+ echo " ⚠ $SERVICE_DOMAIN already exists in Caddyfile"
+ local OVERWRITE=""
+ prompt_yn "Overwrite existing configuration? (y/n):" "n" OVERWRITE
+ if [ "$OVERWRITE" != "y" ] && [ "$OVERWRITE" != "Y" ]; then
+ echo " Keeping existing configuration."
+ return 0
+ fi
+ # Remove existing configuration
+ sed -i "/^${SERVICE_DOMAIN}/,/^}/d" "$CADDYFILE"
+ fi
+
+ # Add service configuration to Caddyfile
+ echo " Adding $SERVICE_NAME configuration to Caddyfile..."
+
+ cat >> "$CADDYFILE" << CADDY_BLOCK
+
+# $SERVICE_NAME
+$SERVICE_DOMAIN {
+ reverse_proxy localhost:$SERVICE_PORT
+
+ # Security headers
+ header {
+ Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
+ X-Content-Type-Options "nosniff"
+ X-Frame-Options "SAMEORIGIN"
+ Referrer-Policy "strict-origin-when-cross-origin"
+ }
+
+ # Logging for fail2ban
+ log {
+ output file /var/log/caddy/${SERVICE_DOMAIN}.log
+ format json
+ }
+$EXTRA_CONFIG
+}
+CADDY_BLOCK
+
+ echo " ✓ Configuration added to Caddyfile"
+
+ # Reload Caddy
+ echo " Reloading Caddy configuration..."
+ if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then
+ echo " ✓ Caddy reloaded successfully"
+ else
+ echo " ⚠ Failed to reload Caddy (will retry after formatting)"
+ fi
+
+ # Format Caddyfile
+ echo " Formatting Caddyfile..."
+ if docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null; then
+ echo " ✓ Caddyfile formatted"
+ else
+ echo " ⚠ Failed to format Caddyfile"
+ fi
+
+ # Final reload after formatting
+ echo " Final reload..."
+ if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then
+ echo " ✓ Caddy configuration active"
+ echo ""
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo " $SERVICE_NAME is now accessible at:"
+ echo " https://$SERVICE_DOMAIN"
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ else
+ echo " ⚠ Failed final reload. Check Caddy logs:"
+ echo " docker logs caddy"
+ echo " You can restore from backup: $BACKUP_FILE"
+ fi
+
+ echo ""
+}
+
+# ============================================================================
+# CHECK IF SERVICE EXISTS AND ASK USER ACTION
+# ============================================================================
+check_service_exists() {
+ local SERVICE_NAME="$1"
+ local SERVICE_DIR="$2"
+ local RETURN_VAR="$3" # Variable name to set (true/false)
+
+ if [ -f "$SERVICE_DIR/docker-compose.yml" ]; then
+ echo ""
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo " $SERVICE_NAME is already installed at $SERVICE_DIR"
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo ""
+ echo "Options:"
+ echo " 1. Skip (keep existing configuration)"
+ echo " 2. Reconfigure (will backup existing config)"
+ echo " 3. Restart containers only"
+ echo ""
+ local ACTION=""
+ prompt_text "Choose option [1/2/3]:" "1" ACTION
+
+ case "$ACTION" in
+ 2)
+ echo " Backing up existing configuration..."
+ local BACKUP_NAME=$(echo "$SERVICE_NAME" | tr '[:upper:] ' '[:lower:]-')
+ BACKUP_DIR="$DOCKER_DIR/backups/$(date +%Y%m%d-%H%M%S)-$BACKUP_NAME"
+ mkdir -p "$BACKUP_DIR"
+ cp -r "$SERVICE_DIR" "$BACKUP_DIR/"
+ echo " ✓ Backup saved to: $BACKUP_DIR"
+ eval "$RETURN_VAR=true"
+ return 0
+ ;;
+ 3)
+ echo " Restarting $SERVICE_NAME containers..."
+ cd "$SERVICE_DIR"
+ docker compose restart 2>/dev/null && echo " ✓ $SERVICE_NAME restarted" || echo " ⚠ Failed to restart"
+ eval "$RETURN_VAR=false"
+ return 0
+ ;;
+ *)
+ echo " Skipping $SERVICE_NAME (already configured)"
+ eval "$RETURN_VAR=false"
+ return 0
+ ;;
+ esac
+ else
+ # Service doesn't exist, proceed with installation
+ eval "$RETURN_VAR=true"
+ return 0
+ fi
+}
+
+# ============================================================================
+# DETECT AVAILABLE DRIVES (RUN ONCE)
+# ============================================================================
+detect_drives() {
+ # Global variables set by this function:
+ # - DRIVES_DETECTED: true/false
+ # - PRIMARY_DRIVE: name of first drive (e.g., "storage1")
+ # - PRIMARY_DRIVE_PATH: full path to first drive
+ # - DRIVES_DIR: base drives directory
+ # - AVAILABLE_DRIVES_COUNT: number of drives found
+
+ DRIVES_DIR="$ACTUAL_HOME/drives"
+ DRIVES_DETECTED=false
+ PRIMARY_DRIVE=""
+ PRIMARY_DRIVE_PATH=""
+ AVAILABLE_DRIVES_COUNT=0
+
+ if [ -d "$DRIVES_DIR" ]; then
+ echo ""
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo " DETECTED DRIVES"
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo ""
+
+ for drive in "$DRIVES_DIR"/*; do
+ if [ -d "$drive" ]; then
+ drive_name=$(basename "$drive")
+ drive_size=$(df -h "$drive" 2>/dev/null | awk 'NR==2 {print $2}')
+ drive_used=$(df -h "$drive" 2>/dev/null | awk 'NR==2 {print $3}')
+ drive_avail=$(df -h "$drive" 2>/dev/null | awk 'NR==2 {print $4}')
+
+ echo " ✓ $drive_name"
+ if [ -n "$drive_size" ]; then
+ echo " Path: $drive"
+ echo " Size: $drive_size (Used: $drive_used, Available: $drive_avail)"
+ else
+ echo " Path: $drive"
+ fi
+
+ # Set PRIMARY_DRIVE to the first drive found
+ if [ -z "$PRIMARY_DRIVE" ]; then
+ PRIMARY_DRIVE="$drive_name"
+ PRIMARY_DRIVE_PATH="$drive"
+ fi
+
+ AVAILABLE_DRIVES_COUNT=$((AVAILABLE_DRIVES_COUNT + 1))
+ fi
+ done
+
+ if [ $AVAILABLE_DRIVES_COUNT -gt 0 ]; then
+ DRIVES_DETECTED=true
+ echo ""
+ echo " Using '$PRIMARY_DRIVE' as primary drive for default paths"
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ fi
+ fi
+
+ # If no drives detected, use home directory
+ if [ "$DRIVES_DETECTED" = "false" ]; then
+ echo ""
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo " No ~/drives directory found"
+ echo " Using $ACTUAL_HOME for default paths"
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ PRIMARY_DRIVE_PATH="$ACTUAL_HOME"
+ fi
+}
+
+# ============================================================================
+# SHOW CURRENT INSTALLATION STATUS
+# ============================================================================
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+echo "CURRENT SYSTEM STATUS"
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+echo ""
+if is_docker_installed; then
+ echo " ✓ Docker: Installed ($(docker --version 2>/dev/null | cut -d' ' -f3 | tr -d ','))"
+else
+ echo " ○ Docker: Not installed"
+fi
+if is_samba_installed; then
+ echo " ✓ Samba: Installed and running"
+else
+ echo " ○ Samba: Not installed"
+fi
+if is_netbird_installed; then
+ echo " ✓ NetBird: Installed"
+else
+ echo " ○ NetBird: Not installed"
+fi
+if is_rustdesk_installed; then
+ echo " ✓ RustDesk: Installed"
+else
+ echo " ○ RustDesk: Not installed"
+fi
+if is_teamviewer_installed; then
+ echo " ✓ TeamViewer: Installed"
+else
+ echo " ○ TeamViewer: Not installed"
+fi
+if is_meshcentral_installed; then
+ echo " ✓ MeshCentral Agent: Installed"
+else
+ echo " ○ MeshCentral Agent: Not installed"
+fi
+if is_wireguard_installed; then
+ echo " ✓ WireGuard: Installed"
+else
+ echo " ○ WireGuard: Not installed"
+fi
+if is_tailscale_installed; then
+ echo " ✓ Tailscale: Installed"
+else
+ echo " ○ Tailscale: Not installed"
+fi
+if is_rclone_installed; then
+ echo " ✓ rclone: Installed"
+else
+ echo " ○ rclone: Not installed"
+fi
+if is_rsync_installed; then
+ echo " ✓ rsync: Installed"
+else
+ echo " ○ rsync: Not installed"
+fi
+if is_ufw_installed; then
+ if ufw status 2>/dev/null | grep -q "Status: active"; then
+ echo " ✓ UFW Firewall: Enabled"
+ else
+ echo " ○ UFW Firewall: Installed but disabled"
+ fi
+else
+ echo " ○ UFW Firewall: Not installed"
+fi
+echo ""
+echo "This script can reinstall/reconfigure any component."
echo ""
# Update package list
echo "Updating package lists..."
-apt update
+run_cmd apt update
# Install basic utilities
echo ""
@@ -31,20 +2964,18 @@ echo " - ncdu: Disk usage analyzer with ncurses interface"
echo " - git: Version control system"
echo " - curl: Command-line tool for transferring data with URLs"
echo " - wget: Network downloader"
-echo " - vim: Advanced text editor"
echo " - htop: Interactive process viewer"
echo " - tree: Display directory structure in tree format"
echo " - zip/unzip: Archive compression utilities"
echo " - rclone: Rsync for cloud storage and local drives (backup tool)"
echo ""
-apt install -y \
+run_cmd apt install -y \
net-tools \
ncdu \
git \
curl \
wget \
- vim \
htop \
tree \
zip \
@@ -63,13 +2994,28 @@ apt install -y openssh-server || echo "Warning: OpenSSH server installation fail
systemctl start ssh || echo "Warning: Failed to start SSH"
systemctl enable ssh || echo "Warning: Failed to enable SSH"
+# On rerun, skip SSH configuration if already set up
+SKIP_SSH_CONFIG=false
+if (systemctl is-active ssh &>/dev/null || systemctl is-active sshd &>/dev/null) && \
+ { [ -f "$ACTUAL_HOME/.ssh/authorized_keys" ] && [ -s "$ACTUAL_HOME/.ssh/authorized_keys" ]; } || \
+ [ -f "$ACTUAL_HOME/.ssh/id_rsa" ]; then
+ echo ""
+ echo "SSH is already configured on this system."
+ prompt_yn "Reconfigure SSH keys and authentication? (y/n):" "n" RECONFIG_SSH
+ if [ "$RECONFIG_SSH" != "y" ] && [ "$RECONFIG_SSH" != "Y" ]; then
+ SKIP_SSH_CONFIG=true
+ fi
+fi
+
+if [ "$SKIP_SSH_CONFIG" != true ]; then
+
# Generate SSH key for this computer
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "SSH KEY GENERATION"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
-read -p "Generate a new SSH key for this computer? (y/n): " GENERATE_KEY
+prompt_yn "Generate a new SSH key for this computer? (y/n):" "n" GENERATE_KEY
if [ "$GENERATE_KEY" = "y" ] || [ "$GENERATE_KEY" = "Y" ]; then
echo ""
@@ -127,10 +3073,18 @@ fi
# Import SSH keys from GitHub/Launchpad
echo ""
-read -p "Import SSH keys from GitHub? (enter username or leave blank to skip): " GITHUB_USER
-read -p "Import SSH keys from Launchpad? (enter username or leave blank to skip): " LAUNCHPAD_USER
+echo "You can import SSH public keys from GitHub or Launchpad for easier SSH access."
+echo ""
+prompt_yn "Import SSH keys from GitHub or Launchpad? (y/n):" "n" IMPORT_SSH_KEYS
KEYS_IMPORTED=false
+GITHUB_USER=""
+LAUNCHPAD_USER=""
+
+if [ "$IMPORT_SSH_KEYS" = "y" ] || [ "$IMPORT_SSH_KEYS" = "Y" ]; then
+ prompt_text "GitHub username (or leave blank to skip):" "" GITHUB_USER
+ prompt_text "Launchpad username (or leave blank to skip):" "" LAUNCHPAD_USER
+fi
# Create .ssh directory if it doesn't exist
mkdir -p "$ACTUAL_HOME/.ssh"
@@ -184,413 +3138,5627 @@ if [ "$KEYS_IMPORTED" = true ]; then
else
echo ""
echo "No SSH keys imported. Password authentication remains enabled."
-fi
-
-# Install Docker prerequisites
-echo ""
-echo "Installing Docker prerequisites..."
-echo " - ca-certificates: SSL/TLS certificates for secure connections"
-echo " - gnupg: GNU Privacy Guard for package verification"
-echo " - lsb-release: Provides Ubuntu version information"
-echo ""
-
-apt install -y \
- ca-certificates \
- gnupg \
- lsb-release || echo "Warning: Some prerequisites failed to install, continuing..."
-
-# Install Docker
-echo ""
-echo "Installing Docker..."
-echo " - Docker Engine: Container runtime platform"
-echo " - Docker Compose: Multi-container application orchestration"
-echo ""
-
-# Remove old Docker packages if they exist
-apt remove -y docker docker-engine docker.io containerd runc 2>/dev/null || true
-
-# Add Docker's official GPG key
-install -m 0755 -d /etc/apt/keyrings
-curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
-chmod a+r /etc/apt/keyrings/docker.asc
-
-# Add Docker repository
-echo \
- "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
- $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
- tee /etc/apt/sources.list.d/docker.list > /dev/null
-
-# Update package list with Docker repo
-apt update
-
-# Install Docker Engine, CLI, containerd, and Docker Compose plugin
-apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin || echo "Warning: Docker installation failed, continuing..."
-
-# Start and enable Docker service
-systemctl start docker || echo "Warning: Failed to start Docker"
-systemctl enable docker || echo "Warning: Failed to enable Docker"
-
-# Add current user to docker group (if not root)
-if [ -n "$SUDO_USER" ]; then
- usermod -aG docker "$SUDO_USER"
- echo "User $SUDO_USER added to docker group"
-fi
-
-# Verify Docker installation
-echo ""
-echo "Verifying Docker installation..."
-docker --version || echo "Warning: Docker verification failed"
-docker compose version || echo "Warning: Docker Compose verification failed"
-
-# Install NetBird
-echo ""
-echo "Installing NetBird..."
-echo " - NetBird: Secure mesh VPN for connecting devices"
-echo ""
-
-curl -fsSL https://pkgs.netbird.io/install.sh | sh || echo "Warning: NetBird installation failed, continuing..."
-
-echo ""
-echo "NetBird installed. Setup instructions:"
-echo " 1. Create account at https://app.netbird.io (or self-host)"
-echo " 2. Run 'netbird up' and authenticate via browser"
-echo ""
-echo "For NetBird SSH functionality:"
-echo " • Enable SSH in NetBird dashboard settings"
-echo " • Use 'netbird ssh ' to connect to peers"
-echo " • NetBird manages SSH keys automatically when using 'netbird ssh'"
-echo " • Traditional SSH also works using peer IPs from 'netbird status'"
-echo " • Configure ACL rules in dashboard for SSH access (port 22)"
-echo ""
-
-# Install RustDesk
-echo ""
-echo "Installing RustDesk..."
-echo " - RustDesk: Open-source remote desktop software"
-echo ""
-
-# Download latest RustDesk .deb package
-RUSTDESK_VERSION=$(curl -s https://api.github.com/repos/rustdesk/rustdesk/releases/latest | grep -oP '"tag_name": "\K(.*)(?=")')
-RUSTDESK_URL="https://github.com/rustdesk/rustdesk/releases/download/${RUSTDESK_VERSION}/rustdesk-${RUSTDESK_VERSION}-x86_64.deb"
-
-wget -O /tmp/rustdesk.deb "$RUSTDESK_URL" || echo "Warning: RustDesk download failed, continuing..."
-
-if [ -f /tmp/rustdesk.deb ]; then
- apt install -y /tmp/rustdesk.deb || echo "Warning: RustDesk installation failed, continuing..."
- rm /tmp/rustdesk.deb
-fi
-
-# Create rclone backup script and instructions
-echo ""
-echo "Setting up rclone backup configuration..."
-echo ""
-
-# Create backup script directory
-mkdir -p /usr/local/bin/backup-scripts
-
-# Create mount point directories
-echo ""
-echo "Creating mount point directories in $ACTUAL_HOME/drives/..."
-mkdir -p "$ACTUAL_HOME/drives/primary"
-mkdir -p "$ACTUAL_HOME/drives/backup1"
-mkdir -p "$ACTUAL_HOME/drives/backup2"
-chown -R "$ACTUAL_USER:$ACTUAL_USER" "$ACTUAL_HOME/drives"
-
-echo ""
-echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
-echo "DRIVE SETUP - Mount your drives"
-echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
-echo ""
-echo "Available block devices:"
-echo ""
-lsblk -o NAME,SIZE,TYPE,FSTYPE,MOUNTPOINT,LABEL
-echo ""
-
-read -p "Do you want to mount drives now? (y/n): " MOUNT_NOW
-
-if [ "$MOUNT_NOW" = "y" ] || [ "$MOUNT_NOW" = "Y" ]; then
+ # Offer fail2ban since password auth is still enabled
echo ""
- echo "Enter device paths (e.g., /dev/sdb1) or leave blank to skip"
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo "FAIL2BAN (Recommended with password SSH)"
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
-
- read -p "Primary drive device (e.g., /dev/sdb1): " PRIMARY_DEV
- read -p "Backup1 drive device (e.g., /dev/sdc1): " BACKUP1_DEV
- read -p "Backup2 drive device (e.g., /dev/sdd1): " BACKUP2_DEV
-
- # Mount primary
- if [ -n "$PRIMARY_DEV" ] && [ -b "$PRIMARY_DEV" ]; then
- echo "Mounting $PRIMARY_DEV to $ACTUAL_HOME/drives/primary..."
- mount "$PRIMARY_DEV" "$ACTUAL_HOME/drives/primary" && echo "✓ Primary mounted" || echo "✗ Failed to mount primary"
- fi
-
- # Mount backup1
- if [ -n "$BACKUP1_DEV" ] && [ -b "$BACKUP1_DEV" ]; then
- echo "Mounting $BACKUP1_DEV to $ACTUAL_HOME/drives/backup1..."
- mount "$BACKUP1_DEV" "$ACTUAL_HOME/drives/backup1" && echo "✓ Backup1 mounted" || echo "✗ Failed to mount backup1"
- fi
-
- # Mount backup2
- if [ -n "$BACKUP2_DEV" ] && [ -b "$BACKUP2_DEV" ]; then
- echo "Mounting $BACKUP2_DEV to $ACTUAL_HOME/drives/backup2..."
- mount "$BACKUP2_DEV" "$ACTUAL_HOME/drives/backup2" && echo "✓ Backup2 mounted" || echo "✗ Failed to mount backup2"
- fi
-
+ echo "Since password authentication is enabled, fail2ban is recommended"
+ echo "to protect against brute-force SSH attacks."
echo ""
- echo "Current mounts:"
- df -h | grep "$ACTUAL_HOME/drives"
-
- echo ""
- read -p "Add these mounts to /etc/fstab for automatic mounting at boot? (y/n): " ADD_FSTAB
-
- if [ "$ADD_FSTAB" = "y" ] || [ "$ADD_FSTAB" = "Y" ]; then
+
+ prompt_yn "Install and enable fail2ban? (y/n):" "y" INSTALL_FAIL2BAN
+
+ if [ "$INSTALL_FAIL2BAN" = "y" ] || [ "$INSTALL_FAIL2BAN" = "Y" ]; then
echo ""
- echo "Adding entries to /etc/fstab..."
- cp /etc/fstab /etc/fstab.backup-$(date +%Y%m%d-%H%M%S)
-
- if [ -n "$PRIMARY_DEV" ] && [ -b "$PRIMARY_DEV" ]; then
- PRIMARY_UUID=$(blkid -s UUID -o value "$PRIMARY_DEV")
- if [ -n "$PRIMARY_UUID" ]; then
- echo "UUID=$PRIMARY_UUID $ACTUAL_HOME/drives/primary auto defaults 0 2" >> /etc/fstab
- echo "✓ Added primary to fstab"
- fi
+ echo "Installing fail2ban..."
+ run_cmd apt install -y fail2ban
+
+ if [ "$DRY_RUN" != true ]; then
+ # Create local config to protect SSH
+ cat > /etc/fail2ban/jail.local << 'FAIL2BAN_CONFIG'
+[sshd]
+enabled = true
+port = ssh
+filter = sshd
+logpath = /var/log/auth.log
+maxretry = 5
+bantime = 3600
+findtime = 600
+FAIL2BAN_CONFIG
+
+ run_cmd systemctl enable fail2ban
+ run_cmd systemctl restart fail2ban
+ echo "✓ fail2ban installed and configured"
+ echo " - 5 failed attempts = 1 hour ban"
+ echo " - View banned IPs: sudo fail2ban-client status sshd"
fi
-
- if [ -n "$BACKUP1_DEV" ] && [ -b "$BACKUP1_DEV" ]; then
- BACKUP1_UUID=$(blkid -s UUID -o value "$BACKUP1_DEV")
- if [ -n "$BACKUP1_UUID" ]; then
- echo "UUID=$BACKUP1_UUID $ACTUAL_HOME/drives/backup1 auto defaults 0 2" >> /etc/fstab
- echo "✓ Added backup1 to fstab"
- fi
+ else
+ echo "Skipping fail2ban installation."
+ fi
+fi
+
+INSTALL_FAIL2BAN="${INSTALL_FAIL2BAN:-n}"
+
+fi # End SKIP_SSH_CONFIG
+
+# Docker Installation
+echo ""
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+echo "DOCKER INSTALLATION"
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+echo ""
+
+if is_docker_installed; then
+ echo "Docker is already installed: $(docker --version 2>/dev/null)"
+ prompt_yn "Reinstall Docker? (y/n):" "n" INSTALL_DOCKER
+else
+ prompt_yn "Install Docker? (y/n):" "y" INSTALL_DOCKER
+fi
+
+if [ "$INSTALL_DOCKER" = "y" ] || [ "$INSTALL_DOCKER" = "Y" ]; then
+ echo ""
+ echo "Installing Docker prerequisites..."
+ echo " - ca-certificates: SSL/TLS certificates for secure connections"
+ echo " - gnupg: GNU Privacy Guard for package verification"
+ echo " - lsb-release: Provides Ubuntu version information"
+ echo ""
+
+ run_cmd apt install -y \
+ ca-certificates \
+ gnupg \
+ lsb-release || echo "Warning: Some prerequisites failed to install, continuing..."
+
+ echo ""
+ echo "Installing Docker..."
+ echo " - Docker Engine: Container runtime platform"
+ echo " - Docker Compose: Multi-container application orchestration"
+ echo ""
+
+ if [ "$DRY_RUN" = true ]; then
+ echo "[DRY-RUN] Would remove old Docker packages"
+ echo "[DRY-RUN] Would add Docker GPG key and repository"
+ echo "[DRY-RUN] Would install docker-ce, docker-ce-cli, containerd.io, plugins"
+ echo "[DRY-RUN] Would start and enable Docker service"
+ echo "[DRY-RUN] Would add $SUDO_USER to docker group"
+ else
+ # Remove old Docker packages if they exist
+ apt remove -y docker docker-engine docker.io containerd runc 2>/dev/null || true
+
+ # Add Docker's official GPG key
+ install -m 0755 -d /etc/apt/keyrings
+ curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
+ chmod a+r /etc/apt/keyrings/docker.asc
+
+ # Add Docker repository
+ echo \
+ "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
+ $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
+ tee /etc/apt/sources.list.d/docker.list > /dev/null
+
+ # Update package list with Docker repo
+ apt update
+
+ # Install Docker Engine, CLI, containerd, and Docker Compose plugin
+ apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin || echo "Warning: Docker installation failed, continuing..."
+
+ # Start and enable Docker service
+ systemctl start docker || echo "Warning: Failed to start Docker"
+ systemctl enable docker || echo "Warning: Failed to enable Docker"
+
+ # Add current user to docker group (if not root)
+ if [ -n "$SUDO_USER" ]; then
+ usermod -aG docker "$SUDO_USER"
+ echo "User $SUDO_USER added to docker group"
fi
-
- if [ -n "$BACKUP2_DEV" ] && [ -b "$BACKUP2_DEV" ]; then
- BACKUP2_UUID=$(blkid -s UUID -o value "$BACKUP2_DEV")
- if [ -n "$BACKUP2_UUID" ]; then
- echo "UUID=$BACKUP2_UUID $ACTUAL_HOME/drives/backup2 auto defaults 0 2" >> /etc/fstab
- echo "✓ Added backup2 to fstab"
- fi
- fi
-
- echo "✓ Backup of original fstab saved with timestamp"
+
+ # Verify Docker installation
+ echo ""
+ echo "Verifying Docker installation..."
+ docker --version || echo "Warning: Docker verification failed"
+ docker compose version || echo "Warning: Docker Compose verification failed"
fi
else
- echo ""
- echo "Skipping drive mounting. You can mount manually later."
- echo "Mount points created at:"
- echo " $ACTUAL_HOME/drives/primary"
- echo " $ACTUAL_HOME/drives/backup1"
- echo " $ACTUAL_HOME/drives/backup2"
+ echo "Skipping Docker installation."
fi
-# Create example backup script with correct paths
-cat > /usr/local/bin/backup-scripts/rclone-backup.sh << BACKUP_SCRIPT
-#!/bin/bash
+# Samba File Sharing (Optional)
+echo ""
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+echo "SAMBA FILE SHARING (Optional)"
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+echo ""
+echo "Samba allows you to share folders over the network to Windows, Mac, and Linux."
+echo "The script will share your primary drive at ~/drives/primary"
+echo ""
-################################################################################
-# Rclone SPLIT Backup Script - Divide data between multiple backup drives
-################################################################################
-#
-# RCLONE TERMINOLOGY:
-# SOURCE (PRIMARY) = Where your data currently lives
-# DESTINATION (BACKUP) = Where you want exact copies stored
-#
-# SPLIT BACKUP STRATEGY:
-# This script splits your primary data between backup1 and backup2
-# Perfect for when: Primary is 4TB, Backup1 is 2TB, Backup2 is 2TB
-#
-# Example:
-# primary/work/ → backup1/work/ (backup1 only)
-# primary/photos/ → backup1/photos/ (backup1 only)
-# primary/videos/ → backup2/videos/ (backup2 only)
-# primary/music/ → backup2/music/ (backup2 only)
-#
-################################################################################
-
-# ┌────────────────────────────────────────────────────────────────────┐
-# │ CONFIGURE THESE PATHS │
-# └────────────────────────────────────────────────────────────────────┘
-
-# SOURCE: Primary drive (where your data lives)
-PRIMARY="$ACTUAL_HOME/drives/primary"
-
-# DESTINATIONS: Backup drives (where copies will be stored)
-BACKUP1="$ACTUAL_HOME/drives/backup1"
-BACKUP2="$ACTUAL_HOME/drives/backup2"
-
-# ┌────────────────────────────────────────────────────────────────────┐
-# │ ⚠️ CRITICAL: CONFIGURE WHICH FOLDERS GO TO WHICH BACKUP DRIVE! ⚠️ │
-# └────────────────────────────────────────────────────────────────────┘
-#
-# List folder names that exist in PRIMARY and assign them to backup drives.
-# Balance the data so each backup drive has roughly equal capacity used.
-#
-# Example setup (adjust to match YOUR actual folders):
-
-# Folders to backup to BACKUP1 only
-BACKUP1_DIRS=(
- "documents"
- "work"
- "photos"
-)
-
-# Folders to backup to BACKUP2 only
-BACKUP2_DIRS=(
- "videos"
- "music"
- "downloads"
-)
-
-# ┌────────────────────────────────────────────────────────────────────┐
-# │ HOW TO BALANCE YOUR DATA: │
-# └────────────────────────────────────────────────────────────────────┘
-#
-# 1. Check size of each folder on PRIMARY:
-# du -sh $ACTUAL_HOME/drives/primary/*
-#
-# 2. Divide folders between BACKUP1_DIRS and BACKUP2_DIRS so the total
-# size in each list fits on the respective backup drive
-#
-# Example output from du -sh:
-# 500G primary/work
-# 800G primary/photos
-# 1.2T primary/videos
-# 500G primary/music
-#
-# Split strategy (for 2TB backup drives):
-# BACKUP1_DIRS: work (500G) + photos (800G) = 1.3TB → fits on 2TB drive
-# BACKUP2_DIRS: videos (1.2T) + music (500G) = 1.7TB → fits on 2TB drive
-#
-
-################################################################################
-# Script logic below - you shouldn't need to edit anything below this line
-################################################################################
-
-# Log file
-LOG="/var/log/rclone-backup.log"
-
-echo "=== Rclone SPLIT Backup Started: \$(date) ===" | tee -a "\$LOG"
-echo "SOURCE (Primary): \$PRIMARY" | tee -a "\$LOG"
-echo "Strategy: Split data between backup drives" | tee -a "\$LOG"
-echo "" | tee -a "\$LOG"
-
-# Function to backup specific directories to a destination
-backup_to_drive() {
- local dest=\$1
- local drive_name=\$2
- shift 2
- local dirs_array=("\$@")
-
- if [ ! -d "\$dest" ]; then
- echo "⚠️ WARNING: \$drive_name (\$dest) not mounted, skipping" | tee -a "\$LOG"
- return 1
+if is_samba_installed; then
+ echo "Samba is already installed and running."
+ if grep -q "\[Primary\]" /etc/samba/smb.conf 2>/dev/null; then
+ echo " Share 'Primary' is configured at: $ACTUAL_HOME/drives/primary"
fi
-
- if [ \${#dirs_array[@]} -eq 0 ]; then
- echo "⚠️ WARNING: No directories assigned to \$drive_name, skipping" | tee -a "\$LOG"
- return 1
- fi
-
- echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" | tee -a "\$LOG"
- echo "DESTINATION: \$drive_name (\$dest)" | tee -a "\$LOG"
- echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" | tee -a "\$LOG"
-
- for dir in "\${dirs_array[@]}"; do
- if [ ! -d "\$PRIMARY/\$dir" ]; then
- echo " ⚠️ WARNING: \$PRIMARY/\$dir does not exist, skipping" | tee -a "\$LOG"
- continue
- fi
-
- echo "" | tee -a "\$LOG"
- echo " Syncing: \$dir" | tee -a "\$LOG"
- echo " FROM: \$PRIMARY/\$dir" | tee -a "\$LOG"
- echo " TO: \$dest/\$dir" | tee -a "\$LOG"
-
- # Use rclone sync for exact mirroring
- # SOURCE -> DESTINATION (one-way)
- # --checksum: verify with checksums (slower but accurate)
- # --verbose: show what's being copied
- # --progress: show progress
- # --delete-during: delete files from dest that don't exist in source
-
- rclone sync "\$PRIMARY/\$dir" "\$dest/\$dir" \\
- --checksum \\
- --verbose \\
- --progress \\
- --log-file="\$LOG" \\
- --stats=30s
-
- if [ \$? -eq 0 ]; then
- echo " ✓ \$dir synced successfully to \$drive_name" | tee -a "\$LOG"
+ echo ""
+ prompt_yn "Reconfigure Samba? (y/n):" "n" INSTALL_SAMBA
+else
+ prompt_yn "Install and configure Samba file sharing? (y/n):" "n" INSTALL_SAMBA
+fi
+
+if [ "$INSTALL_SAMBA" = "y" ] || [ "$INSTALL_SAMBA" = "Y" ]; then
+ echo ""
+ echo "Installing Samba file sharing..."
+ echo " - Samba: SMB/CIFS file server for network file sharing"
+ echo ""
+
+ if [ "$DRY_RUN" = true ]; then
+ echo "[DRY-RUN] Would install samba"
+ echo "[DRY-RUN] Would configure [Primary] share at $ACTUAL_HOME/drives/primary"
+ echo "[DRY-RUN] Would prompt for Samba password"
+ echo "[DRY-RUN] Would enable and start smbd/nmbd services"
+ else
+ run_cmd apt install -y samba || echo "Warning: Samba installation failed, continuing..."
+
+ # Configure Samba share for primary drive
+ if command -v smbd &> /dev/null; then
+ echo ""
+ echo "Configuring Samba share for primary drive..."
+
+ # Backup existing config
+ cp /etc/samba/smb.conf /etc/samba/smb.conf.backup-$(date +%Y%m%d-%H%M%S)
+
+ # Add Primary share configuration
+ if ! grep -q "\[Primary\]" /etc/samba/smb.conf; then
+ cat >> /etc/samba/smb.conf << SAMBA_CONFIG
+
+# Primary drive share - added by post-install script
+[Primary]
+ comment = Primary Drive
+ path = $ACTUAL_HOME/drives/primary
+ browseable = yes
+ read only = no
+ writable = yes
+ valid users = $ACTUAL_USER
+ create mask = 0775
+ directory mask = 0775
+SAMBA_CONFIG
+ echo "✓ Added [Primary] share to Samba configuration"
+ else
+ echo "Samba [Primary] share already configured, skipping..."
+ fi
+
+ # Add Samba user (skip in unattended mode - user must set password manually)
+ if [ "$UNATTENDED" != true ]; then
+ echo ""
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo "SAMBA PASSWORD SETUP"
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo ""
+ echo "Set a password for Samba file sharing access."
+ echo "Tip: Using the same password as your system login is convenient."
+ echo ""
+ smbpasswd -a "$ACTUAL_USER"
+ else
+ echo "Skipping Samba password setup (unattended mode)"
+ echo "Set password later with: sudo smbpasswd -a $ACTUAL_USER"
+ fi
+
+ # Enable and restart Samba services
+ systemctl enable smbd nmbd || echo "Warning: Failed to enable Samba services"
+ systemctl restart smbd nmbd || echo "Warning: Failed to restart Samba services"
+
+ echo ""
+ echo "✓ Samba configured successfully"
+ echo " Share name: Primary"
+ echo " Path: $ACTUAL_HOME/drives/primary"
+ echo " Access: \\\\$(hostname)\\Primary (Windows) or smb://$(hostname)/Primary (Mac/Linux)"
+ echo ""
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo "ADDING MORE SAMBA SHARES"
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo ""
+ echo "To add another share, edit the Samba config file:"
+ echo ""
+ echo " sudo nano /etc/samba/smb.conf"
+ echo ""
+ echo "Add a new section at the end:"
+ echo ""
+ echo " [ShareName]"
+ echo " comment = Description of share"
+ echo " path = /path/to/folder"
+ echo " browseable = yes"
+ echo " read only = no"
+ echo " writable = yes"
+ echo " valid users = $ACTUAL_USER"
+ echo " create mask = 0775"
+ echo " directory mask = 0775"
+ echo ""
+ echo "Save (Ctrl+O, Enter) and exit (Ctrl+X), then restart Samba:"
+ echo ""
+ echo " sudo systemctl restart smbd nmbd"
+ echo ""
+ echo "Verify the share is active:"
+ echo ""
+ echo " testparm -s"
+ echo ""
else
- echo " ✗ \$dir sync to \$drive_name FAILED" | tee -a "\$LOG"
+ echo "✗ Samba installation failed, skipping configuration"
fi
- done
- echo "" | tee -a "\$LOG"
+ fi
+else
+ echo "Skipping Samba installation."
+fi
+
+# On rerun, skip VPN configuration if already set up
+SKIP_VPN_CONFIG=false
+if is_netbird_installed || is_wireguard_installed || is_tailscale_installed; then
+ echo ""
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo "VPN SERVICES"
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo ""
+ echo "VPN services already configured:"
+ is_netbird_installed && echo " ✓ NetBird"
+ is_wireguard_installed && echo " ✓ WireGuard"
+ is_tailscale_installed && echo " ✓ Tailscale"
+ echo ""
+ prompt_yn "Reconfigure VPN services? (y/n):" "n" RECONFIG_VPN
+ if [ "$RECONFIG_VPN" != "y" ] && [ "$RECONFIG_VPN" != "Y" ]; then
+ SKIP_VPN_CONFIG=true
+ fi
+fi
+
+if [ "$SKIP_VPN_CONFIG" != true ]; then
+
+# NetBird Installation
+echo ""
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+echo "NETBIRD MESH VPN (Optional)"
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+echo ""
+echo "NetBird is a secure mesh VPN for connecting devices across networks."
+echo ""
+
+if is_netbird_installed; then
+ echo "NetBird is already installed."
+ netbird status 2>/dev/null || true
+ echo ""
+ prompt_yn "Reinstall NetBird? (y/n):" "n" INSTALL_NETBIRD
+else
+ prompt_yn "Install NetBird? (y/n):" "n" INSTALL_NETBIRD
+fi
+
+if [ "$INSTALL_NETBIRD" = "y" ] || [ "$INSTALL_NETBIRD" = "Y" ]; then
+ echo ""
+ echo "Installing NetBird..."
+ echo " - NetBird: Secure mesh VPN for connecting devices"
+ echo ""
+
+ if [ "$DRY_RUN" = true ]; then
+ echo "[DRY-RUN] Would download and run NetBird install script"
+ else
+ curl -fsSL https://pkgs.netbird.io/install.sh | sh || echo "Warning: NetBird installation failed, continuing..."
+
+ echo ""
+ echo "NetBird installed. Setup instructions:"
+ echo " 1. Create account at https://app.netbird.io (or self-host)"
+ echo " 2. Run 'netbird up' and authenticate via browser"
+ echo ""
+ echo "For NetBird SSH functionality:"
+ echo " • Enable SSH in NetBird dashboard settings"
+ echo " • Use 'netbird ssh ' to connect to peers"
+ echo " • NetBird manages SSH keys automatically when using 'netbird ssh'"
+ echo " • Traditional SSH also works using peer IPs from 'netbird status'"
+ echo " • Configure ACL rules in dashboard for SSH access (port 22)"
+ echo ""
+ fi
+else
+ echo "Skipping NetBird installation."
+fi
+
+# WireGuard Installation
+echo ""
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+echo "WIREGUARD VPN (Optional)"
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+echo ""
+echo "WireGuard is a fast, modern VPN protocol."
+echo " • Lightweight and high-performance"
+echo " • Simple configuration via config files"
+echo " • Built into Linux kernel"
+echo ""
+
+if is_wireguard_installed; then
+ echo "WireGuard is already installed."
+ echo ""
+ prompt_yn "Reinstall WireGuard? (y/n):" "n" INSTALL_WIREGUARD
+else
+ prompt_yn "Install WireGuard? (y/n):" "n" INSTALL_WIREGUARD
+fi
+
+if [ "$INSTALL_WIREGUARD" = "y" ] || [ "$INSTALL_WIREGUARD" = "Y" ]; then
+ echo ""
+ echo "Installing WireGuard..."
+ echo ""
+
+ if [ "$DRY_RUN" = true ]; then
+ echo "[DRY-RUN] Would install wireguard wireguard-tools"
+ else
+ apt install -y wireguard wireguard-tools || echo "Warning: WireGuard installation failed, continuing..."
+
+ echo ""
+ echo "WireGuard installed. Setup instructions:"
+ echo ""
+ echo "Generate keys:"
+ echo " wg genkey | tee /etc/wireguard/privatekey | wg pubkey > /etc/wireguard/publickey"
+ echo ""
+ echo "Create config at /etc/wireguard/wg0.conf:"
+ echo " [Interface]"
+ echo " PrivateKey = "
+ echo " Address = 10.0.0.1/24"
+ echo " ListenPort = 51820"
+ echo ""
+ echo " [Peer]"
+ echo " PublicKey = "
+ echo " AllowedIPs = 10.0.0.2/32"
+ echo " Endpoint = peer.example.com:51820"
+ echo ""
+ echo "Start WireGuard:"
+ echo " sudo wg-quick up wg0"
+ echo " sudo systemctl enable wg-quick@wg0 # Start on boot"
+ echo ""
+ fi
+else
+ echo "Skipping WireGuard installation."
+fi
+
+# Tailscale Installation
+echo ""
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+echo "TAILSCALE VPN (Optional)"
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+echo ""
+echo "Tailscale is a zero-config mesh VPN built on WireGuard."
+echo " • Easy setup - just sign in"
+echo " • Built on WireGuard for performance"
+echo " • Automatic NAT traversal"
+echo " • Built-in SSH (Tailscale SSH)"
+echo ""
+
+if is_tailscale_installed; then
+ echo "Tailscale is already installed."
+ tailscale status 2>/dev/null || true
+ echo ""
+ prompt_yn "Reinstall Tailscale? (y/n):" "n" INSTALL_TAILSCALE
+else
+ prompt_yn "Install Tailscale? (y/n):" "n" INSTALL_TAILSCALE
+fi
+
+if [ "$INSTALL_TAILSCALE" = "y" ] || [ "$INSTALL_TAILSCALE" = "Y" ]; then
+ echo ""
+ echo "Installing Tailscale..."
+ echo ""
+
+ if [ "$DRY_RUN" = true ]; then
+ echo "[DRY-RUN] Would add Tailscale apt repository"
+ echo "[DRY-RUN] Would install tailscale"
+ else
+ # Add Tailscale's package signing key and repository
+ curl -fsSL https://pkgs.tailscale.com/stable/ubuntu/jammy.noarmor.gpg | tee /usr/share/keyrings/tailscale-archive-keyring.gpg >/dev/null
+ curl -fsSL https://pkgs.tailscale.com/stable/ubuntu/jammy.tailscale-keyring.list | tee /etc/apt/sources.list.d/tailscale.list
+
+ apt update
+ apt install -y tailscale || echo "Warning: Tailscale installation failed, continuing..."
+
+ echo ""
+ echo "Tailscale installed. Setup instructions:"
+ echo ""
+ echo "Connect to Tailscale network:"
+ echo " sudo tailscale up"
+ echo ""
+ echo "This opens a browser to authenticate. After that:"
+ echo " tailscale status # View connected devices"
+ echo " tailscale ip # Show your Tailscale IP"
+ echo ""
+ echo "Tailscale SSH (optional - enable in admin console):"
+ echo " • Enable 'SSH' in Tailscale admin console for this machine"
+ echo " • Connect with: ssh user@device-name (uses Tailscale identity)"
+ echo " • No SSH keys needed - Tailscale handles authentication"
+ echo ""
+ fi
+else
+ echo "Skipping Tailscale installation."
+fi
+
+fi # End SKIP_VPN_CONFIG
+
+# On rerun, skip remote desktop configuration if already set up
+SKIP_RD_CONFIG=false
+if is_rustdesk_installed || is_teamviewer_installed || is_meshcentral_installed; then
+ echo ""
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo "REMOTE DESKTOP SERVICES"
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo ""
+ echo "Remote desktop services already configured:"
+ is_rustdesk_installed && echo " ✓ RustDesk"
+ is_teamviewer_installed && echo " ✓ TeamViewer"
+ is_meshcentral_installed && echo " ✓ MeshCentral Agent"
+ echo ""
+ prompt_yn "Reconfigure remote desktop services? (y/n):" "n" RECONFIG_RD
+ if [ "$RECONFIG_RD" != "y" ] && [ "$RECONFIG_RD" != "Y" ]; then
+ SKIP_RD_CONFIG=true
+ fi
+fi
+
+if [ "$SKIP_RD_CONFIG" != true ]; then
+
+# RustDesk Installation
+echo ""
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+echo "RUSTDESK REMOTE DESKTOP (Optional)"
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+echo ""
+echo "RustDesk is an open-source remote desktop software."
+echo ""
+
+if is_rustdesk_installed; then
+ echo "RustDesk is already installed."
+ echo ""
+ prompt_yn "Reinstall RustDesk? (y/n):" "n" INSTALL_RUSTDESK
+else
+ prompt_yn "Install RustDesk? (y/n):" "n" INSTALL_RUSTDESK
+fi
+
+if [ "$INSTALL_RUSTDESK" = "y" ] || [ "$INSTALL_RUSTDESK" = "Y" ]; then
+ echo ""
+ echo "Installing RustDesk..."
+ echo " - RustDesk: Open-source remote desktop software"
+ echo ""
+
+ if [ "$DRY_RUN" = true ]; then
+ echo "[DRY-RUN] Would download latest RustDesk from GitHub"
+ echo "[DRY-RUN] Would install rustdesk .deb package"
+ else
+ # Download latest RustDesk .deb package
+ RUSTDESK_VERSION=$(curl -s https://api.github.com/repos/rustdesk/rustdesk/releases/latest | grep -oP '"tag_name": "\K(.*)(?=")')
+ RUSTDESK_URL="https://github.com/rustdesk/rustdesk/releases/download/${RUSTDESK_VERSION}/rustdesk-${RUSTDESK_VERSION}-x86_64.deb"
+
+ wget -O /tmp/rustdesk.deb "$RUSTDESK_URL" || echo "Warning: RustDesk download failed, continuing..."
+
+ if [ -f /tmp/rustdesk.deb ]; then
+ apt install -y /tmp/rustdesk.deb || echo "Warning: RustDesk installation failed, continuing..."
+ rm /tmp/rustdesk.deb
+ echo "✓ RustDesk installed"
+ fi
+ fi
+else
+ echo "Skipping RustDesk installation."
+fi
+
+# TeamViewer Installation
+echo ""
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+echo "TEAMVIEWER REMOTE DESKTOP (Optional)"
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+echo ""
+echo "TeamViewer is commercial remote desktop software with a free tier."
+echo " • Cross-platform (Windows, Mac, Linux, mobile)"
+echo " • Easy to use - no port forwarding needed"
+echo " • Requires TeamViewer account for unattended access"
+echo ""
+
+if is_teamviewer_installed; then
+ echo "TeamViewer is already installed."
+ echo ""
+ prompt_yn "Reinstall TeamViewer? (y/n):" "n" INSTALL_TEAMVIEWER
+else
+ prompt_yn "Install TeamViewer? (y/n):" "n" INSTALL_TEAMVIEWER
+fi
+
+if [ "$INSTALL_TEAMVIEWER" = "y" ] || [ "$INSTALL_TEAMVIEWER" = "Y" ]; then
+ echo ""
+ echo "Installing TeamViewer..."
+ echo ""
+
+ if [ "$DRY_RUN" = true ]; then
+ echo "[DRY-RUN] Would download TeamViewer .deb package"
+ echo "[DRY-RUN] Would install teamviewer"
+ else
+ # Download TeamViewer .deb package
+ wget -O /tmp/teamviewer.deb "https://download.teamviewer.com/download/linux/teamviewer_amd64.deb" || echo "Warning: TeamViewer download failed, continuing..."
+
+ if [ -f /tmp/teamviewer.deb ]; then
+ apt install -y /tmp/teamviewer.deb || echo "Warning: TeamViewer installation failed, continuing..."
+ rm /tmp/teamviewer.deb
+
+ echo ""
+ echo "TeamViewer installed. Setup instructions:"
+ echo ""
+ echo "Start TeamViewer:"
+ echo " teamviewer"
+ echo ""
+ echo "For unattended access:"
+ echo " 1. Open TeamViewer"
+ echo " 2. Go to Extras → Options → Security"
+ echo " 3. Set a personal password for unattended access"
+ echo " 4. Note your TeamViewer ID (shown in main window)"
+ echo ""
+ echo "✓ TeamViewer installed"
+ fi
+ fi
+else
+ echo "Skipping TeamViewer installation."
+fi
+
+# MeshCentral Installation
+echo ""
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+echo "MESHCENTRAL AGENT (Optional)"
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+echo ""
+echo "MeshCentral is an open-source remote management solution."
+echo " • Self-hosted or use public servers"
+echo " • Web-based remote desktop (no client software needed)"
+echo " • Terminal, file transfer, and remote desktop"
+echo " • Requires a MeshCentral server to connect to"
+echo ""
+
+if is_meshcentral_installed; then
+ echo "MeshCentral Agent is already installed."
+ echo ""
+ prompt_yn "Reinstall MeshCentral Agent? (y/n):" "n" INSTALL_MESHCENTRAL
+else
+ prompt_yn "Install MeshCentral Agent? (y/n):" "n" INSTALL_MESHCENTRAL
+fi
+
+if [ "$INSTALL_MESHCENTRAL" = "y" ] || [ "$INSTALL_MESHCENTRAL" = "Y" ]; then
+ echo ""
+ echo "MeshCentral requires a server URL to connect to."
+ echo ""
+ echo "If you have a MeshCentral server, the agent install is typically done by:"
+ echo " 1. Log into your MeshCentral web interface"
+ echo " 2. Go to 'My Devices' → 'Add Agent'"
+ echo " 3. Download and run the Linux agent installer"
+ echo ""
+ echo "Example (replace with your server's URL):"
+ echo " wget -O meshagent https://your-meshcentral-server/meshagents?id=XXXXX"
+ echo " chmod +x meshagent"
+ echo " sudo ./meshagent -install"
+ echo ""
+
+ if [ "$DRY_RUN" = true ]; then
+ echo "[DRY-RUN] Would prompt for MeshCentral server URL"
+ echo "[DRY-RUN] Would download and install meshagent"
+ else
+ prompt_text "Enter MeshCentral agent URL (or leave blank to skip):" "" MESHCENTRAL_URL
+
+ if [ -n "$MESHCENTRAL_URL" ]; then
+ wget -O /tmp/meshagent "$MESHCENTRAL_URL" || echo "Warning: MeshCentral agent download failed"
+
+ if [ -f /tmp/meshagent ]; then
+ chmod +x /tmp/meshagent
+ /tmp/meshagent -install || echo "Warning: MeshCentral agent installation failed"
+ rm /tmp/meshagent
+ echo ""
+ echo "✓ MeshCentral Agent installed"
+ echo " Check your MeshCentral server - this device should appear shortly."
+ fi
+ else
+ echo ""
+ echo "No URL provided. Skipping MeshCentral agent installation."
+ echo "You can install later by downloading the agent from your MeshCentral server."
+ fi
+ fi
+else
+ echo "Skipping MeshCentral installation."
+fi
+
+fi # End SKIP_RD_CONFIG
+
+# ============================================================================
+# SELF-HOSTED DOCKER APPLICATIONS (Optional)
+# ============================================================================
+# These applications run in Docker containers using docker-compose
+# Each app is installed to ~/docker/{appname}/
+
+echo ""
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+echo "SELF-HOSTED DOCKER APPLICATIONS (Optional)"
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+echo ""
+echo "Install self-hosted applications using Docker Compose."
+echo "Each app will be installed to ~/docker/{appname}/"
+echo ""
+echo "Note: Docker must be installed for these applications."
+echo ""
+
+if ! is_docker_installed && [ "$INSTALL_DOCKER" != "y" ] && [ "$INSTALL_DOCKER" != "Y" ]; then
+ echo "Docker is not installed. Skipping self-hosted applications."
+ echo "Install Docker first, then rerun this script."
+else
+ # Create docker apps directory
+ DOCKER_DIR="$ACTUAL_HOME/docker"
+ if [ "$DRY_RUN" != true ]; then
+ mkdir -p "$DOCKER_DIR"
+ chown "$ACTUAL_USER:$ACTUAL_USER" "$DOCKER_DIR"
+ fi
+
+ # ============================================================================
+ # DETECT DRIVES (ONCE FOR ALL SERVICES)
+ # ============================================================================
+ detect_drives
+
+ # ============================================================================
+ # SERVICE SELECTION MENU
+ # ============================================================================
+
+ # Use whiptail for service selection if available
+ if command -v whiptail &> /dev/null; then
+ # Detect existing services
+ echo "Detecting existing services..."
+ declare -A EXISTING_SERVICES
+ [ -d "$DOCKER_DIR/immich" ] && EXISTING_SERVICES[IMMICH]="ON"
+ [ -d "$DOCKER_DIR/audiobookshelf" ] && EXISTING_SERVICES[AUDIOBOOKSHELF]="ON"
+ [ -d "$DOCKER_DIR/emby" ] && EXISTING_SERVICES[EMBY]="ON"
+ [ -d "$DOCKER_DIR/arm" ] && EXISTING_SERVICES[ARM]="ON"
+ [ -d "$DOCKER_DIR/filebrowser" ] && EXISTING_SERVICES[FILEBROWSER]="ON"
+ [ -d "$DOCKER_DIR/magicmirror" ] && EXISTING_SERVICES[MAGICMIRROR]="ON"
+ [ -d "$DOCKER_DIR/actualbudget" ] && EXISTING_SERVICES[ACTUALBUDGET]="ON"
+ [ -d "$DOCKER_DIR/keycloak" ] && EXISTING_SERVICES[KEYCLOAK]="ON"
+ [ -d "$DOCKER_DIR/caddy" ] && EXISTING_SERVICES[CADDY]="ON"
+ [ -d "$DOCKER_DIR/lms" ] && EXISTING_SERVICES[LYRION]="ON"
+ [ -d "$DOCKER_DIR/mealie" ] && EXISTING_SERVICES[MEALIE]="ON"
+ [ -d "$DOCKER_DIR/minecraft" ] && EXISTING_SERVICES[MINECRAFT]="ON"
+ [ -d "$DOCKER_DIR/jellyfin" ] && EXISTING_SERVICES[JELLYFIN]="ON"
+ [ -d "$DOCKER_DIR/frigate" ] && EXISTING_SERVICES[FRIGATE]="ON"
+ [ -d "$DOCKER_DIR/ntfy" ] && EXISTING_SERVICES[NTFY]="ON"
+ [ -d "$DOCKER_DIR/uptime-kuma" ] && EXISTING_SERVICES[UPTIMEKUMA]="ON"
+ [ -d "$DOCKER_DIR/wg-easy" ] && EXISTING_SERVICES[WGEASY]="ON"
+ [ -d "$DOCKER_DIR/traccar" ] && EXISTING_SERVICES[TRACCAR]="ON"
+ [ -d "$DOCKER_DIR/portainer" ] && EXISTING_SERVICES[PORTAINER]="ON"
+ [ -d "$DOCKER_DIR/meshcentral" ] && EXISTING_SERVICES[MESHCENTRAL]="ON"
+ [ -d "$DOCKER_DIR/fmd" ] && EXISTING_SERVICES[FINDMYDEVICE]="ON"
+ [ -d "$DOCKER_DIR/frigate-notify" ] && EXISTING_SERVICES[FRIGATE_NOTIFY]="ON"
+ [ -d "$DOCKER_DIR/watchtower" ] && EXISTING_SERVICES[WATCHTOWER]="ON"
+ command -v fail2ban-client &> /dev/null && EXISTING_SERVICES[FAIL2BAN]="ON"
+
+ # Ask user what action to perform
+ ACTION=$(whiptail --title "Service Management" --menu \
+ "Choose an action:" 15 60 3 \
+ "1" "Install new services" \
+ "2" "Uninstall existing services" \
+ "3" "Cancel and skip" \
+ 3>&1 1>&2 2>&3)
+
+ case "$ACTION" in
+ 1)
+ # INSTALL MODE
+ MENU_MODE="INSTALL"
+ # Build checklist - mark existing services as ON
+ SELECTED_SERVICES=$(whiptail --title "Select Docker Services to Install" \
+ --checklist "Use SPACE to select, ENTER to confirm. [*] = already installed" 25 78 17 \
+ "IMMICH" "Photo & video backup (like Google Photos)" ${EXISTING_SERVICES[IMMICH]:-OFF} \
+ "AUDIOBOOKSHELF" "Audiobook & podcast server" ${EXISTING_SERVICES[AUDIOBOOKSHELF]:-OFF} \
+ "EMBY" "Media server for movies, TV, music" ${EXISTING_SERVICES[EMBY]:-OFF} \
+ "ARM" "Automatic Ripping Machine for DVDs/Blu-rays" ${EXISTING_SERVICES[ARM]:-OFF} \
+ "FILEBROWSER" "Web-based file manager" ${EXISTING_SERVICES[FILEBROWSER]:-OFF} \
+ "MAGICMIRROR" "Smart mirror / dashboard display" ${EXISTING_SERVICES[MAGICMIRROR]:-OFF} \
+ "ACTUALBUDGET" "Personal finance management with bank sync" ${EXISTING_SERVICES[ACTUALBUDGET]:-OFF} \
+ "KEYCLOAK" "Identity & Access Management (SSO)" ${EXISTING_SERVICES[KEYCLOAK]:-OFF} \
+ "CADDY" "Reverse proxy with automatic HTTPS" ${EXISTING_SERVICES[CADDY]:-OFF} \
+ "FAIL2BAN" "Intrusion prevention system" ${EXISTING_SERVICES[FAIL2BAN]:-OFF} \
+ "LYRION" "Music streaming server (LMS)" ${EXISTING_SERVICES[LYRION]:-OFF} \
+ "MEALIE" "Recipe manager & meal planner" ${EXISTING_SERVICES[MEALIE]:-OFF} \
+ "MINECRAFT" "Minecraft game server" ${EXISTING_SERVICES[MINECRAFT]:-OFF} \
+ "JELLYFIN" "Free media server (Emby alternative)" ${EXISTING_SERVICES[JELLYFIN]:-OFF} \
+ "FRIGATE" "AI-powered NVR for security cameras" ${EXISTING_SERVICES[FRIGATE]:-OFF} \
+ "NTFY" "Push notifications server" ${EXISTING_SERVICES[NTFY]:-OFF} \
+ "UPTIMEKUMA" "Service monitoring dashboard" ${EXISTING_SERVICES[UPTIMEKUMA]:-OFF} \
+ "WGEASY" "WireGuard VPN with web UI" ${EXISTING_SERVICES[WGEASY]:-OFF} \
+ "TRACCAR" "GPS tracking server" ${EXISTING_SERVICES[TRACCAR]:-OFF} \
+ "PORTAINER" "Docker management web UI" ${EXISTING_SERVICES[PORTAINER]:-OFF} \
+ "MESHCENTRAL" "Remote management server" ${EXISTING_SERVICES[MESHCENTRAL]:-OFF} \
+ "FINDMYDEVICE" "Device tracking (like Find My)" ${EXISTING_SERVICES[FINDMYDEVICE]:-OFF} \
+ "FRIGATE_NOTIFY" "Push notifications for Frigate" ${EXISTING_SERVICES[FRIGATE_NOTIFY]:-OFF} \
+ "WATCHTOWER" "Automatic container updates" ${EXISTING_SERVICES[WATCHTOWER]:-OFF} \
+ 3>&1 1>&2 2>&3)
+ ;;
+ 2)
+ # UNINSTALL MODE
+ MENU_MODE="UNINSTALL"
+ # Only show services that exist
+ UNINSTALL_OPTIONS=""
+ [ -n "${EXISTING_SERVICES[IMMICH]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS IMMICH \"Photo & video backup\" ON"
+ [ -n "${EXISTING_SERVICES[AUDIOBOOKSHELF]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS AUDIOBOOKSHELF \"Audiobook server\" ON"
+ [ -n "${EXISTING_SERVICES[EMBY]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS EMBY \"Media server\" ON"
+ [ -n "${EXISTING_SERVICES[ARM]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS ARM \"Automatic Ripping Machine\" ON"
+ [ -n "${EXISTING_SERVICES[FILEBROWSER]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS FILEBROWSER \"Web file manager\" ON"
+ [ -n "${EXISTING_SERVICES[MAGICMIRROR]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS MAGICMIRROR \"Smart mirror\" ON"
+ [ -n "${EXISTING_SERVICES[ACTUALBUDGET]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS ACTUALBUDGET \"Personal finance\" ON"
+ [ -n "${EXISTING_SERVICES[KEYCLOAK]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS KEYCLOAK \"Identity management\" ON"
+ [ -n "${EXISTING_SERVICES[CADDY]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS CADDY \"Reverse proxy\" ON"
+ [ -n "${EXISTING_SERVICES[FAIL2BAN]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS FAIL2BAN \"Intrusion prevention\" ON"
+ [ -n "${EXISTING_SERVICES[LYRION]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS LYRION \"Music server\" ON"
+ [ -n "${EXISTING_SERVICES[MEALIE]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS MEALIE \"Recipe manager\" ON"
+ [ -n "${EXISTING_SERVICES[MINECRAFT]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS MINECRAFT \"Game server\" ON"
+ [ -n "${EXISTING_SERVICES[JELLYFIN]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS JELLYFIN \"Media server\" ON"
+ [ -n "${EXISTING_SERVICES[FRIGATE]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS FRIGATE \"NVR cameras\" ON"
+ [ -n "${EXISTING_SERVICES[NTFY]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS NTFY \"Push notifications\" ON"
+ [ -n "${EXISTING_SERVICES[UPTIMEKUMA]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS UPTIMEKUMA \"Service monitoring\" ON"
+ [ -n "${EXISTING_SERVICES[WGEASY]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS WGEASY \"WireGuard VPN\" ON"
+ [ -n "${EXISTING_SERVICES[TRACCAR]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS TRACCAR \"GPS tracking\" ON"
+ [ -n "${EXISTING_SERVICES[PORTAINER]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS PORTAINER \"Docker management\" ON"
+ [ -n "${EXISTING_SERVICES[MESHCENTRAL]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS MESHCENTRAL \"Remote management\" ON"
+ [ -n "${EXISTING_SERVICES[FINDMYDEVICE]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS FINDMYDEVICE \"Device tracking\" ON"
+ [ -n "${EXISTING_SERVICES[FRIGATE_NOTIFY]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS FRIGATE_NOTIFY \"Frigate notifications\" ON"
+ [ -n "${EXISTING_SERVICES[WATCHTOWER]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS WATCHTOWER \"Container updates\" ON"
+
+ if [ -z "$UNINSTALL_OPTIONS" ]; then
+ whiptail --title "No Services Found" --msgbox "No services detected to uninstall." 8 50
+ SELECTED_SERVICES=""
+ else
+ SELECTED_SERVICES=$(eval "whiptail --title 'Select Services to Uninstall' \
+ --checklist 'WARNING: Data will be backed up but services will be removed' 25 78 17 \
+ $UNINSTALL_OPTIONS \
+ 3>&1 1>&2 2>&3")
+ fi
+ ;;
+ 3|*)
+ # Cancel
+ SELECTED_SERVICES=""
+ MENU_MODE="CANCEL"
+ ;;
+ esac
+
+ # Check if user cancelled
+ if [ $? -ne 0 ]; then
+ echo "Service selection cancelled. Skipping Docker applications."
+ SELECTED_SERVICES=""
+ fi
+
+ # Parse selections (whiptail returns quoted strings)
+ # Only set to "n" if not already set (preserve any earlier choices)
+ : ${INSTALL_IMMICH:="n"}
+ : ${INSTALL_AUDIOBOOKSHELF:="n"}
+ : ${INSTALL_EMBY:="n"}
+ : ${INSTALL_ARM:="n"}
+ : ${INSTALL_FILEBROWSER:="n"}
+ : ${INSTALL_MAGICMIRROR:="n"}
+ : ${INSTALL_ACTUALBUDGET:="n"}
+ : ${INSTALL_KEYCLOAK:="n"}
+ : ${INSTALL_CADDY:="n"}
+ : ${INSTALL_FAIL2BAN:="n"}
+ : ${INSTALL_LMS:="n"}
+ : ${INSTALL_MEALIE:="n"}
+ : ${INSTALL_MINECRAFT:="n"}
+ : ${INSTALL_JELLYFIN:="n"}
+ : ${INSTALL_FRIGATE:="n"}
+ : ${INSTALL_NTFY:="n"}
+ : ${INSTALL_UPTIMEKUMA:="n"}
+ : ${INSTALL_WGEASY:="n"}
+ : ${INSTALL_TRACCAR:="n"}
+ : ${INSTALL_PORTAINER:="n"}
+ : ${INSTALL_MESHCENTRAL_SERVER:="n"}
+ : ${INSTALL_FMD:="n"}
+ : ${INSTALL_FRIGATE_NOTIFY:="n"}
+ : ${INSTALL_WATCHTOWER:="n"}
+
+ # Set installation flags based on selections
+ if echo "$SELECTED_SERVICES" | grep -q "IMMICH"; then INSTALL_IMMICH="y"; fi
+ if echo "$SELECTED_SERVICES" | grep -q "AUDIOBOOKSHELF"; then INSTALL_AUDIOBOOKSHELF="y"; fi
+ if echo "$SELECTED_SERVICES" | grep -q "EMBY"; then INSTALL_EMBY="y"; fi
+ if echo "$SELECTED_SERVICES" | grep -q "ARM"; then INSTALL_ARM="y"; fi
+ if echo "$SELECTED_SERVICES" | grep -q "FILEBROWSER"; then INSTALL_FILEBROWSER="y"; fi
+ if echo "$SELECTED_SERVICES" | grep -q "MAGICMIRROR"; then INSTALL_MAGICMIRROR="y"; fi
+ if echo "$SELECTED_SERVICES" | grep -q "ACTUALBUDGET"; then INSTALL_ACTUALBUDGET="y"; fi
+ if echo "$SELECTED_SERVICES" | grep -q "KEYCLOAK"; then INSTALL_KEYCLOAK="y"; fi
+ if echo "$SELECTED_SERVICES" | grep -q "CADDY"; then INSTALL_CADDY="y"; fi
+ if echo "$SELECTED_SERVICES" | grep -q "FAIL2BAN"; then INSTALL_FAIL2BAN="y"; fi
+ if echo "$SELECTED_SERVICES" | grep -q "LYRION"; then INSTALL_LMS="y"; fi
+ if echo "$SELECTED_SERVICES" | grep -q "MEALIE"; then INSTALL_MEALIE="y"; fi
+ if echo "$SELECTED_SERVICES" | grep -q "MINECRAFT"; then INSTALL_MINECRAFT="y"; fi
+ if echo "$SELECTED_SERVICES" | grep -q "JELLYFIN"; then INSTALL_JELLYFIN="y"; fi
+ if echo "$SELECTED_SERVICES" | grep -q "FRIGATE\""; then INSTALL_FRIGATE="y"; fi
+ if echo "$SELECTED_SERVICES" | grep -q "NTFY"; then INSTALL_NTFY="y"; fi
+ if echo "$SELECTED_SERVICES" | grep -q "UPTIMEKUMA"; then INSTALL_UPTIMEKUMA="y"; fi
+ if echo "$SELECTED_SERVICES" | grep -q "WGEASY"; then INSTALL_WGEASY="y"; fi
+ if echo "$SELECTED_SERVICES" | grep -q "TRACCAR"; then INSTALL_TRACCAR="y"; fi
+ if echo "$SELECTED_SERVICES" | grep -q "PORTAINER"; then INSTALL_PORTAINER="y"; fi
+ if echo "$SELECTED_SERVICES" | grep -q "MESHCENTRAL"; then INSTALL_MESHCENTRAL_SERVER="y"; fi
+ if echo "$SELECTED_SERVICES" | grep -q "FINDMYDEVICE"; then INSTALL_FMD="y"; fi
+ if echo "$SELECTED_SERVICES" | grep -q "FRIGATE_NOTIFY"; then INSTALL_FRIGATE_NOTIFY="y"; fi
+ if echo "$SELECTED_SERVICES" | grep -q "WATCHTOWER"; then INSTALL_WATCHTOWER="y"; fi
+
+ if [ "$MENU_MODE" = "UNINSTALL" ]; then
+ echo ""
+ echo "Services selected for uninstallation:"
+ echo "$SELECTED_SERVICES" | tr '"' '\n' | grep -v '^$' | sed 's/^/ - /'
+ echo ""
+
+ # Process uninstalls
+ if [ -n "$SELECTED_SERVICES" ]; then
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo " UNINSTALLING SERVICES"
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo ""
+
+ # Define uninstall function
+ uninstall_service() {
+ local SERVICE_NAME=$1
+ local SERVICE_DIR=$2
+ local CONTAINER_NAME=$3
+
+ echo "Uninstalling $SERVICE_NAME..."
+
+ if [ -d "$SERVICE_DIR" ]; then
+ cd "$SERVICE_DIR" 2>/dev/null || return
+
+ # Stop and remove containers
+ if [ -f "docker-compose.yml" ]; then
+ echo " Stopping containers..."
+ docker compose down 2>/dev/null || docker-compose down 2>/dev/null || true
+ fi
+
+ # Backup data
+ BACKUP_DIR="$DOCKER_DIR/backups/$(date +%Y%m%d-%H%M%S)-$SERVICE_NAME"
+ mkdir -p "$BACKUP_DIR"
+ echo " Backing up to $BACKUP_DIR..."
+ cp -r "$SERVICE_DIR" "$BACKUP_DIR/" 2>/dev/null || true
+
+ # Remove service directory
+ echo " Removing $SERVICE_DIR..."
+ rm -rf "$SERVICE_DIR"
+
+ echo " ✓ $SERVICE_NAME uninstalled (backup: $BACKUP_DIR)"
+ else
+ echo " ⚠ $SERVICE_NAME directory not found, skipping"
+ fi
+ echo ""
+ }
+
+ # Uninstall selected services
+ if echo "$SELECTED_SERVICES" | grep -q "IMMICH"; then uninstall_service "Immich" "$DOCKER_DIR/immich" "immich"; fi
+ if echo "$SELECTED_SERVICES" | grep -q "AUDIOBOOKSHELF"; then uninstall_service "AudioBookshelf" "$DOCKER_DIR/audiobookshelf" "audiobookshelf"; fi
+ if echo "$SELECTED_SERVICES" | grep -q "EMBY"; then uninstall_service "Emby" "$DOCKER_DIR/emby" "emby"; fi
+ if echo "$SELECTED_SERVICES" | grep -q "ARM"; then uninstall_service "ARM" "$DOCKER_DIR/arm" "arm"; fi
+ if echo "$SELECTED_SERVICES" | grep -q "FILEBROWSER"; then uninstall_service "FileBrowser" "$DOCKER_DIR/filebrowser" "filebrowser"; fi
+ if echo "$SELECTED_SERVICES" | grep -q "MAGICMIRROR"; then uninstall_service "MagicMirror" "$DOCKER_DIR/magicmirror" "magicmirror"; fi
+ if echo "$SELECTED_SERVICES" | grep -q "ACTUALBUDGET"; then uninstall_service "ActualBudget" "$DOCKER_DIR/actualbudget" "actualbudget"; fi
+ if echo "$SELECTED_SERVICES" | grep -q "KEYCLOAK"; then uninstall_service "Keycloak" "$DOCKER_DIR/keycloak" "keycloak"; fi
+ if echo "$SELECTED_SERVICES" | grep -q "CADDY"; then uninstall_service "Caddy" "$DOCKER_DIR/caddy" "caddy"; fi
+ if echo "$SELECTED_SERVICES" | grep -q "LYRION"; then uninstall_service "Lyrion" "$DOCKER_DIR/lms" "lms"; fi
+ if echo "$SELECTED_SERVICES" | grep -q "MEALIE"; then uninstall_service "Mealie" "$DOCKER_DIR/mealie" "mealie"; fi
+ if echo "$SELECTED_SERVICES" | grep -q "MINECRAFT"; then uninstall_service "Minecraft" "$DOCKER_DIR/minecraft" "minecraft"; fi
+ if echo "$SELECTED_SERVICES" | grep -q "JELLYFIN"; then uninstall_service "Jellyfin" "$DOCKER_DIR/jellyfin" "jellyfin"; fi
+ if echo "$SELECTED_SERVICES" | grep -q "FRIGATE\""; then uninstall_service "Frigate" "$DOCKER_DIR/frigate" "frigate"; fi
+ if echo "$SELECTED_SERVICES" | grep -q "NTFY"; then uninstall_service "ntfy" "$DOCKER_DIR/ntfy" "ntfy"; fi
+ if echo "$SELECTED_SERVICES" | grep -q "UPTIMEKUMA"; then uninstall_service "Uptime Kuma" "$DOCKER_DIR/uptime-kuma" "uptime-kuma"; fi
+ if echo "$SELECTED_SERVICES" | grep -q "WGEASY"; then uninstall_service "wg-easy" "$DOCKER_DIR/wg-easy" "wg-easy"; fi
+ if echo "$SELECTED_SERVICES" | grep -q "TRACCAR"; then uninstall_service "Traccar" "$DOCKER_DIR/traccar" "traccar"; fi
+ if echo "$SELECTED_SERVICES" | grep -q "PORTAINER"; then uninstall_service "Portainer" "$DOCKER_DIR/portainer" "portainer"; fi
+ if echo "$SELECTED_SERVICES" | grep -q "MESHCENTRAL"; then uninstall_service "MeshCentral" "$DOCKER_DIR/meshcentral" "meshcentral"; fi
+ if echo "$SELECTED_SERVICES" | grep -q "FINDMYDEVICE"; then uninstall_service "FindMyDevice" "$DOCKER_DIR/fmd" "fmd"; fi
+ if echo "$SELECTED_SERVICES" | grep -q "FRIGATE_NOTIFY"; then uninstall_service "Frigate-Notify" "$DOCKER_DIR/frigate-notify" "frigate-notify"; fi
+ if echo "$SELECTED_SERVICES" | grep -q "WATCHTOWER"; then uninstall_service "Watchtower" "$DOCKER_DIR/watchtower" "watchtower"; fi
+
+ # Special handling for fail2ban (system package)
+ if echo "$SELECTED_SERVICES" | grep -q "FAIL2BAN"; then
+ echo "Uninstalling fail2ban..."
+ echo " Stopping fail2ban service..."
+ systemctl stop fail2ban 2>/dev/null || true
+ systemctl disable fail2ban 2>/dev/null || true
+ echo " Removing fail2ban package..."
+ apt-get remove --purge -y fail2ban 2>/dev/null || true
+ echo " ✓ fail2ban uninstalled"
+ echo ""
+ fi
+
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo " Uninstallation complete!"
+ echo " Backups saved to: $DOCKER_DIR/backups/"
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo ""
+ fi
+
+ # After uninstall, exit the Docker apps section
+ WHIPTAIL_USED=true
+
+ elif [ "$MENU_MODE" = "INSTALL" ]; then
+ echo ""
+ echo "Selected services:"
+ echo "$SELECTED_SERVICES" | tr '"' '\n' | grep -v '^$' | sed 's/^/ - /'
+ echo ""
+
+ # Mark that whiptail was used - skip all individual prompts
+ WHIPTAIL_USED=true
+ else
+ # Cancel or other mode
+ WHIPTAIL_USED=false
+ fi
+ else
+ # Whiptail not available - will use individual prompts
+ WHIPTAIL_USED=false
+ MENU_MODE="INSTALL"
+ fi
+
+ # Skip installation section if we just did uninstalls
+ if [ "$MENU_MODE" = "UNINSTALL" ]; then
+ echo "Skipping installation section (uninstall mode was selected)."
+ # Jump to end of Docker section by setting a flag
+ SKIP_DOCKER_INSTALLS=true
+ else
+ SKIP_DOCKER_INSTALLS=false
+ fi
+
+ # ============================================================================
+ # DOCKER SERVICE INSTALLATIONS
+ # ============================================================================
+
+ if [ "$SKIP_DOCKER_INSTALLS" != true ]; then
+ # ---- IMMICH ----
+ if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_IMMICH" ]; then
+ echo ""
+ echo "┌─────────────────────────────────────────────────────────────────┐"
+ echo "│ IMMICH - Self-hosted photo & video backup │"
+ echo "│ Like Google Photos but private. Mobile app auto-uploads. │"
+ echo "│ Port: 2283 │"
+ echo "└─────────────────────────────────────────────────────────────────┘"
+ prompt_yn "Install Immich? (y/n):" "n" INSTALL_IMMICH
+ fi
+
+ if [ "$INSTALL_IMMICH" = "y" ] || [ "$INSTALL_IMMICH" = "Y" ]; then
+ IMMICH_DIR="$DOCKER_DIR/immich"
+
+ # Check if already installed and ask what to do
+ check_service_exists "Immich" "$IMMICH_DIR" IMMICH_RECONFIGURE
+
+ if [ "$IMMICH_RECONFIGURE" = "true" ]; then
+ echo "Installing Immich..."
+
+ # ── Photo library setup ─────────────────────────────────────
+ echo ""
+ echo "┌─────────────────────────────────────────────────────────────────┐"
+ echo "│ PHOTO LIBRARY SETUP │"
+ echo "└─────────────────────────────────────────────────────────────────┘"
+ echo ""
+
+ DEFAULT_PHOTOS_DIR="$PRIMARY_DRIVE_PATH/photos"
+ IMMICH_STRATEGY=""
+ EXTERNAL_LIBRARY=""
+ EXISTING_PHOTOS_SOURCE=""
+
+ prompt_yn " Do you have existing photos to include? (y/n):" "n" HAS_EXISTING_PHOTOS
+
+ if [ "$HAS_EXISTING_PHOTOS" = "y" ] || [ "$HAS_EXISTING_PHOTOS" = "Y" ]; then
+ echo ""
+ echo " How should Immich handle your existing photos?"
+ echo ""
+ echo " [1] Import everything into Immich (recommended)"
+ echo " Immich manages all photos in one unified library."
+ echo " Dates preserved via EXIF metadata. Organized by date."
+ echo " Your original folder names are NOT kept on disk"
+ echo " (use Immich albums to organize instead)."
+ echo ""
+ echo " [2] Keep existing photos in place (read-only external library)"
+ echo " Immich indexes your existing photos without moving them."
+ echo " New uploads go to a separate folder."
+ echo " Your folder structure stays intact."
+ echo ""
+
+ if [ "$UNATTENDED" = true ]; then
+ IMMICH_STRATEGY="1"
+ echo " Strategy: [auto: 1]"
+ else
+ read -p " Choose [1/2]: " IMMICH_STRATEGY
+ IMMICH_STRATEGY="${IMMICH_STRATEGY:-1}"
+ fi
+
+ if [ "$IMMICH_STRATEGY" = "2" ]; then
+ # Strategy 2: external library - ask where existing photos are
+ echo ""
+ echo " Where are your existing photos?"
+ echo " Default: $DEFAULT_PHOTOS_DIR"
+ prompt_text " Existing photos path:" "$DEFAULT_PHOTOS_DIR" EXISTING_PHOTOS_SOURCE 2>/dev/null || EXISTING_PHOTOS_SOURCE="$DEFAULT_PHOTOS_DIR"
+ EXISTING_PHOTOS_SOURCE="${EXISTING_PHOTOS_SOURCE/#\~/$ACTUAL_HOME}"
+ EXISTING_PHOTOS_SOURCE="${EXISTING_PHOTOS_SOURCE%/}"
+
+ UPLOAD_LOCATION="$PRIMARY_DRIVE_PATH/immich-uploads"
+ EXTERNAL_LIBRARY="$EXISTING_PHOTOS_SOURCE"
+
+ echo ""
+ echo " Setup:"
+ echo " Existing photos: $EXISTING_PHOTOS_SOURCE (read-only)"
+ echo " New uploads: $UPLOAD_LOCATION"
+ else
+ # Strategy 1 with existing photos: ask for library path, will import into it
+ echo ""
+ echo " Where should Immich store your photo library?"
+ echo " (Existing photos will be imported here)"
+ echo " Default: $DEFAULT_PHOTOS_DIR"
+ prompt_text " Photo library path:" "$DEFAULT_PHOTOS_DIR" PHOTOS_DIR 2>/dev/null || PHOTOS_DIR="$DEFAULT_PHOTOS_DIR"
+ PHOTOS_DIR="${PHOTOS_DIR/#\~/$ACTUAL_HOME}"
+ PHOTOS_DIR="${PHOTOS_DIR%/}"
+
+ echo ""
+ echo " Where are your existing photos now?"
+ echo " Default: $PHOTOS_DIR"
+ prompt_text " Existing photos path:" "$PHOTOS_DIR" EXISTING_PHOTOS_SOURCE 2>/dev/null || EXISTING_PHOTOS_SOURCE="$PHOTOS_DIR"
+ EXISTING_PHOTOS_SOURCE="${EXISTING_PHOTOS_SOURCE/#\~/$ACTUAL_HOME}"
+ EXISTING_PHOTOS_SOURCE="${EXISTING_PHOTOS_SOURCE%/}"
+
+ UPLOAD_LOCATION="$PHOTOS_DIR"
+ EXTERNAL_LIBRARY=""
+
+ echo ""
+ echo " All photos (existing + new) will live in:"
+ echo " $PHOTOS_DIR"
+ echo " └── 2026/01/filename.jpg organized by date"
+ fi
+ else
+ # No existing photos: simplest path - just ask where to store
+ IMMICH_STRATEGY="1"
+ echo ""
+ echo " Where should Immich store your photo library?"
+ echo " Default: $DEFAULT_PHOTOS_DIR"
+ prompt_text " Photo library path:" "$DEFAULT_PHOTOS_DIR" PHOTOS_DIR 2>/dev/null || PHOTOS_DIR="$DEFAULT_PHOTOS_DIR"
+ PHOTOS_DIR="${PHOTOS_DIR/#\~/$ACTUAL_HOME}"
+ PHOTOS_DIR="${PHOTOS_DIR%/}"
+
+ UPLOAD_LOCATION="$PHOTOS_DIR"
+ EXTERNAL_LIBRARY=""
+
+ echo ""
+ echo " All photos will be stored in:"
+ echo " $PHOTOS_DIR"
+ echo " └── 2026/01/filename.jpg organized by date"
+ fi
+ echo ""
+
+ if [ "$DRY_RUN" = true ]; then
+ echo "[DRY-RUN] Would create $IMMICH_DIR"
+ echo "[DRY-RUN] Would store uploads at $UPLOAD_LOCATION"
+ [ -n "$EXTERNAL_LIBRARY" ] && echo "[DRY-RUN] Would mount external library at $EXTERNAL_LIBRARY"
+ echo "[DRY-RUN] Would create docker-compose.yml and .env"
+ else
+ mkdir -p "$IMMICH_DIR" 2>/dev/null || true
+ mkdir -p "$UPLOAD_LOCATION" 2>/dev/null || true
+ [ -n "$EXTERNAL_LIBRARY" ] && mkdir -p "$EXTERNAL_LIBRARY" 2>/dev/null || true
+
+ # Create required subdirectories with .immich marker files
+ # Immich checks these on startup and fails if they're missing
+ for subdir in thumbs upload backups library profile encoded-video; do
+ mkdir -p "$UPLOAD_LOCATION/$subdir" 2>/dev/null || true
+ touch "$UPLOAD_LOCATION/$subdir/.immich" 2>/dev/null || true
+ done
+ cd "$IMMICH_DIR" 2>/dev/null || cd "$DOCKER_DIR"
+
+ # Create docker-compose.yml
+ if [ -n "$EXTERNAL_LIBRARY" ]; then
+ # Strategy 2: external library mount included
+ cat > docker-compose.yml << 'IMMICH_COMPOSE'
+name: immich
+
+services:
+ immich-server:
+ container_name: immich_server
+ image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release}
+ volumes:
+ - ${UPLOAD_LOCATION}:/usr/src/app/upload
+ - ${EXTERNAL_LIBRARY}:/usr/src/app/external:ro
+ - /etc/localtime:/etc/localtime:ro
+ env_file:
+ - .env
+ ports:
+ - 2283:2283
+ depends_on:
+ - redis
+ - database
+ restart: always
+ healthcheck:
+ disable: false
+
+ immich-machine-learning:
+ container_name: immich_machine_learning
+ image: ghcr.io/immich-app/immich-machine-learning:${IMMICH_VERSION:-release}
+ volumes:
+ - model-cache:/cache
+ env_file:
+ - .env
+ restart: always
+ healthcheck:
+ disable: false
+
+ redis:
+ container_name: immich_redis
+ image: docker.io/valkey/valkey:9-bookworm
+ healthcheck:
+ test: valkey-cli ping || exit 1
+ restart: always
+
+ database:
+ container_name: immich_postgres
+ image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0
+ environment:
+ POSTGRES_PASSWORD: ${DB_PASSWORD}
+ POSTGRES_USER: ${DB_USERNAME}
+ POSTGRES_DB: ${DB_DATABASE_NAME}
+ POSTGRES_INITDB_ARGS: '--data-checksums'
+ volumes:
+ - ${DB_DATA_LOCATION}:/var/lib/postgresql/data
+ restart: always
+
+volumes:
+ model-cache:
+IMMICH_COMPOSE
+ else
+ # Strategy 1: unified library, no external mount
+ cat > docker-compose.yml << 'IMMICH_COMPOSE'
+name: immich
+
+services:
+ immich-server:
+ container_name: immich_server
+ image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release}
+ volumes:
+ - ${UPLOAD_LOCATION}:/usr/src/app/upload
+ - /etc/localtime:/etc/localtime:ro
+ env_file:
+ - .env
+ ports:
+ - 2283:2283
+ depends_on:
+ - redis
+ - database
+ restart: always
+ healthcheck:
+ disable: false
+
+ immich-machine-learning:
+ container_name: immich_machine_learning
+ image: ghcr.io/immich-app/immich-machine-learning:${IMMICH_VERSION:-release}
+ volumes:
+ - model-cache:/cache
+ env_file:
+ - .env
+ restart: always
+ healthcheck:
+ disable: false
+
+ redis:
+ container_name: immich_redis
+ image: docker.io/valkey/valkey:9-bookworm
+ healthcheck:
+ test: valkey-cli ping || exit 1
+ restart: always
+
+ database:
+ container_name: immich_postgres
+ image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0
+ environment:
+ POSTGRES_PASSWORD: ${DB_PASSWORD}
+ POSTGRES_USER: ${DB_USERNAME}
+ POSTGRES_DB: ${DB_DATABASE_NAME}
+ POSTGRES_INITDB_ARGS: '--data-checksums'
+ volumes:
+ - ${DB_DATA_LOCATION}:/var/lib/postgresql/data
+ restart: always
+
+volumes:
+ model-cache:
+IMMICH_COMPOSE
+ fi
+
+ # Generate random password
+ DB_PASS=$(openssl rand -base64 32 | tr -dc 'a-zA-Z0-9' | head -c 32)
+
+ # Create .env file with strategy-appropriate comments
+ if [ "$IMMICH_STRATEGY" = "2" ]; then
+ cat > .env << IMMICH_ENV
+# ============================================================
+# IMMICH CONFIGURATION — External Library Mode
+# ============================================================
+#
+# STORAGE TEMPLATE (configure in Immich web UI):
+# Admin → Settings → Storage Template → Enable
+# Template: {{y}}/{{MM}}/{{filename}}
+# This organizes new uploads into: immich-uploads/2026/01/filename.jpg
+#
+# EXTERNAL LIBRARY SETUP:
+# Admin → External Libraries → Create Library
+# Import path: /usr/src/app/external
+# Click "Scan" to index your existing photos.
+#
+# ============================================================
+
+# New uploads from phone/web
+UPLOAD_LOCATION=$UPLOAD_LOCATION
+
+# Existing photos (read-only, indexed by Immich)
+# Mounted at /usr/src/app/external inside the container
+EXTERNAL_LIBRARY=$EXTERNAL_LIBRARY
+
+# Database location (keep on fast storage)
+DB_DATA_LOCATION=./postgres
+
+IMMICH_VERSION=release
+
+DB_PASSWORD=$DB_PASS
+DB_USERNAME=postgres
+DB_DATABASE_NAME=immich
+
+TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC")
+IMMICH_ENV
+ else
+ cat > .env << IMMICH_ENV
+# ============================================================
+# IMMICH CONFIGURATION — Unified Library
+# ============================================================
+#
+# All photos (imported + new uploads) are stored in one location.
+# Storage template organizes files by date automatically.
+#
+# To import existing photos, run:
+# $IMMICH_DIR/import-photos.sh
+#
+# The import script handles storage template configuration,
+# CLI installation, and uploads with EXIF date preservation.
+#
+# ============================================================
+
+# All photos stored here (uploads + imported)
+UPLOAD_LOCATION=$UPLOAD_LOCATION
+
+# Database location (keep on fast storage)
+DB_DATA_LOCATION=./postgres
+
+IMMICH_VERSION=release
+
+DB_PASSWORD=$DB_PASS
+DB_USERNAME=postgres
+DB_DATABASE_NAME=immich
+
+TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC")
+IMMICH_ENV
+ fi
+
+ chown -R "$ACTUAL_USER:$ACTUAL_USER" "$IMMICH_DIR" 2>/dev/null || true
+ chown -R "$ACTUAL_USER:$ACTUAL_USER" "$UPLOAD_LOCATION" 2>/dev/null || true
+ [ -n "$EXTERNAL_LIBRARY" ] && chown -R "$ACTUAL_USER:$ACTUAL_USER" "$EXTERNAL_LIBRARY" 2>/dev/null || true
+
+ # Create import helper script for Strategy 1 with existing photos
+ if [ "$IMMICH_STRATEGY" != "2" ] && [ -n "$EXISTING_PHOTOS_SOURCE" ]; then
+ cat > "$IMMICH_DIR/import-photos.sh" << 'IMPORT_SCRIPT_HEAD'
+#!/bin/bash
+################################################################################
+# Immich Photo Import Script
+# Generated by ubuntu-post-install
+#
+# This script imports your existing photo collection into Immich with proper
+# EXIF date preservation. Photos are uploaded through the API, which triggers
+# Immich's metadata extraction pipeline — dates, GPS, camera info are all
+# read from the original files.
+#
+# What this script does:
+# 1. Creates admin account (if first run) or logs in
+# 2. Generates an API key automatically
+# 3. Configures the storage template (date-based organization)
+# 4. Installs the Immich CLI (if needed)
+# 5. Uploads all photos with EXIF metadata preserved
+#
+# Usage:
+# ./import-photos.sh # interactive (prompts for everything)
+# ./import-photos.sh # skip account setup, use existing key
+################################################################################
+
+IMPORT_SCRIPT_HEAD
+
+ cat >> "$IMMICH_DIR/import-photos.sh" << IMPORT_SCRIPT_VARS
+IMMICH_URL="http://localhost:2283"
+SOURCE_DIR="$EXISTING_PHOTOS_SOURCE"
+IMMICH_DIR="$IMMICH_DIR"
+IMPORT_SCRIPT_VARS
+
+ cat >> "$IMMICH_DIR/import-photos.sh" << 'IMPORT_SCRIPT_BODY'
+
+echo ""
+echo "┌─────────────────────────────────────────────────────────────────┐"
+echo "│ IMMICH PHOTO IMPORT │"
+echo "└─────────────────────────────────────────────────────────────────┘"
+echo ""
+
+# ── Preflight checks ────────────────────────────────────────────
+echo "Checking Immich server..."
+if ! curl -s "$IMMICH_URL/api/server/ping" > /dev/null 2>&1; then
+ echo ""
+ echo " ✗ Immich is not running at $IMMICH_URL"
+ echo " Start it with: cd $IMMICH_DIR && docker compose up -d"
+ echo ""
+ exit 1
+fi
+echo " ✓ Immich is running"
+
+# Check source directory
+if [ ! -d "$SOURCE_DIR" ]; then
+ echo ""
+ echo " ✗ Source directory not found: $SOURCE_DIR"
+ echo " Update SOURCE_DIR in this script if your photos moved."
+ echo ""
+ exit 1
+fi
+echo " ✓ Source directory: $SOURCE_DIR"
+
+echo -n " Scanning for photos/videos (this may take a while)..."
+PHOTO_COUNT=$(find "$SOURCE_DIR" -type f \( -iname "*.jpg" -o -iname "*.jpeg" -o -iname "*.png" -o -iname "*.heic" -o -iname "*.heif" -o -iname "*.webp" -o -iname "*.gif" -o -iname "*.tiff" -o -iname "*.bmp" -o -iname "*.mp4" -o -iname "*.mov" -o -iname "*.avi" -o -iname "*.mkv" -o -iname "*.webm" \) 2>/dev/null | wc -l)
+echo " done"
+echo " ✓ Found ~$PHOTO_COUNT photos/videos"
+
+# ── Get or create API key ───────────────────────────────────────
+API_KEY="${1:-}"
+
+if [ -z "$API_KEY" ]; then
+ echo ""
+ # Check if server needs initial setup
+ SERVER_CONFIG=$(curl -s "$IMMICH_URL/api/server/config" 2>/dev/null)
+ IS_INITIALIZED=$(echo "$SERVER_CONFIG" | python3 -c "import sys,json; print(json.load(sys.stdin).get('isInitialized', True))" 2>/dev/null)
+
+ if [ "$IS_INITIALIZED" = "False" ]; then
+ # ── First-time setup: create admin account ──────────────
+ echo "┌─────────────────────────────────────────────────────────────────┐"
+ echo "│ FIRST-TIME SETUP — Creating admin account │"
+ echo "└─────────────────────────────────────────────────────────────────┘"
+ echo ""
+ echo " Immich needs an admin account. Enter your details:"
+ echo ""
+ read -p " Admin email: " ADMIN_EMAIL
+ while [ -z "$ADMIN_EMAIL" ]; do
+ read -p " Admin email (required): " ADMIN_EMAIL
+ done
+ read -sp " Admin password: " ADMIN_PASS
+ echo ""
+ while [ ${#ADMIN_PASS} -lt 8 ]; do
+ echo " Password must be at least 8 characters."
+ read -sp " Admin password: " ADMIN_PASS
+ echo ""
+ done
+ read -p " Your name [Admin]: " ADMIN_NAME
+ ADMIN_NAME="${ADMIN_NAME:-Admin}"
+
+ echo ""
+ echo " Creating admin account..."
+ SIGNUP_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \
+ -H "Content-Type: application/json" \
+ "$IMMICH_URL/api/auth/admin-sign-up" \
+ -d "{\"email\":\"$ADMIN_EMAIL\",\"password\":\"$ADMIN_PASS\",\"name\":\"$ADMIN_NAME\"}" 2>/dev/null)
+ SIGNUP_CODE=$(echo "$SIGNUP_RESPONSE" | tail -1)
+ SIGNUP_BODY=$(echo "$SIGNUP_RESPONSE" | sed '$d')
+
+ if [ "$SIGNUP_CODE" = "201" ]; then
+ echo " ✓ Admin account created"
+ else
+ echo " ✗ Failed to create admin account (HTTP $SIGNUP_CODE)"
+ echo " Response: $SIGNUP_BODY"
+ echo ""
+ echo " Create your account manually at $IMMICH_URL"
+ echo " Then re-run this script with your API key:"
+ echo " $0 "
+ exit 1
+ fi
+ else
+ # Server already initialized, need credentials to log in
+ echo " Immich is already set up. Log in to generate an API key."
+ echo ""
+ read -p " Admin email: " ADMIN_EMAIL
+ while [ -z "$ADMIN_EMAIL" ]; do
+ read -p " Admin email (required): " ADMIN_EMAIL
+ done
+ read -sp " Admin password: " ADMIN_PASS
+ echo ""
+ fi
+
+ # ── Login to get bearer token ───────────────────────────────
+ echo " Logging in..."
+ LOGIN_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \
+ -H "Content-Type: application/json" \
+ "$IMMICH_URL/api/auth/login" \
+ -d "{\"email\":\"$ADMIN_EMAIL\",\"password\":\"$ADMIN_PASS\"}" 2>/dev/null)
+ LOGIN_CODE=$(echo "$LOGIN_RESPONSE" | tail -1)
+ LOGIN_BODY=$(echo "$LOGIN_RESPONSE" | sed '$d')
+
+ if [ "$LOGIN_CODE" != "201" ]; then
+ echo " ✗ Login failed (HTTP $LOGIN_CODE)"
+ echo ""
+ echo " Check your email/password and try again, or pass an API key:"
+ echo " $0 "
+ exit 1
+ fi
+
+ ACCESS_TOKEN=$(echo "$LOGIN_BODY" | python3 -c "import sys,json; print(json.load(sys.stdin)['accessToken'])" 2>/dev/null)
+ if [ -z "$ACCESS_TOKEN" ]; then
+ echo " ✗ Could not extract access token from login response"
+ exit 1
+ fi
+ echo " ✓ Logged in"
+
+ # ── Create API key ──────────────────────────────────────────
+ echo " Creating API key..."
+ APIKEY_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \
+ -H "Content-Type: application/json" \
+ -H "Authorization: Bearer $ACCESS_TOKEN" \
+ "$IMMICH_URL/api/api-keys" \
+ -d '{"name":"import-photos-script"}' 2>/dev/null)
+ APIKEY_CODE=$(echo "$APIKEY_RESPONSE" | tail -1)
+ APIKEY_BODY=$(echo "$APIKEY_RESPONSE" | sed '$d')
+
+ if [ "$APIKEY_CODE" = "201" ]; then
+ API_KEY=$(echo "$APIKEY_BODY" | python3 -c "import sys,json; print(json.load(sys.stdin)['secret'])" 2>/dev/null)
+ if [ -n "$API_KEY" ]; then
+ echo " ✓ API key created"
+ else
+ echo " ✗ Could not extract API key from response"
+ echo " Create one manually: $IMMICH_URL → Account Settings → API Keys"
+ echo " Then re-run: $0 "
+ exit 1
+ fi
+ else
+ echo " ✗ Failed to create API key (HTTP $APIKEY_CODE)"
+ echo " Create one manually: $IMMICH_URL → Account Settings → API Keys"
+ echo " Then re-run: $0 "
+ exit 1
+ fi
+else
+ # API key provided as argument — verify it
+ echo ""
+ echo " Verifying API key..."
+ VERIFY_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
+ -H "x-api-key: $API_KEY" "$IMMICH_URL/api/users/me" 2>/dev/null)
+ if [ "$VERIFY_CODE" != "200" ]; then
+ echo " ✗ Invalid API key (HTTP $VERIFY_CODE)"
+ echo " Double-check the key and try again."
+ exit 1
+ fi
+ echo " ✓ API key valid"
+fi
+
+# ── Configure storage template via API ──────────────────────────
+echo ""
+echo " Configuring storage template ({{y}}/{{MM}}/{{filename}})..."
+CURRENT_CONFIG=$(curl -s -H "x-api-key: $API_KEY" "$IMMICH_URL/api/system-config" 2>/dev/null)
+if [ -n "$CURRENT_CONFIG" ] && command -v python3 &> /dev/null; then
+ UPDATED_CONFIG=$(echo "$CURRENT_CONFIG" | python3 -c "
+import sys, json
+config = json.load(sys.stdin)
+config['storageTemplate']['enabled'] = True
+config['storageTemplate']['template'] = '{{y}}/{{MM}}/{{filename}}'
+json.dump(config, sys.stdout)
+" 2>/dev/null)
+ if [ -n "$UPDATED_CONFIG" ]; then
+ RESULT=$(curl -s -o /dev/null -w "%{http_code}" -X PUT \
+ -H "x-api-key: $API_KEY" \
+ -H "Content-Type: application/json" \
+ "$IMMICH_URL/api/system-config" \
+ -d "$UPDATED_CONFIG" 2>/dev/null)
+ if [ "$RESULT" = "200" ]; then
+ echo " ✓ Storage template configured"
+ else
+ echo " ⚠ Could not set storage template (HTTP $RESULT)"
+ echo " Set manually: Admin → Settings → Storage Template → Enable"
+ echo " Template: {{y}}/{{MM}}/{{filename}}"
+ fi
+ else
+ echo " ⚠ Could not parse config. Set storage template manually:"
+ echo " Admin → Settings → Storage Template → Enable"
+ echo " Template: {{y}}/{{MM}}/{{filename}}"
+ fi
+else
+ echo " ⚠ python3 not found. Set storage template manually:"
+ echo " Admin → Settings → Storage Template → Enable"
+ echo " Template: {{y}}/{{MM}}/{{filename}}"
+fi
+
+# ── Install immich-cli if needed ────────────────────────────────
+echo ""
+IMMICH_CMD=""
+
+# Immich CLI requires Node.js >= 20 (File global class)
+NODE_OK=false
+if command -v node &> /dev/null; then
+ NODE_MAJOR=$(node -v 2>/dev/null | sed 's/^v//' | cut -d. -f1)
+ if [ "$NODE_MAJOR" -ge 20 ] 2>/dev/null; then
+ NODE_OK=true
+ fi
+fi
+
+if [ "$NODE_OK" = false ]; then
+ echo " Immich CLI requires Node.js >= 20 (found: $(node -v 2>/dev/null || echo 'none'))."
+ read -p " Install Node.js 22 LTS now? (y/n): " INSTALL_NODE_YN
+ if [ "$INSTALL_NODE_YN" = "y" ] || [ "$INSTALL_NODE_YN" = "Y" ]; then
+ echo " Installing Node.js 22 LTS from NodeSource..."
+ curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - 2>/dev/null
+ sudo apt-get install -y -qq nodejs 2>/dev/null
+ NODE_MAJOR=$(node -v 2>/dev/null | sed 's/^v//' | cut -d. -f1)
+ if [ "$NODE_MAJOR" -ge 20 ] 2>/dev/null; then
+ NODE_OK=true
+ echo " ✓ Node.js $(node -v) installed"
+ else
+ echo " ✗ Installation failed."
+ echo " Install Node.js 20+ manually: https://nodejs.org/"
+ echo " Then re-run: $0 $API_KEY"
+ exit 1
+ fi
+ else
+ echo ""
+ echo " Install Node.js 20+ later and re-run this script:"
+ echo " curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -"
+ echo " sudo apt-get install -y nodejs"
+ echo " $0 $API_KEY"
+ exit 0
+ fi
+fi
+
+if command -v immich &> /dev/null; then
+ IMMICH_CMD="immich"
+ echo " ✓ Immich CLI found"
+elif command -v npx &> /dev/null; then
+ echo " Immich CLI not installed. Will use npx (downloads temporarily)."
+ IMMICH_CMD="npx --yes @immich/cli"
+elif command -v npm &> /dev/null; then
+ echo " Installing Immich CLI globally..."
+ if npm install -g @immich/cli 2>/dev/null; then
+ IMMICH_CMD="immich"
+ echo " ✓ Immich CLI installed"
+ else
+ echo " ✗ npm install failed. Trying npx..."
+ IMMICH_CMD="npx --yes @immich/cli"
+ fi
+fi
+
+if [ -z "$IMMICH_CMD" ]; then
+ echo " ✗ Could not find npm or npx despite Node.js being installed."
+ echo " Install manually: npm install -g @immich/cli"
+ echo " Then re-run: $0 $API_KEY"
+ exit 1
+fi
+
+# ── Run the import ──────────────────────────────────────────────
+echo ""
+echo " Authenticating CLI with Immich..."
+if ! $IMMICH_CMD login "$IMMICH_URL/api" "$API_KEY"; then
+ echo " ✗ CLI login failed"
+ exit 1
+fi
+
+echo ""
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+echo " Starting import from: $SOURCE_DIR"
+echo " Importing ~$PHOTO_COUNT files. This may take a while."
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+echo ""
+
+$IMMICH_CMD upload --recursive "$SOURCE_DIR"
+
+echo ""
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+echo " Import complete!"
+echo " View your photos at: $IMMICH_URL"
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+echo ""
+IMPORT_SCRIPT_BODY
+
+ chmod +x "$IMMICH_DIR/import-photos.sh"
+ chown "$ACTUAL_USER:$ACTUAL_USER" "$IMMICH_DIR/import-photos.sh"
+ fi
+
+ echo ""
+ echo "✓ Immich configured at $IMMICH_DIR"
+ if [ -n "$EXTERNAL_LIBRARY" ]; then
+ echo " Existing photos: $EXTERNAL_LIBRARY (read-only)"
+ echo " New uploads: $UPLOAD_LOCATION"
+ else
+ echo " Photo library: $UPLOAD_LOCATION"
+ fi
+ echo ""
+
+ # Configure Caddy reverse proxy before starting
+ configure_caddy_for_service "Immich" "2283" "photos"
+
+ # Ask to start container
+ prompt_yn "Start Immich now? (y/n):" "y" START_IMMICH
+ if [ "$START_IMMICH" = "y" ] || [ "$START_IMMICH" = "Y" ]; then
+ echo " Starting Immich..."
+ docker compose up -d 2>/dev/null && echo " ✓ Immich started" || echo " ⚠ Failed to start"
+ fi
+
+ # ── Post-setup instructions ─────────────────────────────
+ echo ""
+ echo "┌─────────────────────────────────────────────────────────────────┐"
+ echo "│ IMMICH SETUP STEPS │"
+ echo "└─────────────────────────────────────────────────────────────────┘"
+ echo ""
+
+ if [ -n "$EXISTING_PHOTOS_SOURCE" ] && [ "$IMMICH_STRATEGY" != "2" ]; then
+ echo " Import your existing photos AFTER this script finishes:"
+ echo " $IMMICH_DIR/import-photos.sh"
+ echo ""
+ echo " The script handles everything automatically:"
+ echo " creates your admin account, generates an API key,"
+ echo " configures the storage template, installs the CLI,"
+ echo " and imports all photos from $EXISTING_PHOTOS_SOURCE"
+ echo " with EXIF dates preserved."
+ echo ""
+ echo " NOTE: Run import-photos.sh separately after this setup"
+ echo " script has completed. It may take a while for large"
+ echo " photo collections."
+ echo ""
+ echo " After import, open http://localhost:2283 to browse your photos."
+ elif [ "$IMMICH_STRATEGY" = "2" ]; then
+ echo " 1. Open http://localhost:2283 and create your admin account"
+ echo ""
+ echo " 2. Enable storage template (keeps new uploads organized):"
+ echo " Admin → Settings → Storage Template → Enable"
+ echo " Template: {{y}}/{{MM}}/{{filename}}"
+ echo ""
+ echo " 3. Set up your existing photo library:"
+ echo " Admin → External Libraries → Create Library"
+ echo " Import path: /usr/src/app/external"
+ echo " Click Scan to index your photos"
+ echo ""
+ echo " New uploads from the mobile app or web UI are stored"
+ echo " separately in: $UPLOAD_LOCATION"
+ else
+ echo " 1. Open http://localhost:2283 and create your admin account"
+ echo ""
+ echo " 2. Enable storage template (keeps files organized by date):"
+ echo " Admin → Settings → Storage Template → Enable"
+ echo " Template: {{y}}/{{MM}}/{{filename}}"
+ echo ""
+ echo " That's it. Upload photos from the mobile app or web UI."
+ echo " All photos are stored in: $PHOTOS_DIR"
+ fi
+ echo ""
+ fi
+ fi # End IMMICH_RECONFIGURE check
+ fi # End INSTALL_IMMICH check
+
+ # ---- AUDIOBOOKSHELF ----
+ if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_AUDIOBOOKSHELF" ]; then
+ echo ""
+ echo "┌─────────────────────────────────────────────────────────────────┐"
+ echo "│ AUDIOBOOKSHELF - Audiobook & podcast server │"
+ echo "│ Stream audiobooks with progress sync across devices. │"
+ echo "│ Port: 13378 │"
+ echo "└─────────────────────────────────────────────────────────────────┘"
+ prompt_yn "Install Audiobookshelf? (y/n):" "n" INSTALL_AUDIOBOOKSHELF
+ fi
+
+ if [ "$INSTALL_AUDIOBOOKSHELF" = "y" ] || [ "$INSTALL_AUDIOBOOKSHELF" = "Y" ]; then
+ ABS_DIR="$DOCKER_DIR/audiobookshelf"
+
+ # Check if already installed and ask what to do
+ check_service_exists "AudioBookshelf" "$ABS_DIR" ABS_RECONFIGURE
+
+ if [ "$ABS_RECONFIGURE" = "true" ]; then
+ echo "Installing Audiobookshelf..."
+
+ if [ "$DRY_RUN" = true ]; then
+ echo "[DRY-RUN] Would create $ABS_DIR"
+ else
+ mkdir -p "$ABS_DIR"
+ ensure_docker_dir_ownership "$ABS_DIR"
+ cd "$ABS_DIR"
+
+ prompt_text "Path to audiobooks folder [default: $PRIMARY_DRIVE_PATH/audiobooks]:" "$PRIMARY_DRIVE_PATH/audiobooks" AUDIOBOOKS_PATH
+
+ cat > docker-compose.yml << ABS_COMPOSE
+name: audiobookshelf
+
+services:
+ audiobookshelf:
+ image: ghcr.io/advplyr/audiobookshelf:latest
+ container_name: audiobookshelf
+ hostname: audiobookshelf
+ restart: unless-stopped
+ environment:
+ - TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC")
+ volumes:
+ - ./config:/config
+ - ./metadata:/metadata
+ - ${AUDIOBOOKS_PATH}:/audiobooks
+ - ${PODCASTS_PATH:-./podcasts}:/podcasts
+ ports:
+ - "13378:80"
+ABS_COMPOSE
+
+ cat > .env << ABS_ENV
+AUDIOBOOKS_PATH=$AUDIOBOOKS_PATH
+PODCASTS_PATH=./podcasts
+ABS_ENV
+
+ mkdir -p config metadata podcasts
+ chown -R "$ACTUAL_USER:$ACTUAL_USER" "$ABS_DIR"
+
+ echo ""
+ echo "✓ Audiobookshelf configured at $ABS_DIR"
+
+ # Configure Caddy reverse proxy before starting
+ configure_caddy_for_service "AudioBookshelf" "13378" "audiobooks"
+
+ prompt_yn "Start Audiobookshelf now? (y/n):" "y" START_ABS
+ if [ "$START_ABS" = "y" ] || [ "$START_ABS" = "Y" ]; then
+ docker compose up -d 2>/dev/null && echo " ✓ Audiobookshelf started" || echo " ⚠ Failed to start"
+ fi
+
+ echo " Access at: http://localhost:13378"
+ echo ""
+ fi
+ fi # End ABS_RECONFIGURE check
+ fi # End INSTALL_AUDIOBOOKSHELF check
+
+ # ---- EMBY ----
+ if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_EMBY" ]; then
+ echo ""
+ echo "┌─────────────────────────────────────────────────────────────────┐"
+ echo "│ EMBY - Media server for movies, TV, music │"
+ echo "│ Stream your media library to any device. │"
+ echo "│ Port: 8096 (web), 8920 (https) │"
+ echo "└─────────────────────────────────────────────────────────────────┘"
+ prompt_yn "Install Emby? (y/n):" "n" INSTALL_EMBY
+ fi
+
+ if [ "$INSTALL_EMBY" = "y" ] || [ "$INSTALL_EMBY" = "Y" ]; then
+ EMBY_DIR="$DOCKER_DIR/emby"
+
+ # Check if already installed and ask what to do
+ check_service_exists "Emby" "$EMBY_DIR" EMBY_RECONFIGURE
+
+ if [ "$EMBY_RECONFIGURE" = "true" ]; then
+ echo "Installing Emby..."
+
+ if [ "$DRY_RUN" = true ]; then
+ echo "[DRY-RUN] Would create $EMBY_DIR"
+ else
+ mkdir -p "$EMBY_DIR"
+ ensure_docker_dir_ownership "$EMBY_DIR"
+ cd "$EMBY_DIR"
+
+ prompt_text "Path to media folder [default: $PRIMARY_DRIVE_PATH/media]:" "$PRIMARY_DRIVE_PATH/media" MEDIA_PATH
+
+ cat > docker-compose.yml << EMBY_COMPOSE
+name: emby
+
+services:
+ emby:
+ image: emby/embyserver:latest
+ container_name: emby
+ hostname: emby
+ restart: unless-stopped
+ environment:
+ - UID=$(id -u "$ACTUAL_USER")
+ - GID=$(id -g "$ACTUAL_USER")
+ - TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC")
+ volumes:
+ - ./config:/config
+ - ${MEDIA_PATH}:/media
+ ports:
+ - "8096:8096"
+ - "8920:8920"
+ # Uncomment for hardware transcoding (Intel/AMD)
+ # devices:
+ # - /dev/dri:/dev/dri
+EMBY_COMPOSE
+
+ cat > .env << EMBY_ENV
+MEDIA_PATH=$MEDIA_PATH
+EMBY_ENV
+
+ mkdir -p config
+ chown -R "$ACTUAL_USER:$ACTUAL_USER" "$EMBY_DIR"
+
+ echo ""
+ echo "✓ Emby configured at $EMBY_DIR"
+
+ # Configure Caddy reverse proxy before starting
+ configure_caddy_for_service "Emby" "8096" "emby"
+
+ prompt_yn "Start Emby now? (y/n):" "y" START_EMBY
+ if [ "$START_EMBY" = "y" ] || [ "$START_EMBY" = "Y" ]; then
+ docker compose up -d 2>/dev/null && echo " ✓ Emby started" || echo " ⚠ Failed to start"
+ fi
+
+ echo " Access at: http://localhost:8096"
+ echo ""
+ fi
+ fi # End EMBY_RECONFIGURE check
+ fi # End INSTALL_EMBY check
+
+ # ---- A.R.M. (Automatic Ripping Machine) ----
+ if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_ARM" ]; then
+ echo ""
+ echo "┌─────────────────────────────────────────────────────────────────┐"
+ echo "│ A.R.M. - Automatic Ripping Machine │"
+ echo "│ Automatically rip DVDs, Blu-rays, and CDs. │"
+ echo "│ Port: 8080 │"
+ echo "└─────────────────────────────────────────────────────────────────┘"
+ prompt_yn "Install A.R.M.? (y/n):" "n" INSTALL_ARM
+ fi
+
+ if [ "$INSTALL_ARM" = "y" ] || [ "$INSTALL_ARM" = "Y" ]; then
+ ARM_DIR="$DOCKER_DIR/arm"
+ check_service_exists "A.R.M." "$ARM_DIR" ARM_RECONFIGURE
+
+ if [ "$ARM_RECONFIGURE" = "true" ]; then
+ echo "Installing A.R.M...."
+
+ if [ "$DRY_RUN" = true ]; then
+ echo "[DRY-RUN] Would create $ARM_DIR"
+ else
+ mkdir -p "$ARM_DIR"
+ ensure_docker_dir_ownership "$ARM_DIR"
+ cd "$ARM_DIR"
+
+ prompt_text "Path for ripped media output [default: $PRIMARY_DRIVE_PATH/ripped]:" "$PRIMARY_DRIVE_PATH/ripped" ARM_OUTPUT
+
+ # Detect optical drives
+ echo ""
+ echo "Detecting optical drives..."
+ OPTICAL_DRIVES=$(ls /dev/sr* 2>/dev/null || echo "")
+ if [ -n "$OPTICAL_DRIVES" ]; then
+ echo "Found: $OPTICAL_DRIVES"
+ else
+ echo "No optical drives detected. You can add them later."
+ OPTICAL_DRIVES="/dev/sr0"
+ fi
+
+ cat > docker-compose.yml << ARM_COMPOSE
+name: arm
+
+services:
+ automatic-ripping-machine:
+ image: automaticrippingmachine/automatic-ripping-machine:latest
+ container_name: arm
+ hostname: arm
+ restart: unless-stopped
+ environment:
+ - ARM_UID=$(id -u "$ACTUAL_USER")
+ - ARM_GID=$(id -g "$ACTUAL_USER")
+ - TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC")
+ volumes:
+ - ./config:/etc/arm/config
+ - ./logs:/home/arm/logs
+ - ${ARM_OUTPUT}/movies:/home/arm/media/completed
+ - ${ARM_OUTPUT}/music:/home/arm/music
+ ports:
+ - "8080:8080"
+ devices:
+ - /dev/sr0:/dev/sr0
+ # Add more drives as needed:
+ # - /dev/sr1:/dev/sr1
+ privileged: true
+ARM_COMPOSE
+
+ cat > .env << ARM_ENV
+ARM_OUTPUT=$ARM_OUTPUT
+ARM_ENV
+
+ mkdir -p config logs
+ mkdir -p "$ARM_OUTPUT/movies" "$ARM_OUTPUT/music"
+ chown -R "$ACTUAL_USER:$ACTUAL_USER" "$ARM_DIR"
+
+ echo ""
+ echo "✓ A.R.M. configured at $ARM_DIR"
+
+ prompt_yn "Start A.R.M. now? (y/n):" "y" START_ARM
+ if [ "$START_ARM" = "y" ] || [ "$START_ARM" = "Y" ]; then
+ docker compose up -d 2>/dev/null && echo " ✓ A.R.M. started" || echo " ⚠ Failed to start"
+ fi
+
+ echo " Access at: http://localhost:8080"
+ echo " ⚠️ Complete setup in browser on first visit!"
+ echo " Note: Edit docker-compose.yml to add more optical drives"
+ echo ""
+ fi
+ fi # End ARM_RECONFIGURE check
+ fi # End INSTALL_ARM check
+
+ # ---- FILEBROWSER ----
+ if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_FILEBROWSER" ]; then
+ echo ""
+ echo "┌─────────────────────────────────────────────────────────────────┐"
+ echo "│ FILEBROWSER - Web-based file manager │"
+ echo "│ Browse, upload, download files via web interface. │"
+ echo "│ Port: 8085 │"
+ echo "└─────────────────────────────────────────────────────────────────┘"
+ prompt_yn "Install Filebrowser? (y/n):" "n" INSTALL_FILEBROWSER
+ fi
+
+ if [ "$INSTALL_FILEBROWSER" = "y" ] || [ "$INSTALL_FILEBROWSER" = "Y" ]; then
+ FB_DIR="$DOCKER_DIR/filebrowser"
+ check_service_exists "FileBrowser" "$FB_DIR" FB_RECONFIGURE
+
+ if [ "$FB_RECONFIGURE" = "true" ]; then
+ echo "Installing Filebrowser..."
+
+ if [ "$DRY_RUN" = true ]; then
+ echo "[DRY-RUN] Would create $FB_DIR"
+ else
+ mkdir -p "$FB_DIR"
+ ensure_docker_dir_ownership "$FB_DIR"
+ cd "$FB_DIR"
+
+ prompt_text "Path to browse [default: $PRIMARY_DRIVE_PATH]:" "$PRIMARY_DRIVE_PATH" FB_PATH
+
+ cat > docker-compose.yml << FB_COMPOSE
+name: filebrowser
+
+services:
+ filebrowser:
+ image: filebrowser/filebrowser:s6
+ container_name: filebrowser
+ hostname: filebrowser
+ restart: unless-stopped
+ environment:
+ - PUID=$(id -u "$ACTUAL_USER")
+ - PGID=$(id -g "$ACTUAL_USER")
+ - TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC")
+ volumes:
+ - ${FB_PATH}:/srv
+ - ./database/filebrowser.db:/database/filebrowser.db
+ - ./config/settings.json:/config/settings.json
+ ports:
+ - "8085:80"
+FB_COMPOSE
+
+ cat > .env << FB_ENV
+FB_PATH=$FB_PATH
+FB_ENV
+
+ mkdir -p database config
+ touch database/filebrowser.db
+ cat > config/settings.json << 'FB_SETTINGS'
+{
+ "port": 80,
+ "baseURL": "",
+ "address": "",
+ "log": "stdout",
+ "database": "/database/filebrowser.db",
+ "root": "/srv"
+}
+FB_SETTINGS
+
+ chown -R "$ACTUAL_USER:$ACTUAL_USER" "$FB_DIR"
+
+ echo ""
+ echo "✓ Filebrowser configured at $FB_DIR"
+
+ prompt_yn "Start Filebrowser now? (y/n):" "y" START_FB
+ if [ "$START_FB" = "y" ] || [ "$START_FB" = "Y" ]; then
+ docker compose up -d 2>/dev/null && echo " ✓ Filebrowser started" || echo " ⚠ Failed to start"
+ fi
+
+ echo " Access at: http://localhost:8085"
+ echo " Default login: admin / admin (change immediately!)"
+ echo ""
+ fi
+ fi # End FB_RECONFIGURE check
+ fi # End INSTALL_FILEBROWSER check
+
+ # ---- MAGIC MIRROR ----
+ if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_MAGICMIRROR" ]; then
+ echo ""
+ echo "┌─────────────────────────────────────────────────────────────────┐"
+ echo "│ MAGIC MIRROR - Smart mirror / dashboard display │"
+ echo "│ Modular smart mirror platform. Run up to 3 instances. │"
+ echo "│ Ports: 8081, 8082, 8083 │"
+ echo "└─────────────────────────────────────────────────────────────────┘"
+ prompt_yn "Install Magic Mirror? (y/n):" "n" INSTALL_MAGICMIRROR
+ fi
+
+ if [ "$INSTALL_MAGICMIRROR" = "y" ] || [ "$INSTALL_MAGICMIRROR" = "Y" ]; then
+ echo ""
+ prompt_text "How many Magic Mirror instances? [1-3, default: 1]:" "1" MM_COUNT
+ MM_COUNT=${MM_COUNT:-1}
+ if [ "$MM_COUNT" -gt 3 ]; then MM_COUNT=3; fi
+ if [ "$MM_COUNT" -lt 1 ]; then MM_COUNT=1; fi
+
+ echo "Installing $MM_COUNT Magic Mirror instance(s)..."
+
+ for i in $(seq 1 $MM_COUNT); do
+ MM_PORT=$((8080 + i))
+ MM_DIR="$DOCKER_DIR/magicmirror-$MM_PORT"
+
+ if [ "$DRY_RUN" = true ]; then
+ echo "[DRY-RUN] Would create $MM_DIR (port $MM_PORT)"
+ else
+ mkdir -p "$MM_DIR"
+ ensure_docker_dir_ownership "$MM_DIR"
+ cd "$MM_DIR"
+
+ cat > docker-compose.yml << MM_COMPOSE
+name: mm-$MM_PORT
+
+services:
+ magicmirror:
+ image: karsten13/magicmirror:latest
+ container_name: magicmirror-$MM_PORT
+ hostname: magicmirror-$MM_PORT
+ restart: unless-stopped
+ environment:
+ - TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC")
+ volumes:
+ - ./config:/opt/magic_mirror/config
+ - ./modules:/opt/magic_mirror/modules
+ - ./css:/opt/magic_mirror/css
+ ports:
+ - "$MM_PORT:8080"
+MM_COMPOSE
+
+ mkdir -p config modules css
+
+ # Ask if user has existing config to copy
+ echo ""
+ echo " Config options:"
+ echo " [1] Use default config (basic modules)"
+ echo " [2] Copy existing config from path"
+ read -p " Choose [1]: " MM_CONFIG_CHOICE
+ MM_CONFIG_CHOICE=${MM_CONFIG_CHOICE:-1}
+
+ if [ "$MM_CONFIG_CHOICE" = "2" ]; then
+ read -p " Path to config.js: " MM_CONFIG_PATH
+ if [ -f "$MM_CONFIG_PATH" ]; then
+ cp "$MM_CONFIG_PATH" config/config.js
+ echo " ✓ Copied config from $MM_CONFIG_PATH"
+
+ # Also copy custom.css if exists in same directory
+ MM_CSS_PATH="${MM_CONFIG_PATH%/*}/custom.css"
+ if [ -f "$MM_CSS_PATH" ]; then
+ cp "$MM_CSS_PATH" css/custom.css
+ echo " ✓ Copied custom.css"
+ fi
+
+ # Parse config for third-party modules and offer to download
+ echo ""
+ echo " Scanning for third-party modules..."
+
+ # Extract module names starting with MMM- (third-party convention)
+ THIRD_PARTY_MODULES=$(grep -oP 'module:\s*["\x27]MMM-[^"\x27]+["\x27]' config/config.js 2>/dev/null | sed "s/module:\s*[\"']//g" | sed "s/[\"']//g" | sort -u)
+
+ if [ -n "$THIRD_PARTY_MODULES" ]; then
+ echo " Found third-party modules:"
+ echo "$THIRD_PARTY_MODULES" | while read mod; do
+ echo " - $mod"
+ done
+ echo ""
+ prompt_yn " Download these modules from GitHub? (y/n):" "y" MM_DOWNLOAD_MODS
+ if [ "$MM_DOWNLOAD_MODS" = "y" ] || [ "$MM_DOWNLOAD_MODS" = "Y" ]; then
+ cd modules
+ echo "$THIRD_PARTY_MODULES" | while read mod; do
+ if [ -n "$mod" ] && [ ! -d "$mod" ]; then
+ echo " Downloading $mod..."
+ # Try common GitHub patterns
+ git clone --depth 1 "https://github.com/MichMich/$mod.git" 2>/dev/null || \
+ git clone --depth 1 "https://github.com/bugsounet/$mod.git" 2>/dev/null || \
+ git clone --depth 1 "https://github.com/MagicMirrorOrg/$mod.git" 2>/dev/null || \
+ echo " ⚠ Could not find $mod - search at https://github.com/topics/magicmirror"
+ fi
+ done
+ cd ..
+
+ # Run npm install for each downloaded module
+ # Note: This runs AFTER container is started, inside the container
+ echo ""
+ echo " Note: Module dependencies will be installed when container starts"
+ echo " If you need to manually install module dependencies, run:"
+ echo " docker exec magicmirror-$MM_PORT sh -c 'cd /opt/magic_mirror/modules/ && npm install --production'"
+ echo ""
+ fi
+ else
+ echo " No third-party modules (MMM-*) found in config"
+ fi
+ else
+ echo " ⚠ File not found: $MM_CONFIG_PATH"
+ echo " Using default config..."
+ MM_CONFIG_CHOICE="1"
+ fi
+ fi
+
+ # Create default config if not copying
+ if [ "$MM_CONFIG_CHOICE" != "2" ]; then
+ # Create basic config.js
+ cat > config/config.js << 'MM_CONFIG'
+let config = {
+ address: "0.0.0.0",
+ port: 8080,
+ ipWhitelist: [],
+ language: "en",
+ timeFormat: 12,
+ units: "imperial",
+ modules: [
+ {
+ module: "alert",
+ },
+ {
+ module: "clock",
+ position: "top_left"
+ },
+ {
+ module: "calendar",
+ header: "Calendar",
+ position: "top_left",
+ config: {
+ calendars: [
+ {
+ symbol: "calendar-check",
+ url: "webcal://www.calendarlabs.com/ical-calendar/ics/76/US_Holidays.ics"
+ }
+ ]
+ }
+ },
+ {
+ module: "weather",
+ position: "top_right",
+ config: {
+ weatherProvider: "openmeteo",
+ type: "current",
+ lat: 40.7128,
+ lon: -74.0060
+ }
+ },
+ {
+ module: "weather",
+ position: "top_right",
+ header: "Weather Forecast",
+ config: {
+ weatherProvider: "openmeteo",
+ type: "forecast",
+ lat: 40.7128,
+ lon: -74.0060
+ }
+ },
+ {
+ module: "newsfeed",
+ position: "bottom_bar",
+ config: {
+ feeds: [
+ {
+ title: "BBC",
+ url: "https://feeds.bbci.co.uk/news/rss.xml"
+ }
+ ],
+ showSourceTitle: true,
+ showPublishDate: true,
+ broadcastNewsFeeds: true,
+ broadcastNewsUpdates: true
+ }
+ },
+ ]
+};
+
+/*************** DO NOT EDIT THE LINE BELOW ***************/
+if (typeof module !== "undefined") {module.exports = config;}
+MM_CONFIG
+ fi
+
+ chown -R "$ACTUAL_USER:$ACTUAL_USER" "$MM_DIR"
+
+ echo " ✓ Magic Mirror #$i configured at $MM_DIR (port $MM_PORT)"
+ fi
+ done
+
+ if [ "$DRY_RUN" != true ]; then
+ echo ""
+ prompt_yn "Start Magic Mirror instance(s) now? (y/n):" "y" START_MM
+ if [ "$START_MM" = "y" ] || [ "$START_MM" = "Y" ]; then
+ for i in $(seq 1 $MM_COUNT); do
+ MM_PORT=$((8080 + i))
+ MM_DIR="$DOCKER_DIR/magicmirror-$MM_PORT"
+ (cd "$MM_DIR" && docker compose up -d 2>/dev/null) && echo " ✓ Magic Mirror #$i started (port $MM_PORT)" || echo " ⚠ Failed to start Magic Mirror #$i"
+
+ # Install npm dependencies for third-party modules inside container
+ if [ -d "$MM_DIR/modules" ]; then
+ echo " Installing module dependencies inside container..."
+ sleep 3 # Wait for container to fully start
+ for mod_dir in "$MM_DIR/modules"/MMM-*/; do
+ if [ -d "$mod_dir" ] && [ -f "$mod_dir/package.json" ]; then
+ mod_name=$(basename "$mod_dir")
+ echo " Installing $mod_name dependencies..."
+ docker exec magicmirror-$MM_PORT sh -c "cd /opt/magic_mirror/modules/$mod_name && npm install --production" 2>/dev/null && \
+ echo " ✓ $mod_name dependencies installed" || \
+ echo " ⚠ $mod_name - npm install failed (container may need restart)"
+ fi
+ done
+ fi
+ done
+ fi
+
+ echo " Access at: http://localhost:808X"
+ echo " Edit config: ~/docker/magicmirror-808X/config/config.js"
+ echo ""
+ fi
+ fi
+
+ # ---- ACTUALBUDGET ----
+ if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_ACTUALBUDGET" ]; then
+ echo ""
+ echo "┌─────────────────────────────────────────────────────────────────┐"
+ echo "│ ACTUALBUDGET - Open-source Personal Finance Management │"
+ echo "│ Budget tracking with bank account synchronization via SimpleFIN│"
+ echo "│ Port: 5006 │"
+ echo "└─────────────────────────────────────────────────────────────────┘"
+ prompt_yn "Install ActualBudget? (y/n):" "n" INSTALL_ACTUALBUDGET
+ fi
+
+ if [ "$INSTALL_ACTUALBUDGET" = "y" ] || [ "$INSTALL_ACTUALBUDGET" = "Y" ]; then
+ AB_DIR="$DOCKER_DIR/actualbudget"
+ check_service_exists "ActualBudget" "$AB_DIR" AB_RECONFIGURE
+
+ if [ "$AB_RECONFIGURE" = "true" ]; then
+ if [ "$DRY_RUN" = true ]; then
+ echo "[DRY-RUN] Would create $AB_DIR"
+ else
+ echo "Installing ActualBudget..."
+ mkdir -p "$AB_DIR/data"
+ ensure_docker_dir_ownership "$AB_DIR"
+ cd "$AB_DIR"
+
+ # Create .env file for environment variables
+ cat > .env << 'AB_ENV'
+# ActualBudget Environment Variables
+TZ=UTC
+AB_ENV
+
+ cat > docker-compose.yml << 'AB_COMPOSE'
+name: actualbudget
+
+services:
+ actualbudget:
+ image: actualbudget/actual-server:latest
+ container_name: actualbudget
+ restart: unless-stopped
+ ports:
+ - "5006:5006"
+ volumes:
+ - ./data:/data
+ env_file:
+ - .env
+ labels:
+ - "io.podman.annotations.label/fail2ban.enable=true"
+ - "io.podman.annotations.label/fail2ban.filter=caddy-auth"
+AB_COMPOSE
+
+ echo " ✓ ActualBudget configured at $AB_DIR"
+
+ # Configure Caddy reverse proxy before starting
+ configure_caddy_for_service "ActualBudget" "5006" "budget"
+
+ prompt_yn "Start ActualBudget now? (y/n):" "y" START_AB
+ if [ "$START_AB" = "y" ] || [ "$START_AB" = "Y" ]; then
+ docker compose up -d 2>/dev/null && echo " ✓ ActualBudget started" || echo " ⚠ Failed to start ActualBudget"
+ fi
+
+ echo ""
+ echo " Access at: http://localhost:5006"
+ echo " Bank sync: https://simplefin.org/ (SimpleFIN account required)"
+ echo " Data dir: $AB_DIR/data"
+ echo ""
+ fi
+ fi # End AB_RECONFIGURE check
+ fi # End INSTALL_ACTUALBUDGET check
+
+ # ---- KEYCLOAK ----
+ if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_KEYCLOAK" ]; then
+ echo ""
+ echo "┌─────────────────────────────────────────────────────────────────┐"
+ echo "│ KEYCLOAK - Identity and Access Management (IAM) │"
+ echo "│ SSO, OAuth2, SAML, User Management, MFA │"
+ echo "│ Port: 8180 (HTTP) - Use reverse proxy for HTTPS │"
+ echo "└─────────────────────────────────────────────────────────────────┘"
+ prompt_yn "Install Keycloak? (y/n):" "n" INSTALL_KEYCLOAK
+ fi
+
+ if [ "$INSTALL_KEYCLOAK" = "y" ] || [ "$INSTALL_KEYCLOAK" = "Y" ]; then
+ KC_DIR="$DOCKER_DIR/keycloak"
+
+ if [ "$DRY_RUN" = true ]; then
+ echo "[DRY-RUN] Would create $KC_DIR"
+ else
+ echo "Installing Keycloak..."
+ echo ""
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo "PASSWORD REQUIREMENTS:"
+ echo " • Minimum 12 characters (16+ recommended)"
+ echo " • Letters and numbers ONLY (no special characters)"
+ echo " • Press ENTER for secure auto-generated password"
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo ""
+
+ # Prompt for admin password with validation
+ KC_ADMIN_PASS=""
+ while true; do
+ read -s -p "Enter Keycloak admin password [auto-generate]: " KC_ADMIN_PASS
+ echo ""
+
+ # Generate secure password if user pressed Enter
+ if [ -z "$KC_ADMIN_PASS" ]; then
+ KC_ADMIN_PASS=$(generate_password 20)
+ echo " ✓ Generated secure admin password (saved in .env)"
+ break
+ fi
+
+ # Validate password
+ if validate_password "$KC_ADMIN_PASS" 12; then
+ echo " ✓ Admin password accepted"
+ break
+ fi
+ echo " Please try again."
+ done
+
+ # Prompt for database password with validation
+ KC_DB_PASS=""
+ while true; do
+ read -s -p "Enter database password [auto-generate]: " KC_DB_PASS
+ echo ""
+
+ # Generate secure password if user pressed Enter
+ if [ -z "$KC_DB_PASS" ]; then
+ KC_DB_PASS=$(generate_password 32)
+ echo " ✓ Generated secure database password (saved in .env)"
+ break
+ fi
+
+ # Validate password
+ if validate_password "$KC_DB_PASS" 12; then
+ echo " ✓ Database password accepted"
+ break
+ fi
+ echo " Please try again."
+ done
+ echo ""
+
+ # Ask about production vs development mode
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo "DEPLOYMENT MODE:"
+ echo " • Production: Requires HTTPS via Caddy2 (recommended)"
+ echo " • Development: HTTP only, relaxed security (testing only)"
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ prompt_yn "Use production mode? (requires Caddy2 reverse proxy) (y/n):" "y" KC_PRODUCTION
+
+ KC_HOSTNAME=""
+ KC_START_CMD="start-dev"
+ KC_HOSTNAME_STRICT="false"
+
+ if [ "$KC_PRODUCTION" = "y" ] || [ "$KC_PRODUCTION" = "Y" ]; then
+ KC_START_CMD="start"
+ KC_HOSTNAME_STRICT="false"
+
+ echo ""
+ echo "Enter your Keycloak hostname (e.g., auth.yourdomain.com)"
+ echo "This should match your Caddy2 configuration."
+ read -p "Hostname: " KC_HOSTNAME
+
+ if [ -n "$KC_HOSTNAME" ]; then
+ echo " ✓ Production mode enabled with hostname: $KC_HOSTNAME"
+ echo " ⚠ Make sure Caddy2 is configured for this domain!"
+ else
+ echo " ⚠ No hostname provided - using relaxed mode"
+ KC_HOSTNAME=""
+ fi
+ fi
+
+ mkdir -p "$KC_DIR/data" "$KC_DIR/postgres-data"
+ ensure_docker_dir_ownership "$KC_DIR"
+ cd "$KC_DIR"
+
+ # Create .env file for sensitive credentials
+ cat > .env << KC_ENV
+# Keycloak Environment Variables
+# ⚠ KEEP THIS FILE SECURE - Contains sensitive passwords
+
+# Admin Credentials
+KEYCLOAK_ADMIN=admin
+KEYCLOAK_ADMIN_PASSWORD=$KC_ADMIN_PASS
+
+# Database Credentials
+POSTGRES_DB=keycloak
+POSTGRES_USER=keycloak
+POSTGRES_PASSWORD=$KC_DB_PASS
+KC_DB=postgres
+KC_DB_URL=jdbc:postgresql://postgres:5432/keycloak
+KC_DB_USERNAME=keycloak
+KC_DB_PASSWORD=$KC_DB_PASS
+
+# Keycloak Configuration
+# Proxy settings (v2) - Trust X-Forwarded-* headers from Caddy2
+KC_PROXY_HEADERS=xforwarded
+KC_HTTP_ENABLED=true
+KC_HOSTNAME_STRICT=$KC_HOSTNAME_STRICT
+KC_LOG_LEVEL=INFO
+KC_HEALTH_ENABLED=true
+KC_METRICS_ENABLED=true
+KC_ENV
+
+ # Add hostname to .env if provided
+ if [ -n "$KC_HOSTNAME" ]; then
+ echo "KC_HOSTNAME=$KC_HOSTNAME" >> .env
+ fi
+
+ # Create docker-compose.yml
+ cat > docker-compose.yml << KC_COMPOSE
+name: keycloak
+
+services:
+ postgres:
+ image: postgres:16-alpine
+ container_name: keycloak-db
+ restart: unless-stopped
+ env_file:
+ - .env
+ volumes:
+ - ./postgres-data:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U keycloak"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+
+ keycloak:
+ image: quay.io/keycloak/keycloak:latest
+ container_name: keycloak
+ restart: unless-stopped
+ command:
+ - $KC_START_CMD
+ env_file:
+ - .env
+ ports:
+ - "8180:8080"
+ volumes:
+ - ./data:/opt/keycloak/data
+ depends_on:
+ postgres:
+ condition: service_healthy
+ labels:
+ - "io.podman.annotations.label/fail2ban.enable=true"
+ - "io.podman.annotations.label/fail2ban.filter=caddy-auth"
+KC_COMPOSE
+
+ echo ""
+ echo " ✓ Keycloak configured at $KC_DIR"
+ echo " ✓ Credentials saved in .env file"
+ if [ "$KC_PRODUCTION" = "y" ] || [ "$KC_PRODUCTION" = "Y" ]; then
+ echo " ✓ Production mode enabled"
+ else
+ echo " ℹ Development mode (use production mode for internet-facing deployments)"
+ fi
+ echo ""
+
+ # If Caddy is installed/being installed, offer to configure it for Keycloak
+ if [ "$INSTALL_CADDY" = "y" ] || [ "$INSTALL_CADDY" = "Y" ] || [ -d "$DOCKER_DIR/caddy" ]; then
+ echo ""
+ prompt_yn "Configure Caddy reverse proxy for Keycloak? (y/n):" "y" CONFIGURE_CADDY_KC
+
+ if [ "$CONFIGURE_CADDY_KC" = "y" ] || [ "$CONFIGURE_CADDY_KC" = "Y" ]; then
+ CADDY_DIR="$DOCKER_DIR/caddy"
+
+ # Ask for domain
+ prompt_text " Domain for Keycloak (e.g., auth.yourdomain.com):" "auth.localhost" KC_CADDY_DOMAIN
+
+ if [ -f "$CADDY_DIR/Caddyfile" ]; then
+ # Backup existing Caddyfile
+ mkdir -p "$CADDY_DIR/backups"
+ cp "$CADDY_DIR/Caddyfile" "$CADDY_DIR/backups/Caddyfile.backup.$(date +%Y%m%d_%H%M%S)"
+ echo " ✓ Backed up existing Caddyfile"
+
+ # Check if Keycloak config already exists
+ if ! grep -q "$KC_CADDY_DOMAIN" "$CADDY_DIR/Caddyfile"; then
+ # Add Keycloak configuration
+ cat >> "$CADDY_DIR/Caddyfile" << EOF
+
+# Keycloak - Identity and Access Management
+$KC_CADDY_DOMAIN {
+ log {
+ output file /var/log/caddy/keycloak-access.log
+ format json
+ level INFO
+ }
+
+ reverse_proxy localhost:8180
+
+ # Security headers
+ header {
+ Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
+ X-Frame-Options "SAMEORIGIN"
+ X-Content-Type-Options "nosniff"
+ X-XSS-Protection "1; mode=block"
+ Referrer-Policy "strict-origin-when-cross-origin"
+ }
+}
+EOF
+ echo " ✓ Added Keycloak configuration to Caddyfile"
+
+ # Reload Caddy if it's running
+ if docker ps --format '{{.Names}}' | grep -q "caddy"; then
+ CADDY_CONTAINER=$(docker ps --format '{{.Names}}' | grep "caddy" | head -1)
+ echo " Reloading Caddy configuration..."
+
+ if docker exec -w /etc/caddy "$CADDY_CONTAINER" caddy fmt --overwrite 2>/dev/null; then
+ echo " ✓ Formatted Caddyfile"
+ fi
+
+ if docker exec -w /etc/caddy "$CADDY_CONTAINER" caddy reload 2>/dev/null; then
+ echo " ✓ Caddy reloaded successfully"
+ echo ""
+ echo " Keycloak will be available at: https://$KC_CADDY_DOMAIN"
+ else
+ echo " ⚠ Failed to reload Caddy - check logs"
+ echo " Manual reload: cd $CADDY_DIR && docker exec -w /etc/caddy caddy caddy reload"
+ fi
+ else
+ echo " ⚠ Caddy container not running - start it to use this configuration"
+ fi
+ else
+ echo " ℹ Keycloak configuration already exists in Caddyfile"
+ fi
+ else
+ echo " ⚠ Caddyfile not found at $CADDY_DIR/Caddyfile"
+ echo " You can configure Caddy manually later"
+ fi
+ fi
+ fi
+
+ prompt_yn "Start Keycloak now? (y/n):" "y" START_KC
+ if [ "$START_KC" = "y" ] || [ "$START_KC" = "Y" ]; then
+ echo " Starting Keycloak (this may take a minute)..."
+ docker compose up -d 2>/dev/null && echo " ✓ Keycloak started" || echo " ⚠ Failed to start Keycloak"
+
+ # Automated initial configuration
+ echo ""
+ prompt_yn "Configure Keycloak with initial realm and clients? (y/n):" "y" CONFIGURE_KC
+
+ if [ "$CONFIGURE_KC" = "y" ] || [ "$CONFIGURE_KC" = "Y" ]; then
+ echo ""
+ echo " Configuring Keycloak..."
+ echo " This will create a realm and OAuth2 clients for your services."
+ echo ""
+
+ # Get realm name
+ prompt_text " Realm name (e.g., homelab, services):" "homelab" KC_REALM
+
+ # Get domain configuration for redirect URIs
+ echo ""
+ echo " ──────────────────────────────────────────────────────────────"
+ echo " DOMAIN CONFIGURATION"
+ echo " ──────────────────────────────────────────────────────────────"
+ echo ""
+ echo " Keycloak needs to know where your services are hosted."
+ echo ""
+ echo " Options:"
+ echo " 1. Local only (http://localhost:PORT)"
+ echo " 2. Public domain (https://yourdomain.com)"
+ echo " 3. Both local and public"
+ echo ""
+ prompt_text " Enter your setup (1/2/3):" "1" KC_SETUP_TYPE
+
+ KC_DOMAIN="localhost"
+ KC_PUBLIC_DOMAIN=""
+ KC_EXTERNAL_SERVICE=""
+
+ if [ "$KC_SETUP_TYPE" = "2" ] || [ "$KC_SETUP_TYPE" = "3" ]; then
+ echo ""
+ prompt_text " Your public domain (e.g., example.com):" "" KC_PUBLIC_DOMAIN
+
+ echo ""
+ echo " ⚠ IMPORTANT: For Keycloak to work with external services,"
+ echo " it MUST be accessible at https://auth.$KC_PUBLIC_DOMAIN"
+ echo ""
+ echo " This requires:"
+ echo " ✓ DNS A record: auth.$KC_PUBLIC_DOMAIN → Your Server IP"
+ echo " ✓ Caddy reverse proxy configured"
+ echo " ✓ Ports 80/443 open in firewall"
+ echo ""
+ prompt_yn " Is Keycloak accessible at https://auth.$KC_PUBLIC_DOMAIN? (y/n):" "n" KC_DOMAIN_READY
+
+ if [ "$KC_DOMAIN_READY" != "y" ] && [ "$KC_DOMAIN_READY" != "Y" ]; then
+ echo ""
+ echo " ⚠ WARNING: Keycloak won't work with external services until"
+ echo " you configure Caddy and DNS. See KEYCLOAK-SETUP-GUIDE.md"
+ echo ""
+ echo " You can still proceed and configure Caddy later."
+ echo ""
+ fi
+
+ # Ask about external services (like Pikapod)
+ echo ""
+ prompt_yn " Are you using external hosted services (e.g., Pikapod)? (y/n):" "n" KC_HAS_EXTERNAL
+
+ if [ "$KC_HAS_EXTERNAL" = "y" ] || [ "$KC_HAS_EXTERNAL" = "Y" ]; then
+ echo ""
+ echo " Enter the URL of your external service (e.g., https://actualbudget-abc.pikapod.net)"
+ prompt_text " External service URL:" "" KC_EXTERNAL_SERVICE
+ fi
+ fi
+
+ if [ "$KC_SETUP_TYPE" = "1" ] || [ "$KC_SETUP_TYPE" = "3" ]; then
+ KC_DOMAIN="localhost"
+ fi
+
+ # Wait for Keycloak to be fully ready (can take 30-60 seconds)
+ echo ""
+ echo " Waiting for Keycloak to be ready..."
+ KC_READY=false
+ for i in {1..60}; do
+ if docker exec keycloak curl -sf http://localhost:8080/health/ready > /dev/null 2>&1; then
+ KC_READY=true
+ echo " ✓ Keycloak is ready"
+ break
+ fi
+ echo -n "."
+ sleep 2
+ done
+ echo ""
+
+ if [ "$KC_READY" = true ]; then
+ # Login to Keycloak admin CLI
+ echo " Logging in to Keycloak admin CLI..."
+ docker exec keycloak /opt/keycloak/bin/kcadm.sh config credentials \
+ --server http://localhost:8080 \
+ --realm master \
+ --user admin \
+ --password "$KC_ADMIN_PASS" > /dev/null 2>&1
+
+ if [ $? -eq 0 ]; then
+ echo " ✓ Logged in to Keycloak"
+
+ # Create realm
+ echo " Creating realm '$KC_REALM'..."
+ docker exec keycloak /opt/keycloak/bin/kcadm.sh create realms \
+ -s realm="$KC_REALM" \
+ -s enabled=true \
+ -s displayName="$KC_REALM" \
+ -s registrationAllowed=false \
+ -s resetPasswordAllowed=true \
+ -s rememberMe=true \
+ -s loginWithEmailAllowed=true \
+ -s duplicateEmailsAllowed=false \
+ -s sslRequired=EXTERNAL > /dev/null 2>&1
+
+ if [ $? -eq 0 ]; then
+ echo " ✓ Created realm '$KC_REALM'"
+ fi
+
+ # Create OAuth2 client for ActualBudget
+ if [ "$INSTALL_ACTUALBUDGET" = "y" ] || [ "$INSTALL_ACTUALBUDGET" = "Y" ]; then
+ echo " Creating OAuth2 client for ActualBudget..."
+ AB_CLIENT_SECRET=$(openssl rand -hex 32)
+
+ # Build redirect URIs based on configuration
+ AB_REDIRECT_URIS='["http://localhost:5006/*","http://localhost:5006/callback"'
+
+ if [ -n "$KC_PUBLIC_DOMAIN" ]; then
+ AB_REDIRECT_URIS="$AB_REDIRECT_URIS"',"https://budget.'$KC_PUBLIC_DOMAIN'/*","https://budget.'$KC_PUBLIC_DOMAIN'/callback"'
+ AB_REDIRECT_URIS="$AB_REDIRECT_URIS"',"https://'$KC_PUBLIC_DOMAIN':5006/*","https://'$KC_PUBLIC_DOMAIN':5006/callback"'
+ fi
+
+ if [ -n "$KC_EXTERNAL_SERVICE" ]; then
+ AB_REDIRECT_URIS="$AB_REDIRECT_URIS"',"'$KC_EXTERNAL_SERVICE'/*","'$KC_EXTERNAL_SERVICE'/callback"'
+ fi
+
+ AB_REDIRECT_URIS="$AB_REDIRECT_URIS"']'
+
+ # Build web origins
+ AB_WEB_ORIGINS='["http://localhost:5006"'
+
+ if [ -n "$KC_PUBLIC_DOMAIN" ]; then
+ AB_WEB_ORIGINS="$AB_WEB_ORIGINS"',"https://budget.'$KC_PUBLIC_DOMAIN'","https://'$KC_PUBLIC_DOMAIN':5006"'
+ fi
+
+ if [ -n "$KC_EXTERNAL_SERVICE" ]; then
+ AB_WEB_ORIGINS="$AB_WEB_ORIGINS"',"'$KC_EXTERNAL_SERVICE'"'
+ fi
+
+ AB_WEB_ORIGINS="$AB_WEB_ORIGINS"']'
+
+ docker exec keycloak /opt/keycloak/bin/kcadm.sh create clients -r "$KC_REALM" \
+ -s clientId=actualbudget \
+ -s name="ActualBudget" \
+ -s description="Personal Finance Management" \
+ -s enabled=true \
+ -s clientAuthenticatorType=client-secret \
+ -s secret="$AB_CLIENT_SECRET" \
+ -s publicClient=false \
+ -s standardFlowEnabled=true \
+ -s directAccessGrantsEnabled=true \
+ -s serviceAccountsEnabled=false \
+ -s "redirectUris=$AB_REDIRECT_URIS" \
+ -s "webOrigins=$AB_WEB_ORIGINS" \
+ -s protocol=openid-connect > /dev/null 2>&1
+
+ if [ $? -eq 0 ]; then
+ echo " ✓ Created ActualBudget client"
+ echo " Client ID: actualbudget"
+ echo " Client Secret: $AB_CLIENT_SECRET"
+ echo ""
+
+ # Save to file with appropriate URLs
+ KC_AUTH_URL="http://localhost:8180"
+ if [ -n "$KC_PUBLIC_DOMAIN" ]; then
+ KC_AUTH_URL="https://auth.$KC_PUBLIC_DOMAIN"
+ fi
+
+ cat > "$KC_DIR/actualbudget-oauth.txt" << EOF
+ActualBudget OAuth2 Configuration
+==================================
+
+Client ID: actualbudget
+Client Secret: $AB_CLIENT_SECRET
+
+LOCAL DEVELOPMENT:
+Authorization URL: http://localhost:8180/realms/$KC_REALM/protocol/openid-connect/auth
+Token URL: http://localhost:8180/realms/$KC_REALM/protocol/openid-connect/token
+User Info URL: http://localhost:8180/realms/$KC_REALM/protocol/openid-connect/userinfo
+EOF
+
+ if [ -n "$KC_PUBLIC_DOMAIN" ]; then
+ cat >> "$KC_DIR/actualbudget-oauth.txt" << EOF
+
+PRODUCTION (with Caddy at https://auth.$KC_PUBLIC_DOMAIN):
+Authorization URL: https://auth.$KC_PUBLIC_DOMAIN/realms/$KC_REALM/protocol/openid-connect/auth
+Token URL: https://auth.$KC_PUBLIC_DOMAIN/realms/$KC_REALM/protocol/openid-connect/token
+User Info URL: https://auth.$KC_PUBLIC_DOMAIN/realms/$KC_REALM/protocol/openid-connect/userinfo
+EOF
+ fi
+
+ if [ -n "$KC_EXTERNAL_SERVICE" ]; then
+ cat >> "$KC_DIR/actualbudget-oauth.txt" << EOF
+
+EXTERNAL SERVICE ($KC_EXTERNAL_SERVICE):
+- Use PRODUCTION URLs above
+- Keycloak MUST be accessible at: https://auth.$KC_PUBLIC_DOMAIN
+- Redirect URI configured: $KC_EXTERNAL_SERVICE/*
+EOF
+ fi
+
+ cat >> "$KC_DIR/actualbudget-oauth.txt" << EOF
+
+Redirect URIs configured:
+- http://localhost:5006/* (local)
+EOF
+
+ if [ -n "$KC_PUBLIC_DOMAIN" ]; then
+ cat >> "$KC_DIR/actualbudget-oauth.txt" << EOF
+- https://budget.$KC_PUBLIC_DOMAIN/* (self-hosted)
+EOF
+ fi
+
+ if [ -n "$KC_EXTERNAL_SERVICE" ]; then
+ cat >> "$KC_DIR/actualbudget-oauth.txt" << EOF
+- $KC_EXTERNAL_SERVICE/* (external)
+EOF
+ fi
+
+ cat >> "$KC_DIR/actualbudget-oauth.txt" << EOF
+
+To configure ActualBudget:
+1. Go to ActualBudget settings
+2. Enable OpenID/OAuth authentication
+3. Enter the Client ID and Secret above
+4. Use the URLs above based on your setup
+EOF
+ echo " ✓ Saved OAuth config to $KC_DIR/actualbudget-oauth.txt"
+ fi
+ fi
+
+ # Create a generic OAuth2 client template for other services
+ echo " Creating generic OAuth2 client for other services..."
+ GENERIC_CLIENT_SECRET=$(openssl rand -hex 32)
+
+ docker exec keycloak /opt/keycloak/bin/kcadm.sh create clients -r "$KC_REALM" \
+ -s clientId=generic-app \
+ -s name="Generic Application" \
+ -s description="Template client for other services" \
+ -s enabled=true \
+ -s clientAuthenticatorType=client-secret \
+ -s secret="$GENERIC_CLIENT_SECRET" \
+ -s publicClient=false \
+ -s standardFlowEnabled=true \
+ -s directAccessGrantsEnabled=true \
+ -s 'redirectUris=["http://localhost:*/*","https://'$KC_DOMAIN'/*","https://*.'$KC_DOMAIN'/*"]' \
+ -s 'webOrigins=["*"]' \
+ -s protocol=openid-connect > /dev/null 2>&1
+
+ if [ $? -eq 0 ]; then
+ echo " ✓ Created generic OAuth2 client template"
+ cat > "$KC_DIR/generic-oauth.txt" << EOF
+Generic OAuth2 Client Configuration
+====================================
+
+Client ID: generic-app
+Client Secret: $GENERIC_CLIENT_SECRET
+
+Use this as a template for other services. You can clone this client
+in the Keycloak admin console and modify the redirect URIs.
+
+Base URLs:
+- Authorization: http://localhost:8180/realms/$KC_REALM/protocol/openid-connect/auth
+- Token: http://localhost:8180/realms/$KC_REALM/protocol/openid-connect/token
+- User Info: http://localhost:8180/realms/$KC_REALM/protocol/openid-connect/userinfo
+
+For production: Replace localhost:8180 with https://auth.$KC_DOMAIN
+EOF
+ echo " ✓ Saved config to $KC_DIR/generic-oauth.txt"
+ fi
+
+ # Optionally create initial user
+ echo ""
+ prompt_yn "Create an initial user in realm '$KC_REALM'? (y/n):" "y" CREATE_USER
+
+ if [ "$CREATE_USER" = "y" ] || [ "$CREATE_USER" = "Y" ]; then
+ prompt_text " Username:" "$ACTUAL_USER" KC_USERNAME
+ prompt_text " Email:" "${KC_USERNAME}@${KC_DOMAIN}" KC_EMAIL
+ prompt_text " First name:" "" KC_FIRSTNAME
+ prompt_text " Last name:" "" KC_LASTNAME
+
+ echo " Password for $KC_USERNAME:"
+ read -s KC_USER_PASS
+ echo ""
+
+ docker exec keycloak /opt/keycloak/bin/kcadm.sh create users -r "$KC_REALM" \
+ -s username="$KC_USERNAME" \
+ -s email="$KC_EMAIL" \
+ -s firstName="$KC_FIRSTNAME" \
+ -s lastName="$KC_LASTNAME" \
+ -s enabled=true \
+ -s emailVerified=true > /dev/null 2>&1
+
+ if [ $? -eq 0 ]; then
+ # Set password
+ KC_USER_ID=$(docker exec keycloak /opt/keycloak/bin/kcadm.sh get users -r "$KC_REALM" -q username="$KC_USERNAME" 2>/dev/null | grep -o '"id" : "[^"]*"' | cut -d'"' -f4)
+
+ docker exec keycloak /opt/keycloak/bin/kcadm.sh set-password -r "$KC_REALM" \
+ --username "$KC_USERNAME" \
+ --new-password "$KC_USER_PASS" > /dev/null 2>&1
+
+ echo " ✓ Created user: $KC_USERNAME"
+ echo " ✓ Password set"
+ echo ""
+ echo " This user can now log in to ActualBudget and other services!"
+ fi
+ fi
+
+ echo ""
+ echo " ✓ Keycloak configuration complete!"
+ echo ""
+ echo " Next steps:"
+ echo " 1. Go to http://localhost:8180/admin"
+ echo " 2. Login with admin / $KC_ADMIN_PASS"
+ echo " 3. Switch to realm '$KC_REALM' (top-left dropdown)"
+ echo " 4. Manage users in Users menu"
+ echo " 5. OAuth configs saved to $KC_DIR/*.txt"
+ echo ""
+
+ else
+ echo " ⚠ Failed to login to Keycloak admin CLI"
+ echo " You can configure Keycloak manually via the web UI"
+ fi
+ else
+ echo " ⚠ Keycloak did not become ready in time"
+ echo " You can configure it manually after it starts"
+ fi
+ fi
+ fi
+
+ echo ""
+ echo " Admin console: http://localhost:8180/admin"
+ echo " Username: admin"
+ echo " Password: $KC_ADMIN_PASS"
+ echo " Database: PostgreSQL (./postgres-data)"
+ if [ -n "$KC_REALM" ]; then
+ echo " Realm: $KC_REALM"
+ echo " Config files: $KC_DIR/*.txt"
+ fi
+ echo ""
+ echo " ⚠ For production:"
+ echo " - Use HTTPS via reverse proxy (Caddy)"
+ echo " - Change command to 'start' instead of 'start-dev'"
+ echo " - Set KC_HOSTNAME to your domain"
+ echo ""
+ fi
+ fi
+
+ # ---- CADDY WEB SERVER ----
+ if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_CADDY" ]; then
+ echo ""
+ echo "┌─────────────────────────────────────────────────────────────────┐"
+ echo "│ CADDY - Modern Web Server & Reverse Proxy │"
+ echo "│ Automatic HTTPS, reverse proxy for all your services │"
+ echo "│ Port: 80 (HTTP), 443 (HTTPS) │"
+ echo "└─────────────────────────────────────────────────────────────────┘"
+ prompt_yn "Install Caddy reverse proxy? (y/n):" "n" INSTALL_CADDY
+ fi
+
+ if [ "$INSTALL_CADDY" = "y" ] || [ "$INSTALL_CADDY" = "Y" ]; then
+ CADDY_DIR="$DOCKER_DIR/caddy"
+
+ # Check if Caddy is already installed
+ if [ -f "$CADDY_DIR/Caddyfile" ] || [ -f "$CADDY_DIR/docker-compose.yml" ]; then
+ echo ""
+ echo "⚠ Caddy appears to be already installed at $CADDY_DIR"
+ prompt_yn "Do you want to reconfigure it? (y/n):" "n" RECONFIGURE_CADDY
+ if [ "$RECONFIGURE_CADDY" != "y" ] && [ "$RECONFIGURE_CADDY" != "Y" ]; then
+ echo " Skipping Caddy installation"
+ INSTALL_CADDY="n"
+ fi
+ fi
+
+ if [ "$INSTALL_CADDY" = "y" ] || [ "$INSTALL_CADDY" = "Y" ]; then
+ if [ "$DRY_RUN" = true ]; then
+ echo "[DRY-RUN] Would create $CADDY_DIR"
+ else
+ echo "Installing Caddy..."
+ mkdir -p "$CADDY_DIR/data" "$CADDY_DIR/config"
+ ensure_docker_dir_ownership "$CADDY_DIR"
+
+ # Backup existing Caddyfile if it exists
+ if [ -f "$CADDY_DIR/Caddyfile" ]; then
+ mkdir -p "$CADDY_DIR/backups"
+ BACKUP_FILE="$CADDY_DIR/backups/Caddyfile.backup.$(date +%Y%m%d_%H%M%S)"
+ cp "$CADDY_DIR/Caddyfile" "$BACKUP_FILE"
+ echo " ✓ Backed up existing Caddyfile to: $BACKUP_FILE"
+ fi
+
+ cd "$CADDY_DIR"
+
+ cat > docker-compose.yml << 'CADDY_COMPOSE'
+name: caddy
+
+services:
+ caddy:
+ image: caddy:latest
+ container_name: caddy
+ restart: unless-stopped
+ ports:
+ - "80:80"
+ - "443:443"
+ - "443:443/udp" # HTTP/3
+ volumes:
+ - ./Caddyfile:/etc/caddy/Caddyfile
+ - ./data:/data
+ - ./config:/config
+ - /var/log/caddy:/var/log/caddy
+ environment:
+ - ACME_AGREE=true
+ labels:
+ - "io.podman.annotations.label/fail2ban.enable=true"
+CADDY_COMPOSE
+
+ # Create Caddyfile if it doesn't exist
+ if [ ! -f "Caddyfile" ]; then
+ cat > Caddyfile << 'CADDYFILE'
+{
+ # Global options
+ admin off
+ # Email for Let's Encrypt notifications
+ # email admin@yourdomain.com
}
-# Sync to backup drives with their assigned directories
-backup_to_drive "\$BACKUP1" "Backup Drive 1" "\${BACKUP1_DIRS[@]}"
-backup_to_drive "\$BACKUP2" "Backup Drive 2" "\${BACKUP2_DIRS[@]}"
+# Example configuration - edit this for your services
+# Uncomment and modify these examples:
+# ActualBudget
+# budget.yourdomain.com {
+# log {
+# output file /var/log/caddy/actualbudget-access.log
+# format json
+# level INFO
+# }
+# reverse_proxy localhost:5006
+# header {
+# Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
+# X-Frame-Options "SAMEORIGIN"
+# X-Content-Type-Options "nosniff"
+# X-XSS-Protection "1; mode=block"
+# Referrer-Policy "strict-origin-when-cross-origin"
+# }
+# }
+
+# Keycloak
+# auth.yourdomain.com {
+# log {
+# output file /var/log/caddy/keycloak-access.log
+# format json
+# level INFO
+# }
+# reverse_proxy localhost:8180
+# header {
+# Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
+# X-Frame-Options "SAMEORIGIN"
+# X-Content-Type-Options "nosniff"
+# X-XSS-Protection "1; mode=block"
+# Referrer-Policy "strict-origin-when-cross-origin"
+# }
+# }
+
+# Add more services here...
+CADDYFILE
+ echo " ✓ Created example Caddyfile"
+ else
+ echo " ℹ Using existing Caddyfile"
+ fi
+
+ echo " ✓ Caddy configured at $CADDY_DIR"
+
+ prompt_yn "Start Caddy now? (y/n):" "y" START_CADDY
+ if [ "$START_CADDY" = "y" ] || [ "$START_CADDY" = "Y" ]; then
+ docker compose up -d 2>/dev/null && echo " ✓ Caddy started" || echo " ⚠ Failed to start Caddy"
+ fi
+
+ echo ""
+ echo " Configuration file: $CADDY_DIR/Caddyfile"
+ echo " Edit Caddyfile to add your domains and services"
+ echo " Reload config: cd $CADDY_DIR && docker exec -w /etc/caddy caddy caddy reload"
+ echo ""
+ echo " ⚠ IMPORTANT: Edit the Caddyfile to configure your domains!"
+ echo " - Uncomment and modify the example configurations"
+ echo " - Add your domain names"
+ echo " - Configure services you want to expose"
+ echo ""
+ fi
+ fi
+ fi
+
+ # ---- FAIL2BAN ----
+ if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_FAIL2BAN" ]; then
+ echo ""
+ echo "┌─────────────────────────────────────────────────────────────────┐"
+ echo "│ FAIL2BAN - Intrusion Prevention System │"
+ echo "│ Automatically ban IPs with failed auth attempts │"
+ echo "│ Protects SSH, Caddy, and other services │"
+ echo "└─────────────────────────────────────────────────────────────────┘"
+ prompt_yn "Install and configure fail2ban? (y/n):" "n" INSTALL_FAIL2BAN
+ fi
+
+ if [ "$INSTALL_FAIL2BAN" = "y" ] || [ "$INSTALL_FAIL2BAN" = "Y" ]; then
+ if [ "$DRY_RUN" = true ]; then
+ echo "[DRY-RUN] Would install fail2ban"
+ else
+ echo "Installing fail2ban..."
+
+ # Check if fail2ban is already installed
+ if command -v fail2ban-client &> /dev/null; then
+ echo " ✓ fail2ban is already installed"
+ else
+ echo " Installing fail2ban package..."
+ if sudo apt update && sudo apt install -y fail2ban; then
+ echo " ✓ fail2ban installed successfully"
+ else
+ echo " ⚠ Failed to install fail2ban"
+ echo " You may need to install it manually: sudo apt install fail2ban"
+ fi
+ fi
+
+ # Create log directory for Caddy
+ if [ ! -d "/var/log/caddy" ]; then
+ sudo mkdir -p /var/log/caddy
+ sudo chmod 755 /var/log/caddy
+ echo " ✓ Created /var/log/caddy directory"
+ fi
+
+ # Check if Caddy filter exists
+ FILTER_FILE="/etc/fail2ban/filter.d/caddy-auth.conf"
+ if [ ! -f "$FILTER_FILE" ]; then
+ echo " Creating fail2ban filter for Caddy..."
+
+ FILTER_CONTENT='[Definition]
+failregex = ^.*"remote_ip":"".*"status":(?:401|403|429).*$
+ ^.*"remote_addr":".*"status":(?:401|403|429).*$
+ignoreregex = ^.*"remote_ip":"(?:127\.0\.0\.1|::1)".*$
+datepattern = "ts":%%s'
+
+ if echo "$FILTER_CONTENT" | sudo tee "$FILTER_FILE" > /dev/null; then
+ echo " ✓ Created Caddy fail2ban filter"
+ else
+ echo " ⚠ Failed to create filter - you may need to create it manually"
+ fi
+ else
+ echo " ✓ Caddy fail2ban filter already exists"
+ fi
+
+ # Check if Caddy jail exists
+ JAIL_FILE="/etc/fail2ban/jail.d/caddy.conf"
+ if [ ! -f "$JAIL_FILE" ]; then
+ echo " Creating fail2ban jail for Caddy..."
+ echo ""
+ echo " Configure fail2ban settings (press Enter for defaults):"
+
+ prompt_text " Max retries before ban:" "5" F2B_MAXRETRY
+ prompt_text " Find time window (seconds):" "600" F2B_FINDTIME
+ prompt_text " Ban duration (seconds):" "3600" F2B_BANTIME
+
+ JAIL_CONTENT="[caddy-auth]
+enabled = true
+port = http,https
+filter = caddy-auth
+logpath = /var/log/caddy/access.log
+ /var/log/caddy/*-access.log
+maxretry = $F2B_MAXRETRY
+findtime = $F2B_FINDTIME
+bantime = $F2B_BANTIME
+action = iptables-multiport[name=CaddyAuth, port=\"http,https\", protocol=tcp]
+backend = auto"
+
+ if echo "$JAIL_CONTENT" | sudo tee "$JAIL_FILE" > /dev/null; then
+ echo " ✓ Created Caddy fail2ban jail"
+ else
+ echo " ⚠ Failed to create jail - you may need to create it manually"
+ fi
+ else
+ echo " ✓ Caddy fail2ban jail already exists"
+ fi
+
+ # Test fail2ban configuration
+ echo ""
+ echo " Testing fail2ban configuration..."
+ if sudo fail2ban-client -t &> /dev/null; then
+ echo " ✓ fail2ban configuration is valid"
+ else
+ echo " ⚠ fail2ban configuration has errors"
+ echo " Check with: sudo fail2ban-client -t"
+ fi
+
+ # Restart fail2ban
+ prompt_yn "Restart fail2ban to apply changes? (y/n):" "y" RESTART_F2B
+ if [ "$RESTART_F2B" = "y" ] || [ "$RESTART_F2B" = "Y" ]; then
+ if sudo systemctl restart fail2ban; then
+ echo " ✓ fail2ban restarted successfully"
+
+ # Wait for fail2ban to start
+ sleep 2
+
+ # Check jail status
+ if sudo fail2ban-client status caddy-auth &> /dev/null; then
+ echo " ✓ caddy-auth jail is active"
+ echo ""
+ sudo fail2ban-client status caddy-auth
+ else
+ echo " ⚠ caddy-auth jail is not active (may need Caddy logs to exist first)"
+ fi
+ else
+ echo " ⚠ Failed to restart fail2ban"
+ echo " Check logs: sudo journalctl -u fail2ban -n 50"
+ fi
+ fi
+
+ echo ""
+ echo " Useful commands:"
+ echo " Check jail status: sudo fail2ban-client status caddy-auth"
+ echo " View banned IPs: sudo fail2ban-client get caddy-auth banip"
+ echo " Unban IP: sudo fail2ban-client set caddy-auth unbanip 1.2.3.4"
+ echo " View logs: sudo tail -f /var/log/fail2ban.log"
+ echo ""
+ fi
+ fi
+
+ # ---- LYRION MUSIC SERVER ----
+ if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_LMS" ]; then
+ echo ""
+ echo "┌─────────────────────────────────────────────────────────────────┐"
+ echo "│ LYRION MUSIC SERVER (LMS) - Music streaming server │"
+ echo "│ Stream music to Squeezebox devices, apps, and Chromecast. │"
+ echo "│ Port: 9000 (web), 9090 (CLI), 3483 (players) │"
+ echo "└─────────────────────────────────────────────────────────────────┘"
+ prompt_yn "Install Lyrion Music Server? (y/n):" "n" INSTALL_LMS
+ fi
+
+ if [ "$INSTALL_LMS" = "y" ] || [ "$INSTALL_LMS" = "Y" ]; then
+ echo "Installing Lyrion Music Server..."
+ LMS_DIR="$DOCKER_DIR/lyrion"
+
+ if [ "$DRY_RUN" = true ]; then
+ echo "[DRY-RUN] Would create $LMS_DIR"
+ else
+ mkdir -p "$LMS_DIR"
+ ensure_docker_dir_ownership "$LMS_DIR"
+ cd "$LMS_DIR"
+
+ prompt_text "Path to music folder [default: $PRIMARY_DRIVE_PATH/music]:" "$PRIMARY_DRIVE_PATH/music" MUSIC_PATH
+
+ cat > docker-compose.yml << LMS_COMPOSE
+name: lyrion
+
+services:
+ lyrion:
+ image: lmscommunity/lyrionmusicserver:stable
+ container_name: lyrion
+ hostname: lyrion
+ restart: unless-stopped
+ network_mode: host
+ environment:
+ - HTTP_PORT=9000
+ - PUID=$(id -u "$ACTUAL_USER")
+ - PGID=$(id -g "$ACTUAL_USER")
+ - TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC")
+ volumes:
+ - ./config:/config:rw
+ - ${MUSIC_PATH}:/music:ro
+ - ./playlists:/playlists:rw
+ - /etc/localtime:/etc/localtime:ro
+LMS_COMPOSE
+
+ cat > .env << LMS_ENV
+MUSIC_PATH=$MUSIC_PATH
+LMS_ENV
+
+ mkdir -p config playlists
+ chown -R "$ACTUAL_USER:$ACTUAL_USER" "$LMS_DIR"
+
+ echo ""
+ echo "✓ Lyrion Music Server configured at $LMS_DIR"
+
+ prompt_yn "Start Lyrion Music Server now? (y/n):" "y" START_LMS
+ if [ "$START_LMS" = "y" ] || [ "$START_LMS" = "Y" ]; then
+ docker compose up -d 2>/dev/null && echo " ✓ Lyrion Music Server started" || echo " ⚠ Failed to start"
+ fi
+
+ echo " Access at: http://localhost:9000"
+ echo " Note: Uses host networking for Chromecast support"
+ echo ""
+ fi
+ fi
+
+ # ---- MEALIE ----
+ if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_MEALIE" ]; then
+ echo ""
+ echo "┌─────────────────────────────────────────────────────────────────┐"
+ echo "│ MEALIE - Recipe manager & meal planner │"
+ echo "│ Save recipes, plan meals, generate shopping lists. │"
+ echo "│ Port: 9925 │"
+ echo "└─────────────────────────────────────────────────────────────────┘"
+ prompt_yn "Install Mealie? (y/n):" "n" INSTALL_MEALIE
+ fi
+
+ if [ "$INSTALL_MEALIE" = "y" ] || [ "$INSTALL_MEALIE" = "Y" ]; then
+ echo "Installing Mealie..."
+ MEALIE_DIR="$DOCKER_DIR/mealie"
+
+ if [ "$DRY_RUN" = true ]; then
+ echo "[DRY-RUN] Would create $MEALIE_DIR"
+ else
+ mkdir -p "$MEALIE_DIR"
+ ensure_docker_dir_ownership "$MEALIE_DIR"
+ cd "$MEALIE_DIR"
+
+ cat > docker-compose.yml << MEALIE_COMPOSE
+name: mealie
+
+services:
+ mealie:
+ image: ghcr.io/mealie-recipes/mealie:latest
+ container_name: mealie
+ hostname: mealie
+ restart: unless-stopped
+ environment:
+ - PUID=$(id -u "$ACTUAL_USER")
+ - PGID=$(id -g "$ACTUAL_USER")
+ - TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC")
+ - ALLOW_SIGNUP=true
+ - MAX_WORKERS=1
+ - WEB_CONCURRENCY=1
+ - BASE_URL=http://localhost:9925
+ volumes:
+ - ./data:/app/data
+ ports:
+ - "9925:9000"
+MEALIE_COMPOSE
+
+ mkdir -p data
+ chown -R "$ACTUAL_USER:$ACTUAL_USER" "$MEALIE_DIR"
+
+ echo ""
+ echo "✓ Mealie configured at $MEALIE_DIR"
+
+ # Configure Caddy reverse proxy before starting
+ configure_caddy_for_service "Mealie" "9925" "recipes"
+
+ prompt_yn "Start Mealie now? (y/n):" "y" START_MEALIE
+ if [ "$START_MEALIE" = "y" ] || [ "$START_MEALIE" = "Y" ]; then
+ docker compose up -d 2>/dev/null && echo " ✓ Mealie started" || echo " ⚠ Failed to start"
+ fi
+
+ echo " Access at: http://localhost:9925"
+ echo " Default: changeme@email.com / MyPassword"
+ echo ""
+ fi
+ fi
+
+ # ---- MINECRAFT SERVER ----
+ if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_MINECRAFT" ]; then
+ echo ""
+ echo "┌─────────────────────────────────────────────────────────────────┐"
+ echo "│ MINECRAFT SERVER - Game server with RAM limit │"
+ echo "│ Fabric server with configurable memory allocation. │"
+ echo "│ Port: 25565 │"
+ echo "└─────────────────────────────────────────────────────────────────┘"
+ prompt_yn "Install Minecraft Server? (y/n):" "n" INSTALL_MINECRAFT
+ fi
+
+ if [ "$INSTALL_MINECRAFT" = "y" ] || [ "$INSTALL_MINECRAFT" = "Y" ]; then
+ echo "Installing Minecraft Server..."
+ MC_DIR="$DOCKER_DIR/minecraft"
+
+ if [ "$DRY_RUN" = true ]; then
+ echo "[DRY-RUN] Would create $MC_DIR"
+ else
+ mkdir -p "$MC_DIR"
+ ensure_docker_dir_ownership "$MC_DIR"
+ cd "$MC_DIR"
+
+ echo ""
+ prompt_text "Maximum RAM for Minecraft (e.g., 2G, 4G) [default: 2G]:" "2G" MC_RAM
+ MC_RAM=${MC_RAM:-2G}
+
+ cat > docker-compose.yml << MC_COMPOSE
+name: minecraft
+
+services:
+ minecraft:
+ image: itzg/minecraft-server:latest
+ container_name: minecraft
+ hostname: minecraft
+ restart: unless-stopped
+ tty: true
+ stdin_open: true
+ environment:
+ - EULA=TRUE
+ - TYPE=FABRIC
+ - VERSION=LATEST
+ - MEMORY=${MC_RAM}
+ - TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC")
+ - OPS=
+ - MOTD=A Minecraft Server
+ - DIFFICULTY=normal
+ - MODE=survival
+ volumes:
+ - ./data:/data
+ ports:
+ - "25565:25565"
+ deploy:
+ resources:
+ limits:
+ memory: ${MC_RAM}
+MC_COMPOSE
+
+ cat > .env << MC_ENV
+MC_RAM=$MC_RAM
+MC_ENV
+
+ mkdir -p data
+ chown -R "$ACTUAL_USER:$ACTUAL_USER" "$MC_DIR"
+
+ echo ""
+ echo "✓ Minecraft Server configured at $MC_DIR"
+
+ prompt_yn "Start Minecraft Server now? (y/n):" "y" START_MC
+ if [ "$START_MC" = "y" ] || [ "$START_MC" = "Y" ]; then
+ docker compose up -d 2>/dev/null && echo " ✓ Minecraft Server started" || echo " ⚠ Failed to start"
+ fi
+
+ echo " Connect: localhost:25565"
+ echo " RAM limit: $MC_RAM"
+ echo " Console: docker attach minecraft (Ctrl+P, Ctrl+Q to detach)"
+ echo ""
+ fi
+ fi
+
+ # ---- LINUX-TO-SYNC (Private Repo) ----
+ if [ "$WHIPTAIL_USED" != true ]; then
+ echo ""
+ echo "┌─────────────────────────────────────────────────────────────────┐"
+ echo "│ LINUX-TO-SYNC - Private sync repository │"
+ echo "│ Clone and set up your private linux-to-sync repository. │"
+ echo "└─────────────────────────────────────────────────────────────────┘"
+ echo ""
+ echo "Note: This requires access to github.com/outis1one/linux-to-sync"
+ echo ""
+ echo "To grant access, you need ONE of these:"
+ echo " 1. SSH key already added to your GitHub account"
+ echo " 2. GitHub Personal Access Token (PAT)"
+ echo " 3. GitHub CLI (gh) authenticated"
+ echo ""
+ prompt_yn "Set up linux-to-sync? (y/n):" "n" INSTALL_LINUXTOSYNC
+
+ if [ "$INSTALL_LINUXTOSYNC" = "y" ] || [ "$INSTALL_LINUXTOSYNC" = "Y" ]; then
+ SYNC_DIR="$DOCKER_DIR/linux-to-sync"
+
+ if [ "$DRY_RUN" = true ]; then
+ echo "[DRY-RUN] Would clone linux-to-sync to $SYNC_DIR"
+ else
+ echo ""
+ echo "Choose authentication method:"
+ echo " [1] SSH (if you have SSH key added to GitHub)"
+ echo " [2] HTTPS with token (requires Personal Access Token)"
+ echo ""
+ prompt_text "Enter 1 or 2 [default: 1]:" "1" AUTH_METHOD
+
+ if [ "$AUTH_METHOD" = "2" ]; then
+ echo ""
+ echo "Create a Personal Access Token at:"
+ echo " https://github.com/settings/tokens/new"
+ echo " - Select 'repo' scope for full repository access"
+ echo ""
+ prompt_text "Enter your GitHub Personal Access Token:" "" GH_TOKEN
+
+ if [ -n "$GH_TOKEN" ]; then
+ git clone "https://$GH_TOKEN@github.com/outis1one/linux-to-sync.git" "$SYNC_DIR" 2>/dev/null
+ if [ $? -eq 0 ]; then
+ # Remove token from remote URL for security
+ cd "$SYNC_DIR"
+ git remote set-url origin "https://github.com/outis1one/linux-to-sync.git"
+ chown -R "$ACTUAL_USER:$ACTUAL_USER" "$SYNC_DIR"
+ echo ""
+ echo "✓ linux-to-sync cloned to $SYNC_DIR"
+ echo " Note: You'll need to enter token again for push/pull"
+ echo " Or set up: git config credential.helper store"
+ else
+ echo "✗ Clone failed. Check your token and try again."
+ fi
+ else
+ echo "No token provided. Skipping."
+ fi
+ else
+ echo ""
+ echo "Attempting SSH clone..."
+ echo "(Make sure your SSH key is added to GitHub)"
+ echo ""
+ git clone git@github.com:outis1one/linux-to-sync.git "$SYNC_DIR" 2>/dev/null
+ if [ $? -eq 0 ]; then
+ chown -R "$ACTUAL_USER:$ACTUAL_USER" "$SYNC_DIR"
+ echo ""
+ echo "✓ linux-to-sync cloned to $SYNC_DIR"
+ else
+ echo ""
+ echo "✗ SSH clone failed."
+ echo ""
+ echo "To add your SSH key to GitHub:"
+ echo " 1. Copy your public key: cat ~/.ssh/id_rsa.pub"
+ echo " 2. Go to: https://github.com/settings/keys"
+ echo " 3. Click 'New SSH key' and paste your key"
+ echo ""
+ echo "Then retry this script or manually clone:"
+ echo " git clone git@github.com:outis1one/linux-to-sync.git ~/docker/linux-to-sync"
+ fi
+ fi
+ fi
+ fi
+ fi # End WHIPTAIL_USED check for linux-to-sync
+
+ # ---- JELLYFIN (Alternative to Emby) ----
+ if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_JELLYFIN" ]; then
+ echo ""
+ echo "┌─────────────────────────────────────────────────────────────────┐"
+ echo "│ JELLYFIN - Free media server (alternative to Emby) │"
+ echo "│ Stream movies, TV, music. No premium features locked. │"
+ echo "│ Port: 8096 │"
+ echo "└─────────────────────────────────────────────────────────────────┘"
+ prompt_yn "Install Jellyfin? (y/n):" "n" INSTALL_JELLYFIN
+ fi
+
+ if [ "$INSTALL_JELLYFIN" = "y" ] || [ "$INSTALL_JELLYFIN" = "Y" ]; then
+ echo "Installing Jellyfin..."
+ JELLYFIN_DIR="$DOCKER_DIR/jellyfin"
+
+ if [ "$DRY_RUN" = true ]; then
+ echo "[DRY-RUN] Would create $JELLYFIN_DIR"
+ else
+ mkdir -p "$JELLYFIN_DIR"
+ ensure_docker_dir_ownership "$JELLYFIN_DIR"
+ cd "$JELLYFIN_DIR"
+
+ prompt_text "Path to media folder [default: $PRIMARY_DRIVE_PATH/media]:" "$PRIMARY_DRIVE_PATH/media" MEDIA_PATH
+
+ # Get render group ID for hardware acceleration
+ RENDER_GID=$(getent group render | cut -d: -f3 2>/dev/null || echo "989")
+
+ cat > docker-compose.yml << JELLYFIN_COMPOSE
+name: jellyfin
+
+services:
+ jellyfin:
+ image: jellyfin/jellyfin:latest
+ container_name: jellyfin
+ hostname: jellyfin
+ restart: unless-stopped
+ environment:
+ - TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC")
+ devices:
+ - /dev/dri/renderD128:/dev/dri/renderD128
+ group_add:
+ - "$RENDER_GID"
+ volumes:
+ - ./config:/config
+ - ./cache:/cache
+ - \${MEDIA_PATH}:/media:ro
+ ports:
+ - "8096:8096"
+ - "1900:1900/udp"
+ - "7359:7359/udp"
+JELLYFIN_COMPOSE
+
+ cat > .env << JELLYFIN_ENV
+MEDIA_PATH=$MEDIA_PATH
+JELLYFIN_ENV
+
+ mkdir -p config cache
+ chown -R "$ACTUAL_USER:$ACTUAL_USER" "$JELLYFIN_DIR"
+
+ echo ""
+ echo "✓ Jellyfin configured at $JELLYFIN_DIR"
+
+ # Configure Caddy reverse proxy before starting
+ configure_caddy_for_service "Jellyfin" "8096" "jellyfin"
+
+ prompt_yn "Start Jellyfin now? (y/n):" "y" START_JELLYFIN
+ if [ "$START_JELLYFIN" = "y" ] || [ "$START_JELLYFIN" = "Y" ]; then
+ docker compose up -d 2>/dev/null && echo " ✓ Jellyfin started" || echo " ⚠ Failed to start"
+ fi
+
+ echo " Access at: http://localhost:8096"
+ echo " Note: Hardware acceleration enabled (Intel GPU)"
+ echo ""
+ fi
+ fi
+
+ # ---- FRIGATE NVR ----
+ if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_FRIGATE" ]; then
+ echo ""
+ echo "┌─────────────────────────────────────────────────────────────────┐"
+ echo "│ FRIGATE - AI-powered NVR for security cameras │"
+ echo "│ Object detection, recordings, 24/7 monitoring. │"
+ echo "│ Port: 5000 (web), 8554 (RTSP), 8555 (WebRTC) │"
+ echo "└─────────────────────────────────────────────────────────────────┘"
+ prompt_yn "Install Frigate? (y/n):" "n" INSTALL_FRIGATE
+ fi
+
+ if [ "$INSTALL_FRIGATE" = "y" ] || [ "$INSTALL_FRIGATE" = "Y" ]; then
+ echo "Installing Frigate..."
+ FRIGATE_DIR="$DOCKER_DIR/frigate"
+
+ if [ "$DRY_RUN" = true ]; then
+ echo "[DRY-RUN] Would create $FRIGATE_DIR"
+ else
+ # STEP 1: Create directory and install docker-compose
+ mkdir -p "$FRIGATE_DIR" 2>/dev/null || true
+ ensure_docker_dir_ownership "$FRIGATE_DIR"
+ cd "$FRIGATE_DIR" 2>/dev/null || cd "$DOCKER_DIR"
+
+ # Default path
+ FRIGATE_PATH="$ACTUAL_HOME/drives/primary/frigate"
+
+ cat > docker-compose.yml << FRIGATE_COMPOSE
+name: frigate
+
+services:
+ frigate:
+ image: ghcr.io/blakeblackshear/frigate:stable
+ container_name: frigate
+ hostname: frigate
+ restart: unless-stopped
+ privileged: true
+ shm_size: "256mb"
+ environment:
+ - TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC")
+ devices:
+ - /dev/dri/renderD128:/dev/dri/renderD128
+ volumes:
+ - ./config:/config
+ - \${FRIGATE_MEDIA}:/media/frigate
+ - type: tmpfs
+ target: /tmp/cache
+ tmpfs:
+ size: 1000000000
+ ports:
+ - "5000:5000"
+ - "8554:8554"
+ - "8555:8555/tcp"
+ - "8555:8555/udp"
+FRIGATE_COMPOSE
+ echo "✓ Installed docker-compose.yml"
+
+ # STEP 2: Try to configure (uses defaults if fails)
+ prompt_text "Path for recordings [$FRIGATE_PATH]:" "$FRIGATE_PATH" FRIGATE_PATH 2>/dev/null || FRIGATE_PATH="$ACTUAL_HOME/drives/primary/frigate"
+
+ cat > .env << FRIGATE_ENV
+FRIGATE_MEDIA=$FRIGATE_PATH
+FRIGATE_ENV
+
+ mkdir -p config 2>/dev/null || true
+ mkdir -p "$FRIGATE_PATH" 2>/dev/null || echo " ⚠ Could not create $FRIGATE_PATH - create manually"
+
+ # Create template config
+ cat > config/config.yml << 'FRIGATE_CONFIG'
+# Frigate Configuration
+# Docs: https://docs.frigate.video
+#
+# ⚠️ YOU MUST EDIT THIS FILE to add your cameras!
+
+mqtt:
+ enabled: false
+
+cameras:
+ # EXAMPLE - Replace with your camera:
+ # front_door:
+ # ffmpeg:
+ # inputs:
+ # - path: rtsp://user:pass@192.168.1.100:554/stream
+ # roles: [detect, record]
+ # detect:
+ # width: 1280
+ # height: 720
+ # fps: 5
+
+detectors:
+ default:
+ type: cpu
+
+record:
+ enabled: true
+ retain:
+ days: 7
+ mode: motion
+
+snapshots:
+ enabled: true
+ retain:
+ default: 7
+FRIGATE_CONFIG
+
+ chown -R "$ACTUAL_USER:$ACTUAL_USER" "$FRIGATE_DIR" 2>/dev/null || true
+
+ echo ""
+ echo "✓ Frigate installed at $FRIGATE_DIR"
+ echo ""
+ echo " ⚠️ Note: You should edit config/config.yml to add cameras before starting."
+ prompt_yn "Start Frigate now anyway? (y/n):" "n" START_FRIGATE
+ if [ "$START_FRIGATE" = "y" ] || [ "$START_FRIGATE" = "Y" ]; then
+ docker compose up -d 2>/dev/null && echo " ✓ Frigate started" || echo " ⚠ Failed to start"
+ fi
+
+ echo " Access: http://localhost:5000"
+ echo " Docs: https://docs.frigate.video"
+ echo ""
+ fi
+ fi
+
+ # ---- CADDY REVERSE PROXY (Legacy) ----
+ # Note: This is the legacy Caddy installation
+ # The newer installation above includes fail2ban support
+ # This section is kept for backwards compatibility
+ if [ "$WHIPTAIL_USED" != true ] && [ "$INSTALL_CADDY" != "y" ] && [ "$INSTALL_CADDY" != "Y" ]; then
+ echo ""
+ echo "┌─────────────────────────────────────────────────────────────────┐"
+ echo "│ CADDY - Automatic HTTPS reverse proxy (Legacy) │"
+ echo "│ Route domains to containers with automatic SSL certificates. │"
+ echo "│ Ports: 80, 443 │"
+ echo "└─────────────────────────────────────────────────────────────────┘"
+ if [ -z "$INSTALL_CADDY_LEGACY" ]; then
+ prompt_yn "Install Caddy reverse proxy? (y/n):" "n" INSTALL_CADDY_LEGACY
+ fi
+ INSTALL_CADDY="$INSTALL_CADDY_LEGACY"
+ fi
+
+ if [ "$INSTALL_CADDY" = "y" ] || [ "$INSTALL_CADDY" = "Y" ]; then
+ echo "Installing Caddy..."
+ CADDY_DIR="$DOCKER_DIR/caddy"
+
+ if [ "$DRY_RUN" = true ]; then
+ echo "[DRY-RUN] Would create $CADDY_DIR"
+ else
+ # STEP 1: Create directory and install docker-compose
+ mkdir -p "$CADDY_DIR" 2>/dev/null || true
+ cd "$CADDY_DIR" 2>/dev/null || cd "$DOCKER_DIR"
+
+ cat > docker-compose.yml << 'CADDY_COMPOSE'
+name: caddy
+
+services:
+ caddy:
+ image: caddy:latest
+ container_name: caddy
+ hostname: caddy
+ restart: unless-stopped
+ env_file: .env
+ ports:
+ - "80:80"
+ - "443:443"
+ - "443:443/udp"
+ volumes:
+ - ./Caddyfile:/etc/caddy/Caddyfile:ro
+ - ./config:/config
+ - ./data:/data
+ - ./site:/srv
+
+networks:
+ default:
+ name: caddy_net
+ external: true
+CADDY_COMPOSE
+ echo "✓ Installed docker-compose.yml"
+
+ # STEP 2: Try configuration (uses defaults if fails)
+ CADDY_DOMAIN=""
+ prompt_text "Your domain (e.g., example.com) [blank for local]:" "" CADDY_DOMAIN 2>/dev/null || CADDY_DOMAIN=""
+
+ cat > .env << CADDY_ENV
+MY_DOMAIN=${CADDY_DOMAIN:-localhost}
+TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC")
+CADDY_ENV
+
+ # Create Docker network for Caddy (ignore if exists)
+ docker network create caddy_net 2>/dev/null || true
+
+ # Create comprehensive Caddyfile with all services
+ cat > Caddyfile << 'CADDY_FILE'
+# Caddy reverse proxy configuration
+# Edit MY_DOMAIN in .env file, then uncomment services below
+#
+# To use: containers must be on 'caddy_net' network
+# Add to each container's docker-compose.yml:
+# networks:
+# default:
+# name: caddy_net
+# external: true
+
+# ============================================================================
+# GLOBAL OPTIONS
+# ============================================================================
+{
+ # Uncomment for local-only (no domain/SSL):
+ # auto_https off
+}
+
+# ============================================================================
+# MEDIA SERVERS
+# ============================================================================
+
+# Immich (photo backup)
+# immich.{$MY_DOMAIN} {
+# reverse_proxy immich_server:2283
+# }
+
+# Jellyfin (media server)
+# jellyfin.{$MY_DOMAIN} {
+# reverse_proxy jellyfin:8096
+# }
+
+# Emby (media server)
+# emby.{$MY_DOMAIN} {
+# reverse_proxy emby:8096
+# }
+
+# Audiobookshelf
+# audiobooks.{$MY_DOMAIN} {
+# reverse_proxy audiobookshelf:80
+# }
+
+# Lyrion Music Server
+# music.{$MY_DOMAIN} {
+# reverse_proxy lms:9000
+# }
+
+# ============================================================================
+# HOME AUTOMATION & MONITORING
+# ============================================================================
+
+# Frigate NVR
+# frigate.{$MY_DOMAIN} {
+# reverse_proxy frigate:5000
+# }
+
+# Uptime Kuma
+# status.{$MY_DOMAIN} {
+# reverse_proxy uptime-kuma:3001
+# }
+
+# Magic Mirror
+# mirror.{$MY_DOMAIN} {
+# reverse_proxy magicmirror:8080
+# }
+
+# Traccar GPS
+# gps.{$MY_DOMAIN} {
+# reverse_proxy traccar:8082
+# }
+
+# FindMyDevice
+# fmd.{$MY_DOMAIN} {
+# reverse_proxy fmd:8080
+# }
+
+# ============================================================================
+# UTILITIES
+# ============================================================================
+
+# Filebrowser
+# files.{$MY_DOMAIN} {
+# reverse_proxy filebrowser:80
+# }
+
+# Mealie (recipes)
+# recipes.{$MY_DOMAIN} {
+# reverse_proxy mealie:9000
+# }
+
+# ntfy (notifications)
+# ntfy.{$MY_DOMAIN} {
+# reverse_proxy ntfy:80
+# }
+
+# Portainer
+# docker.{$MY_DOMAIN} {
+# reverse_proxy portainer:9000
+# }
+
+# Kopia backup UI
+# backup.{$MY_DOMAIN} {
+# reverse_proxy kopia:51515
+# }
+
+# ============================================================================
+# TESTING / CATCH-ALL
+# ============================================================================
+
+# Local testing (always responds)
+:80 {
+ respond "Caddy is running! Edit Caddyfile to enable your services."
+}
+CADDY_FILE
+
+ mkdir -p config data site 2>/dev/null || true
+ chown -R "$ACTUAL_USER:$ACTUAL_USER" "$CADDY_DIR" 2>/dev/null || true
+
+ echo ""
+ echo "✓ Caddy installed at $CADDY_DIR"
+
+ prompt_yn "Start Caddy now? (y/n):" "y" START_CADDY
+ if [ "$START_CADDY" = "y" ] || [ "$START_CADDY" = "Y" ]; then
+ docker compose up -d 2>/dev/null && echo " ✓ Caddy started" || echo " ⚠ Failed to start"
+ fi
+
+ echo " Domain: ${CADDY_DOMAIN:-localhost} (edit .env)"
+ echo " Config: $CADDY_DIR/Caddyfile (uncomment services)"
+ echo ""
+ echo " ⚠️ Containers must be on 'caddy_net' network"
+ echo " Docs: https://caddyserver.com/docs/"
+ echo ""
+ fi
+ fi
+
+ # ---- DDCLIENT DYNAMIC DNS ----
+ if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_DDCLIENT" ]; then
+ echo ""
+ echo "┌─────────────────────────────────────────────────────────────────┐"
+ echo "│ DDCLIENT - Dynamic DNS updater │"
+ echo "│ Keep your domain pointing to your home IP. │"
+ echo "│ Supports: Cloudflare, DuckDNS, No-IP, and more. │"
+ echo "└─────────────────────────────────────────────────────────────────┘"
+ prompt_yn "Install ddclient? (y/n):" "n" INSTALL_DDCLIENT
+ fi
+
+ if [ "$INSTALL_DDCLIENT" = "y" ] || [ "$INSTALL_DDCLIENT" = "Y" ]; then
+ echo "Installing ddclient..."
+ DDCLIENT_DIR="$DOCKER_DIR/ddclient"
+
+ if [ "$DRY_RUN" = true ]; then
+ echo "[DRY-RUN] Would create $DDCLIENT_DIR"
+ else
+ # STEP 1: Install docker-compose
+ mkdir -p "$DDCLIENT_DIR" 2>/dev/null || true
+ cd "$DDCLIENT_DIR" 2>/dev/null || cd "$DOCKER_DIR"
+
+ cat > docker-compose.yml << 'DDCLIENT_COMPOSE'
+name: ddclient
+
+services:
+ ddclient:
+ image: lscr.io/linuxserver/ddclient:latest
+ container_name: ddclient
+ hostname: ddclient
+ restart: unless-stopped
+ environment:
+ - PUID=1000
+ - PGID=1000
+ - TZ=${TZ}
+ volumes:
+ - ./config:/config
+DDCLIENT_COMPOSE
+ echo "✓ Installed docker-compose.yml"
+
+ cat > .env << DDCLIENT_ENV
+TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC")
+DDCLIENT_ENV
+
+ mkdir -p config 2>/dev/null || true
+
+ # Create template config
+ cat > config/ddclient.conf << 'DDCLIENT_CONF'
+# ddclient configuration
+# ⚠️ YOU MUST EDIT THIS FILE!
+# Uncomment and edit for your DNS provider
+
+daemon=300
+syslog=yes
+pid=/var/run/ddclient/ddclient.pid
+ssl=yes
+
+# Cloudflare example:
+# use=web, web=cloudflare
+# protocol=cloudflare
+# zone=example.com
+# login=token
+# password=your-api-token
+# example.com
+
+# DuckDNS example:
+# use=web
+# protocol=duckdns
+# password=your-duckdns-token
+# yourdomain.duckdns.org
+DDCLIENT_CONF
+
+ chown -R "$ACTUAL_USER:$ACTUAL_USER" "$DDCLIENT_DIR" 2>/dev/null || true
+
+ echo ""
+ echo "✓ ddclient installed at $DDCLIENT_DIR"
+ echo ""
+ echo " ⚠️ Note: You should edit config/ddclient.conf before starting."
+ prompt_yn "Start ddclient now anyway? (y/n):" "n" START_DDCLIENT
+ if [ "$START_DDCLIENT" = "y" ] || [ "$START_DDCLIENT" = "Y" ]; then
+ docker compose up -d 2>/dev/null && echo " ✓ ddclient started" || echo " ⚠ Failed to start"
+ fi
+
+ echo " Docs: https://ddclient.net/"
+ echo ""
+ fi
+ fi
+
+ # ---- NTFY NOTIFICATIONS ----
+ if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_NTFY" ]; then
+ echo ""
+ echo "┌─────────────────────────────────────────────────────────────────┐"
+ echo "│ NTFY - Push notifications server │"
+ echo "│ Send notifications from scripts to your phone. │"
+ echo "│ Port: 8090 │"
+ echo "└─────────────────────────────────────────────────────────────────┘"
+ prompt_yn "Install ntfy? (y/n):" "n" INSTALL_NTFY
+ fi
+
+ if [ "$INSTALL_NTFY" = "y" ] || [ "$INSTALL_NTFY" = "Y" ]; then
+ echo "Installing ntfy..."
+ NTFY_DIR="$DOCKER_DIR/ntfy"
+
+ if [ "$DRY_RUN" = true ]; then
+ echo "[DRY-RUN] Would create $NTFY_DIR"
+ else
+ mkdir -p "$NTFY_DIR"
+ ensure_docker_dir_ownership "$NTFY_DIR"
+ cd "$NTFY_DIR"
+
+ cat > docker-compose.yml << 'NTFY_COMPOSE'
+name: ntfy
+
+services:
+ ntfy:
+ image: binwiederhier/ntfy:latest
+ container_name: ntfy
+ hostname: ntfy
+ restart: unless-stopped
+ command: serve
+ environment:
+ - TZ=${TZ}
+ volumes:
+ - ./cache:/var/cache/ntfy
+ - ./config:/etc/ntfy
+ ports:
+ - "8090:80"
+NTFY_COMPOSE
+
+ cat > .env << NTFY_ENV
+TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC")
+NTFY_ENV
+
+ mkdir -p cache config
+ chown -R "$ACTUAL_USER:$ACTUAL_USER" "$NTFY_DIR"
+
+ echo ""
+ echo "✓ ntfy configured at $NTFY_DIR"
+
+ prompt_yn "Start ntfy now? (y/n):" "y" START_NTFY
+ if [ "$START_NTFY" = "y" ] || [ "$START_NTFY" = "Y" ]; then
+ docker compose up -d 2>/dev/null && echo " ✓ ntfy started" || echo " ⚠ Failed to start"
+ fi
+
+ echo " Access at: http://localhost:8090"
+ echo ""
+ echo " Send notification: curl -d \"Hello!\" localhost:8090/mytopic"
+ echo " Subscribe on phone: ntfy app → Add subscription → localhost:8090/mytopic"
+ echo ""
+ fi
+ fi
+
+ # ---- UPTIME KUMA ----
+ if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_UPTIMEKUMA" ]; then
+ echo ""
+ echo "┌─────────────────────────────────────────────────────────────────┐"
+ echo "│ UPTIME KUMA - Service monitoring dashboard │"
+ echo "│ Monitor websites, servers, Docker containers. │"
+ echo "│ Port: 3001 │"
+ echo "└─────────────────────────────────────────────────────────────────┘"
+ prompt_yn "Install Uptime Kuma? (y/n):" "n" INSTALL_UPTIMEKUMA
+ fi
+
+ if [ "$INSTALL_UPTIMEKUMA" = "y" ] || [ "$INSTALL_UPTIMEKUMA" = "Y" ]; then
+ echo "Installing Uptime Kuma..."
+ UPTIME_DIR="$DOCKER_DIR/uptime-kuma"
+
+ if [ "$DRY_RUN" = true ]; then
+ echo "[DRY-RUN] Would create $UPTIME_DIR"
+ else
+ mkdir -p "$UPTIME_DIR"
+ ensure_docker_dir_ownership "$UPTIME_DIR"
+ cd "$UPTIME_DIR"
+
+ cat > docker-compose.yml << 'UPTIME_COMPOSE'
+name: uptime-kuma
+
+services:
+ uptime-kuma:
+ image: louislam/uptime-kuma:1
+ container_name: uptime-kuma
+ hostname: uptime-kuma
+ restart: unless-stopped
+ volumes:
+ - ./data:/app/data
+ - /var/run/docker.sock:/var/run/docker.sock:ro
+ ports:
+ - "3001:3001"
+UPTIME_COMPOSE
+
+ mkdir -p data
+ chown -R "$ACTUAL_USER:$ACTUAL_USER" "$UPTIME_DIR"
+
+ echo ""
+ echo "✓ Uptime Kuma configured at $UPTIME_DIR"
+
+ # Configure Caddy reverse proxy before starting
+ configure_caddy_for_service "Uptime Kuma" "3001" "uptime"
+
+ prompt_yn "Start Uptime Kuma now? (y/n):" "y" START_UPTIME
+ if [ "$START_UPTIME" = "y" ] || [ "$START_UPTIME" = "Y" ]; then
+ docker compose up -d 2>/dev/null && echo " ✓ Uptime Kuma started" || echo " ⚠ Failed to start"
+ fi
+
+ echo " Access at: http://localhost:3001"
+ echo ""
+ fi
+ fi
+
+ # ---- WG-EASY (WireGuard with Web UI) ----
+ if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_WGEASY" ]; then
+ echo ""
+ echo "┌─────────────────────────────────────────────────────────────────┐"
+ echo "│ WG-EASY - WireGuard VPN with web management │"
+ echo "│ Easy WireGuard setup with QR codes for clients. │"
+ echo "│ Port: 51821 (web), 51820 (VPN) │"
+ echo "└─────────────────────────────────────────────────────────────────┘"
+ prompt_yn "Install wg-easy? (y/n):" "n" INSTALL_WGEASY
+ fi
+
+ if [ "$INSTALL_WGEASY" = "y" ] || [ "$INSTALL_WGEASY" = "Y" ]; then
+ echo "Installing wg-easy..."
+ WGEASY_DIR="$DOCKER_DIR/wg-easy"
+
+ if [ "$DRY_RUN" = true ]; then
+ echo "[DRY-RUN] Would create $WGEASY_DIR"
+ else
+ mkdir -p "$WGEASY_DIR"
+ ensure_docker_dir_ownership "$WGEASY_DIR"
+ cd "$WGEASY_DIR"
+
+ # Get public IP or hostname
+ PUBLIC_IP=$(curl -s ifconfig.me 2>/dev/null || echo "your-public-ip")
+ WG_PASSWORD=$(openssl rand -base64 16 | tr -dc 'a-zA-Z0-9' | head -c 16)
+
+ prompt_text "Public IP or hostname for VPN [default: $PUBLIC_IP]:" "$PUBLIC_IP" WG_HOST
+
+ cat > docker-compose.yml << WGEASY_COMPOSE
+name: wg-easy
+
+services:
+ wg-easy:
+ image: ghcr.io/wg-easy/wg-easy:latest
+ container_name: wg-easy
+ hostname: wg-easy
+ restart: unless-stopped
+ cap_add:
+ - NET_ADMIN
+ - SYS_MODULE
+ sysctls:
+ - net.ipv4.ip_forward=1
+ - net.ipv4.conf.all.src_valid_mark=1
+ environment:
+ - WG_HOST=\${WG_HOST}
+ - PASSWORD=\${WG_PASSWORD}
+ - WG_DEFAULT_DNS=1.1.1.1
+ volumes:
+ - ./config:/etc/wireguard
+ ports:
+ - "51820:51820/udp"
+ - "51821:51821/tcp"
+WGEASY_COMPOSE
+
+ cat > .env << WGEASY_ENV
+WG_HOST=$WG_HOST
+WG_PASSWORD=$WG_PASSWORD
+WGEASY_ENV
+
+ mkdir -p config
+ chown -R "$ACTUAL_USER:$ACTUAL_USER" "$WGEASY_DIR"
+
+ echo ""
+ echo "✓ wg-easy configured at $WGEASY_DIR"
+
+ prompt_yn "Start wg-easy now? (y/n):" "y" START_WGEASY
+ if [ "$START_WGEASY" = "y" ] || [ "$START_WGEASY" = "Y" ]; then
+ docker compose up -d 2>/dev/null && echo " ✓ wg-easy started" || echo " ⚠ Failed to start"
+ fi
+
+ echo " Web UI: http://localhost:51821"
+ echo " Password: $WG_PASSWORD (saved in .env)"
+ echo " VPN Port: 51820/udp (forward this in your router)"
+ echo ""
+ fi
+ fi
+
+ # ---- TRACCAR GPS TRACKING ----
+ if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_TRACCAR" ]; then
+ echo ""
+ echo "┌─────────────────────────────────────────────────────────────────┐"
+ echo "│ TRACCAR - GPS tracking server │"
+ echo "│ Track phones, vehicles, assets with OwnTracks/Traccar apps. │"
+ echo "│ Port: 8082 (web), 5055 (OsmAnd), 5000+ (devices) │"
+ echo "└─────────────────────────────────────────────────────────────────┘"
+ prompt_yn "Install Traccar? (y/n):" "n" INSTALL_TRACCAR
+ fi
+
+ if [ "$INSTALL_TRACCAR" = "y" ] || [ "$INSTALL_TRACCAR" = "Y" ]; then
+ echo "Installing Traccar..."
+ TRACCAR_DIR="$DOCKER_DIR/traccar"
+
+ if [ "$DRY_RUN" = true ]; then
+ echo "[DRY-RUN] Would create $TRACCAR_DIR"
+ else
+ mkdir -p "$TRACCAR_DIR"
+ ensure_docker_dir_ownership "$TRACCAR_DIR"
+ cd "$TRACCAR_DIR"
+
+ cat > docker-compose.yml << 'TRACCAR_COMPOSE'
+name: traccar
+
+services:
+ traccar:
+ image: traccar/traccar:latest
+ container_name: traccar
+ hostname: traccar
+ restart: unless-stopped
+ volumes:
+ - ./logs:/opt/traccar/logs:rw
+ - ./data:/opt/traccar/data:rw
+ - ./config/traccar.xml:/opt/traccar/conf/traccar.xml:ro
+ ports:
+ - "8082:8082"
+ - "5000-5150:5000-5150"
+ - "5000-5150:5000-5150/udp"
+TRACCAR_COMPOSE
+
+ mkdir -p logs data config
+
+ # Create basic traccar.xml config
+ cat > config/traccar.xml << 'TRACCAR_XML'
+
+
+
+
+
+ ./conf/default.xml
+ org.h2.Driver
+ jdbc:h2:/opt/traccar/data/database
+ sa
+
+
+TRACCAR_XML
+
+ chown -R "$ACTUAL_USER:$ACTUAL_USER" "$TRACCAR_DIR"
+
+ echo ""
+ echo "✓ Traccar configured at $TRACCAR_DIR"
+
+ prompt_yn "Start Traccar now? (y/n):" "y" START_TRACCAR
+ if [ "$START_TRACCAR" = "y" ] || [ "$START_TRACCAR" = "Y" ]; then
+ docker compose up -d 2>/dev/null && echo " ✓ Traccar started" || echo " ⚠ Failed to start"
+ fi
+
+ echo " Access at: http://localhost:8082"
+ echo " Default: admin@admin.com / admin (change immediately!)"
+ echo ""
+ fi
+ fi
+
+ # ---- PORTAINER ----
+ if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_PORTAINER" ]; then
+ echo ""
+ echo "┌─────────────────────────────────────────────────────────────────┐"
+ echo "│ PORTAINER - Docker management web UI │"
+ echo "│ Manage containers, images, volumes via browser. │"
+ echo "│ Port: 9443 (https), 9000 (http) │"
+ echo "└─────────────────────────────────────────────────────────────────┘"
+ prompt_yn "Install Portainer? (y/n):" "n" INSTALL_PORTAINER
+ fi
+
+ if [ "$INSTALL_PORTAINER" = "y" ] || [ "$INSTALL_PORTAINER" = "Y" ]; then
+ echo "Installing Portainer..."
+ PORTAINER_DIR="$DOCKER_DIR/portainer"
+
+ if [ "$DRY_RUN" = true ]; then
+ echo "[DRY-RUN] Would create $PORTAINER_DIR"
+ else
+ mkdir -p "$PORTAINER_DIR"
+ ensure_docker_dir_ownership "$PORTAINER_DIR"
+ cd "$PORTAINER_DIR"
+
+ cat > docker-compose.yml << 'PORTAINER_COMPOSE'
+name: portainer
+
+services:
+ portainer:
+ image: portainer/portainer-ce:latest
+ container_name: portainer
+ hostname: portainer
+ restart: always
+ volumes:
+ - /var/run/docker.sock:/var/run/docker.sock
+ - ./data:/data
+ ports:
+ - "9000:9000"
+ - "9443:9443"
+PORTAINER_COMPOSE
+
+ mkdir -p data
+ chown -R "$ACTUAL_USER:$ACTUAL_USER" "$PORTAINER_DIR"
+
+ echo ""
+ echo "✓ Portainer configured at $PORTAINER_DIR"
+
+ prompt_yn "Start Portainer now? (y/n):" "y" START_PORTAINER
+ if [ "$START_PORTAINER" = "y" ] || [ "$START_PORTAINER" = "Y" ]; then
+ docker compose up -d 2>/dev/null && echo " ✓ Portainer started" || echo " ⚠ Failed to start"
+ fi
+
+ echo " Access at: https://localhost:9443"
+ echo " Create admin account on first visit"
+ echo ""
+ fi
+ fi
+
+ # ---- MESHCENTRAL SERVER ----
+ if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_MESHCENTRAL_SERVER" ]; then
+ echo ""
+ echo "┌─────────────────────────────────────────────────────────────────┐"
+ echo "│ MESHCENTRAL SERVER - Self-hosted remote management │"
+ echo "│ Full MeshCentral server (not just agent). Manage all devices. │"
+ echo "│ Port: 4430 (https), 4433 (agent) │"
+ echo "└─────────────────────────────────────────────────────────────────┘"
+ prompt_yn "Install MeshCentral Server? (y/n):" "n" INSTALL_MESHCENTRAL_SERVER
+ fi
+
+ if [ "$INSTALL_MESHCENTRAL_SERVER" = "y" ] || [ "$INSTALL_MESHCENTRAL_SERVER" = "Y" ]; then
+ echo "Installing MeshCentral Server..."
+ MC_DIR="$DOCKER_DIR/meshcentral"
+
+ if [ "$DRY_RUN" = true ]; then
+ echo "[DRY-RUN] Would create $MC_DIR"
+ else
+ mkdir -p "$MC_DIR" 2>/dev/null || true
+ ensure_docker_dir_ownership "$MC_DIR"
+ cd "$MC_DIR" 2>/dev/null || cd "$DOCKER_DIR"
+
+ cat > docker-compose.yml << 'MC_COMPOSE'
+name: meshcentral
+
+services:
+ meshcentral:
+ image: ghcr.io/ylianst/meshcentral:latest
+ container_name: meshcentral
+ hostname: meshcentral
+ restart: unless-stopped
+ environment:
+ - NODE_ENV=production
+ - HOSTNAME=${MC_HOSTNAME:-localhost}
+ - REVERSE_PROXY=${MC_REVERSE_PROXY:-false}
+ - REVERSE_PROXY_TLS_PORT=${MC_TLS_PORT:-443}
+ - IFRAME=false
+ - ALLOW_NEW_ACCOUNTS=true
+ - WEBRTC=true
+ volumes:
+ - ./data:/opt/meshcentral/meshcentral-data
+ - ./files:/opt/meshcentral/meshcentral-files
+ - ./backups:/opt/meshcentral/meshcentral-backups
+ ports:
+ - "4430:443"
+ - "4433:4433"
+MC_COMPOSE
+ echo "✓ Installed docker-compose.yml"
+
+ # Ask for hostname
+ echo ""
+ prompt_text "MeshCentral hostname (domain or IP) [localhost]:" "localhost" MC_HOSTNAME 2>/dev/null || MC_HOSTNAME="localhost"
+
+ cat > .env << MC_ENV
+MC_HOSTNAME=$MC_HOSTNAME
+MC_REVERSE_PROXY=false
+MC_TLS_PORT=443
+MC_ENV
+
+ mkdir -p data files backups 2>/dev/null || true
+ chown -R "$ACTUAL_USER:$ACTUAL_USER" "$MC_DIR" 2>/dev/null || true
+
+ echo ""
+ echo "✓ MeshCentral Server installed at $MC_DIR"
+
+ prompt_yn "Start MeshCentral now? (y/n):" "y" START_MESHCENTRAL
+ if [ "$START_MESHCENTRAL" = "y" ] || [ "$START_MESHCENTRAL" = "Y" ]; then
+ docker compose up -d 2>/dev/null && echo " ✓ MeshCentral started" || echo " ⚠ Failed to start"
+ fi
+
+ echo " Access: https://localhost:4430"
+ echo ""
+ echo " First visit: Create admin account"
+ echo " Then: Add devices → Download agent for each OS"
+ echo ""
+ echo " ⚠️ For remote access, set MC_HOSTNAME in .env to your domain/IP"
+ echo " Docs: https://meshcentral.com/docs/"
+ echo ""
+ fi
+ fi
+
+ # ---- FINDMYDEVICE (FMD) ----
+ if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_FMD" ]; then
+ echo ""
+ echo "┌─────────────────────────────────────────────────────────────────┐"
+ echo "│ FINDMYDEVICE - Self-hosted device tracking │"
+ echo "│ Track and locate Android devices. Alternative to Google Find. │"
+ echo "│ Port: 8084 │"
+ echo "└─────────────────────────────────────────────────────────────────┘"
+ prompt_yn "Install FindMyDevice server? (y/n):" "n" INSTALL_FMD
+ fi
+
+ if [ "$INSTALL_FMD" = "y" ] || [ "$INSTALL_FMD" = "Y" ]; then
+ echo "Installing FindMyDevice..."
+ FMD_DIR="$DOCKER_DIR/fmd"
+
+ if [ "$DRY_RUN" = true ]; then
+ echo "[DRY-RUN] Would create $FMD_DIR"
+ else
+ mkdir -p "$FMD_DIR"
+ ensure_docker_dir_ownership "$FMD_DIR"
+ cd "$FMD_DIR"
+
+ # Generate random admin password
+ FMD_ADMIN_PASS=$(openssl rand -base64 16 | tr -dc 'a-zA-Z0-9' | head -c 16)
+
+ cat > docker-compose.yml << FMD_COMPOSE
+name: fmd
+
+services:
+ fmd:
+ image: nulide/findmydevice
+ container_name: fmd
+ hostname: fmd
+ restart: unless-stopped
+ environment:
+ - FMD_ADMIN_PASSWORD=\${FMD_ADMIN_PASSWORD}
+ volumes:
+ - ./data:/fmd/data
+ ports:
+ - "8084:8080"
+FMD_COMPOSE
+
+ cat > .env << FMD_ENV
+FMD_ADMIN_PASSWORD=$FMD_ADMIN_PASS
+FMD_ENV
+
+ mkdir -p data
+ chown -R "$ACTUAL_USER:$ACTUAL_USER" "$FMD_DIR"
+
+ echo ""
+ echo "✓ FindMyDevice configured at $FMD_DIR"
+
+ prompt_yn "Start FindMyDevice now? (y/n):" "y" START_FMD
+ if [ "$START_FMD" = "y" ] || [ "$START_FMD" = "Y" ]; then
+ docker compose up -d 2>/dev/null && echo " ✓ FindMyDevice started" || echo " ⚠ Failed to start"
+ fi
+
+ echo " Access at: http://localhost:8084"
+ echo " Admin password: $FMD_ADMIN_PASS (saved in .env)"
+ echo ""
+ echo " Mobile app: Install 'FindMyDevice' from F-Droid"
+ echo " Configure app to point to: http://YOUR-SERVER-IP:8084"
+ echo ""
+ fi
+ fi
+
+ # ---- FRIGATE-NOTIFY ----
+ if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_FRIGATE_NOTIFY" ]; then
+ echo ""
+ echo "┌─────────────────────────────────────────────────────────────────┐"
+ echo "│ FRIGATE-NOTIFY - Push notifications for Frigate events │"
+ echo "│ Get alerts when Frigate detects people, cars, etc. │"
+ echo "│ Sends to: ntfy, Pushover, Discord, Gotify, and more. │"
+ echo "└─────────────────────────────────────────────────────────────────┘"
+ prompt_yn "Install Frigate-Notify? (y/n):" "n" INSTALL_FRIGATE_NOTIFY
+ fi
+
+ if [ "$INSTALL_FRIGATE_NOTIFY" = "y" ] || [ "$INSTALL_FRIGATE_NOTIFY" = "Y" ]; then
+ echo "Installing Frigate-Notify..."
+ FN_DIR="$DOCKER_DIR/frigate-notify"
+
+ if [ "$DRY_RUN" = true ]; then
+ echo "[DRY-RUN] Would create $FN_DIR"
+ else
+ # STEP 1: Create directory and docker-compose (always succeeds)
+ mkdir -p "$FN_DIR" 2>/dev/null || true
+ ensure_docker_dir_ownership "$FN_DIR"
+ cd "$FN_DIR" 2>/dev/null || cd "$DOCKER_DIR"
+
+ cat > docker-compose.yml << 'FN_COMPOSE'
+name: frigate-notify
+
+services:
+ frigate-notify:
+ image: ghcr.io/0x2142/frigate-notify:latest
+ container_name: frigate-notify
+ hostname: frigate-notify
+ restart: unless-stopped
+ volumes:
+ - ./config.yml:/app/config.yml:ro
+FN_COMPOSE
+ echo "✓ Installed docker-compose.yml"
+
+ # STEP 2: Try configuration (uses defaults if prompts fail)
+ echo ""
+ echo "Attempting auto-configuration..."
+
+ # Set smart defaults based on what's installed
+ FRIGATE_URL="http://frigate:5000"
+ NTFY_URL="https://ntfy.sh"
+ NTFY_TOPIC="frigate-alerts"
+
+ [ -d "$DOCKER_DIR/frigate" ] && echo " ✓ Frigate detected" || echo " ⚠ Frigate not found (using default URL)"
+ [ -d "$DOCKER_DIR/ntfy" ] && { NTFY_URL="http://ntfy:80"; echo " ✓ ntfy detected"; } || echo " ⚠ ntfy not found (using ntfy.sh)"
+
+ # Try prompts, use defaults if they fail
+ echo ""
+ prompt_text "Frigate URL [$FRIGATE_URL]:" "$FRIGATE_URL" FRIGATE_URL 2>/dev/null || FRIGATE_URL="http://frigate:5000"
+ prompt_text "ntfy server [$NTFY_URL]:" "$NTFY_URL" NTFY_URL 2>/dev/null || NTFY_URL="https://ntfy.sh"
+ prompt_text "ntfy topic [frigate-alerts]:" "frigate-alerts" NTFY_TOPIC 2>/dev/null || NTFY_TOPIC="frigate-alerts"
+
+ # Create config (template with user values or defaults)
+ cat > config.yml << FN_CONFIG
+# Frigate-Notify Configuration
+# Docs: https://frigate-notify.0x2142.com
+#
+# ⚠️ YOU MAY NEED TO EDIT THIS FILE!
+# If notifications don't work, check:
+# - Frigate server URL is correct
+# - ntfy server is reachable
+# - Containers are on same Docker network
+
+frigate:
+ server: $FRIGATE_URL
+ webapi:
+ enabled: true
+ interval: 30
+
+alerts:
+ general:
+ send_startup_message: true
+ labels:
+ - person
+ - car
+ # - dog
+ # - package
+
+notifiers:
+ - name: ntfy
+ enabled: true
+ provider: ntfy
+ config:
+ server: $NTFY_URL
+ topic: $NTFY_TOPIC
+FN_CONFIG
+
+ chown -R "$ACTUAL_USER:$ACTUAL_USER" "$FN_DIR" 2>/dev/null || true
+
+ echo ""
+ echo "✓ Frigate-Notify installed at $FN_DIR"
+
+ prompt_yn "Start Frigate-Notify now? (y/n):" "y" START_FN
+ if [ "$START_FN" = "y" ] || [ "$START_FN" = "Y" ]; then
+ docker compose up -d 2>/dev/null && echo " ✓ Frigate-Notify started" || echo " ⚠ Failed to start"
+ fi
+
+ echo " Config: $FN_DIR/config.yml (edit if needed)"
+ echo " Docs: https://frigate-notify.0x2142.com"
+ echo ""
+ fi
+ fi
+
+ # ---- WATCHTOWER ----
+ if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_WATCHTOWER" ]; then
+ echo ""
+ echo "┌─────────────────────────────────────────────────────────────────┐"
+ echo "│ WATCHTOWER - Container update monitoring │"
+ echo "│ Monitor containers for updates. NOTIFY ONLY by default. │"
+ echo "│ Why notify-only? Apps like Immich have breaking DB migrations. │"
+ echo "└─────────────────────────────────────────────────────────────────┘"
+ prompt_yn "Install Watchtower? (y/n):" "n" INSTALL_WATCHTOWER
+ fi
+
+ if [ "$INSTALL_WATCHTOWER" = "y" ] || [ "$INSTALL_WATCHTOWER" = "Y" ]; then
+ echo "Installing Watchtower..."
+ WT_DIR="$DOCKER_DIR/watchtower"
+
+ if [ "$DRY_RUN" = true ]; then
+ echo "[DRY-RUN] Would create $WT_DIR"
+ else
+ mkdir -p "$WT_DIR" 2>/dev/null || true
+ ensure_docker_dir_ownership "$WT_DIR"
+ cd "$WT_DIR" 2>/dev/null || cd "$DOCKER_DIR"
+
+ # Ask about mode
+ echo ""
+ echo "Watchtower Mode:"
+ echo " [M] Monitor only - Get notifications about available updates (SAFE)"
+ echo " [A] Auto-update - Automatically pull and restart containers (RISKY)"
+ echo ""
+ echo " ⚠️ Auto-update can break apps like Immich that need DB migrations!"
+ echo " Recommendation: Use monitor mode, update manually when ready."
+ echo ""
+ WT_MODE="M"
+ prompt_text "Mode [M/A]:" "M" WT_MODE 2>/dev/null || WT_MODE="M"
+ WT_MODE=$(echo "$WT_MODE" | tr '[:lower:]' '[:upper:]')
+
+ if [ "$WT_MODE" = "A" ]; then
+ MONITOR_ONLY="false"
+ echo " Mode: Auto-update (containers will be updated automatically)"
+ else
+ MONITOR_ONLY="true"
+ echo " Mode: Monitor only (you'll be notified of updates)"
+ fi
+
+ # Check for ntfy
+ NTFY_URL=""
+ if [ -d "$DOCKER_DIR/ntfy" ]; then
+ echo " ✓ ntfy detected - configuring notifications"
+ NTFY_URL="http://ntfy/watchtower"
+ fi
+
+ cat > docker-compose.yml << WT_COMPOSE
+name: watchtower
+
+services:
+ watchtower:
+ image: containrrr/watchtower:latest
+ container_name: watchtower
+ hostname: watchtower
+ restart: unless-stopped
+ environment:
+ # Check for updates daily at 4 AM
+ - WATCHTOWER_SCHEDULE=0 0 4 * * *
+ # Monitor only - don't auto-update (change to false for auto-update)
+ - WATCHTOWER_MONITOR_ONLY=${MONITOR_ONLY}
+ # Cleanup old images after update
+ - WATCHTOWER_CLEANUP=true
+ # Include stopped containers
+ - WATCHTOWER_INCLUDE_STOPPED=true
+ # Notification URL (ntfy, Discord, Slack, etc.)
+ - WATCHTOWER_NOTIFICATION_URL=${NOTIFICATION_URL:-}
+ # Show debug info
+ - WATCHTOWER_DEBUG=false
+ volumes:
+ - /var/run/docker.sock:/var/run/docker.sock:ro
+WT_COMPOSE
+
+ # Create .env
+ cat > .env << WT_ENV
+# Watchtower Configuration
+# =========================
+#
+# Monitor-only mode: Watchtower checks for updates but doesn't apply them.
+# This is SAFER because some apps (Immich, Mealie) have database migrations
+# that can break if you update without proper procedures.
+#
+# To update manually:
+# cd ~/docker/{app}
+# docker compose pull
+# docker compose up -d
+
+# Set to "false" to enable auto-updates (RISKY!)
+MONITOR_ONLY=$MONITOR_ONLY
+
+# Notification URL (optional)
+# Examples:
+# ntfy: ntfy://ntfy.example.com/watchtower
+# Discord: discord://token@id
+# Slack: slack://hook-url
+# Gotify: gotify://hostname/token
+#
+# Full list: https://containrrr.dev/shoutrrr/services/overview/
+NOTIFICATION_URL=$NTFY_URL
+WT_ENV
+
+ chown -R "$ACTUAL_USER:$ACTUAL_USER" "$WT_DIR" 2>/dev/null || true
+
+ echo ""
+ echo "✓ Watchtower installed at $WT_DIR"
+
+ prompt_yn "Start Watchtower now? (y/n):" "y" START_WATCHTOWER
+ if [ "$START_WATCHTOWER" = "y" ] || [ "$START_WATCHTOWER" = "Y" ]; then
+ docker compose up -d 2>/dev/null && echo " ✓ Watchtower started" || echo " ⚠ Failed to start"
+ fi
+
+ echo " Mode: $([ "$MONITOR_ONLY" = "true" ] && echo "Monitor only" || echo "Auto-update")"
+ echo ""
+ echo " Checks for updates daily at 4 AM."
+ if [ -n "$NTFY_URL" ]; then
+ echo " Notifications: $NTFY_URL"
+ else
+ echo " Configure NOTIFICATION_URL in .env for alerts."
+ fi
+ echo ""
+ echo " To exclude a container from Watchtower:"
+ echo " Add label: com.centurylinklabs.watchtower.enable=false"
+ echo ""
+ fi
+ fi
+
+ fi # End SKIP_DOCKER_INSTALLS check
+
+ # ============================================================================
+ # KOPIA BACKUP FOR DOCKER CONTAINERS
+ # ============================================================================
+ if [ "$WHIPTAIL_USED" != true ]; then
+ echo ""
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo "DOCKER CONTAINER BACKUP (Kopia)"
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo ""
+ echo "Backup all Docker containers (configs, databases, app data) to your"
+ echo "backup drives. Essential for disaster recovery - if your OS drive"
+ echo "fails, you can restore everything including:"
+ echo " • Immich memories, facial recognition data"
+ echo " • Emby/Jellyfin metadata, watch history"
+ echo " • Minecraft worlds, mods, permissions"
+ echo " • All app configs, users, and databases"
+ echo ""
+ prompt_yn "Set up Kopia container backup? (y/n):" "n" INSTALL_KOPIA
+
+ if [ "$INSTALL_KOPIA" = "y" ] || [ "$INSTALL_KOPIA" = "Y" ]; then
+ echo "Installing Kopia backup..."
+ KOPIA_DIR="$DOCKER_DIR/kopia"
+
+ if [ "$DRY_RUN" = true ]; then
+ echo "[DRY-RUN] Would create $KOPIA_DIR"
+ else
+ mkdir -p "$KOPIA_DIR"
+ ensure_docker_dir_ownership "$KOPIA_DIR"
+ cd "$KOPIA_DIR"
+
+ KOPIA_PASSWORD=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24)
+
+ echo ""
+ echo "Select backup destination(s):"
+ echo " Backups will be stored in ~/drives/{backup-drive}/kopia-repo/"
+ echo ""
+
+ # List available backup drives
+ echo "Available mount points in ~/drives/:"
+ ls -1 "$ACTUAL_HOME/drives/" 2>/dev/null | grep -v "^primary$" || echo " (none found - set up drives first)"
+ echo ""
+
+ prompt_text "Backup drive name [default: backup1]:" "backup1" KOPIA_BACKUP_DRIVE
+
+ KOPIA_REPO="$ACTUAL_HOME/drives/$KOPIA_BACKUP_DRIVE/kopia-repo"
+
+ cat > docker-compose.yml << KOPIA_COMPOSE
+name: kopia
+
+services:
+ kopia:
+ image: kopia/kopia:latest
+ container_name: kopia
+ hostname: kopia
+ restart: unless-stopped
+ privileged: true
+ devices:
+ - /dev/fuse:/dev/fuse:rwm
+ environment:
+ - TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC")
+ - KOPIA_PASSWORD=\${KOPIA_PASSWORD}
+ command: >
+ server start
+ --tls-generate-cert
+ --disable-csrf-token-checks
+ --address=0.0.0.0:51515
+ --server-username=admin
+ --server-password=\${KOPIA_PASSWORD}
+ volumes:
+ - ./config:/app/config
+ - ./cache:/app/cache
+ - ./logs:/app/logs
+ - $DOCKER_DIR:/data/docker:ro
+ - $KOPIA_REPO:/repository
+ - ./tmp:/tmp:shared
+ ports:
+ - "51515:51515"
+KOPIA_COMPOSE
+
+ cat > .env << KOPIA_ENV
+KOPIA_PASSWORD=$KOPIA_PASSWORD
+KOPIA_ENV
+
+ mkdir -p config cache logs tmp
+ mkdir -p "$KOPIA_REPO"
+
+ # Create backup script
+ cat > backup-containers.sh << 'BACKUP_SCRIPT'
+#!/bin/bash
+# Backup all Docker containers using Kopia
+
+DOCKER_DIR="$HOME/docker"
+TIMESTAMP=$(date +%Y%m%d-%H%M%S)
+
+echo "=== Docker Container Backup: $TIMESTAMP ==="
+echo ""
+
+# Stop containers before backup for consistency (optional)
+read -p "Stop containers during backup for consistency? (y/n): " STOP_CONTAINERS
+
+if [ "$STOP_CONTAINERS" = "y" ]; then
+ echo "Stopping containers..."
+ for dir in "$DOCKER_DIR"/*/; do
+ if [ -f "$dir/docker-compose.yml" ]; then
+ echo " Stopping $(basename $dir)..."
+ (cd "$dir" && docker compose stop) 2>/dev/null
+ fi
+ done
+fi
+
+echo ""
+echo "Running Kopia backup..."
+docker exec kopia kopia snapshot create /data/docker --description "Container backup $TIMESTAMP"
+
+if [ "$STOP_CONTAINERS" = "y" ]; then
+ echo ""
+ echo "Restarting containers..."
+ for dir in "$DOCKER_DIR"/*/; do
+ if [ -f "$dir/docker-compose.yml" ]; then
+ echo " Starting $(basename $dir)..."
+ (cd "$dir" && docker compose start) 2>/dev/null
+ fi
+ done
+fi
+
+echo ""
+echo "=== Backup Complete ==="
+docker exec kopia kopia snapshot list /data/docker --max-results 5
+BACKUP_SCRIPT
+
+ # Create restore script
+ cat > restore-containers.sh << 'RESTORE_SCRIPT'
+#!/bin/bash
+# Restore Docker containers from Kopia backup
+
+echo "=== Docker Container Restore ==="
+echo ""
+echo "Available snapshots:"
+docker exec kopia kopia snapshot list /data/docker
+
+echo ""
+echo "To restore a specific snapshot:"
+echo " docker exec kopia kopia restore /tmp/restore"
+echo " Then copy files from ~/docker/kopia/tmp/restore/ to ~/docker/"
+echo ""
+echo "To mount snapshots for browsing:"
+echo " docker exec kopia kopia mount all /tmp/mnt &"
+echo " Then browse: ~/docker/kopia/tmp/mnt/"
+RESTORE_SCRIPT
+
+ chmod +x backup-containers.sh restore-containers.sh
+ chown -R "$ACTUAL_USER:$ACTUAL_USER" "$KOPIA_DIR"
+
+ echo ""
+ echo "✓ Kopia backup configured at $KOPIA_DIR"
+
+ prompt_yn "Start Kopia now? (y/n):" "y" START_KOPIA
+ if [ "$START_KOPIA" = "y" ] || [ "$START_KOPIA" = "Y" ]; then
+ docker compose up -d 2>/dev/null && echo " ✓ Kopia started" || echo " ⚠ Failed to start"
+ fi
+
+ echo " Web UI: https://localhost:51515"
+ echo " Username: admin"
+ echo " Password: $KOPIA_PASSWORD (saved in .env)"
+ echo ""
+ echo " Repository at: $KOPIA_REPO"
+ echo ""
+ echo " Backup now: cd $KOPIA_DIR && ./backup-containers.sh"
+ echo " Restore: cd $KOPIA_DIR && ./restore-containers.sh"
+ echo ""
+ echo " ⚠️ SAVE YOUR KOPIA PASSWORD! Without it, backups cannot be restored."
+ echo ""
+ fi
+ fi
+ fi # End WHIPTAIL_USED check for Kopia
+
+ echo ""
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo "Docker applications configured in: $DOCKER_DIR"
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo ""
+ echo "To start an application:"
+ echo " cd ~/docker/{appname}"
+ echo " docker compose up -d"
+ echo ""
+ echo "To view logs:"
+ echo " docker compose logs -f"
+ echo ""
+ echo "To stop:"
+ echo " docker compose down"
+ echo ""
+fi
+
+# Backup System (Optional)
+if [ "$WHIPTAIL_USED" != true ]; then
+echo ""
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+echo "LOCAL BACKUP SYSTEM (Optional)"
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+echo ""
+echo "Set up rsync backup from primary drive(s) to backup drive(s)."
+echo ""
+echo "Why rsync instead of RAID?"
+echo " • RAID mirrors corruption instantly - rsync gives you time to notice"
+echo " • RAID requires identical drives - rsync works with any sizes"
+echo " • RAID is complex to set up/recover - rsync is simple copy"
+echo " • rsync can run on schedule - RAID is always-on (more wear)"
+echo " • With rsync, backup drives can be disconnected for safety"
+echo ""
+
+# Check if backup is already configured
+BACKUP_CONFIGURED=false
+if [ -f /usr/local/bin/backup-scripts/rsync-backup.sh ]; then
+ BACKUP_CONFIGURED=true
+ echo "Local backup system is already configured."
+ echo ""
+ prompt_yn "Reconfigure local backup system? (y/n):" "n" SETUP_BACKUP
+else
+ prompt_yn "Set up local backup system? (y/n):" "n" SETUP_BACKUP
+fi
+
+if [ "$SETUP_BACKUP" = "y" ] || [ "$SETUP_BACKUP" = "Y" ]; then
+ echo ""
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo "DRIVE CONFIGURATION"
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo ""
+ echo "Configure your drive mount points in ~/drives/"
+ echo "Default names: primary, backup1, backup2, etc."
+ echo "You can customize these (e.g., 'media', 'documents', 'photos-backup')"
+ echo ""
+
+ # Install rsync if needed
+ if ! is_rsync_installed; then
+ run_cmd apt install -y rsync || echo "Warning: rsync installation failed"
+ fi
+
+ # Create backup script directory
+ mkdir -p /usr/local/bin/backup-scripts
+
+ # Ask for primary drive name
+ echo "PRIMARY DRIVE (source for backups):"
+ prompt_text " Mount point name [default: primary]:" "primary" PRIMARY_NAME
+ PRIMARY_NAME="${PRIMARY_NAME:-primary}"
+
+ # Ask for number of backup drives
+ echo ""
+ echo "BACKUP DRIVES (destinations):"
+ prompt_text " How many backup drives? [1-4, default: 1]:" "1" NUM_BACKUPS
+ NUM_BACKUPS="${NUM_BACKUPS:-1}"
+
+ # Validate number
+ case $NUM_BACKUPS in
+ 1|2|3|4) ;;
+ *) NUM_BACKUPS=1 ;;
+ esac
+
+ # Collect backup drive names
+ declare -a BACKUP_NAMES
+ for i in $(seq 1 $NUM_BACKUPS); do
+ prompt_text " Backup drive $i name [default: backup$i]:" "backup$i" "BACKUP_NAME_$i"
+ eval "BACKUP_NAMES[$i]=\${BACKUP_NAME_$i:-backup$i}"
+ done
+
+ # Create mount point directories
+ echo ""
+ echo "Creating mount point directories..."
+ mkdir -p "$ACTUAL_HOME/drives/$PRIMARY_NAME"
+ for i in $(seq 1 $NUM_BACKUPS); do
+ mkdir -p "$ACTUAL_HOME/drives/${BACKUP_NAMES[$i]}"
+ done
+ chown -R "$ACTUAL_USER:$ACTUAL_USER" "$ACTUAL_HOME/drives"
+
+ echo "✓ Created mount points:"
+ echo " Primary: $ACTUAL_HOME/drives/$PRIMARY_NAME"
+ for i in $(seq 1 $NUM_BACKUPS); do
+ echo " Backup$i: $ACTUAL_HOME/drives/${BACKUP_NAMES[$i]}"
+ done
+
+ echo ""
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo "DRIVE MOUNTING"
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo ""
+ echo "Available block devices:"
+ echo ""
+ lsblk -o NAME,SIZE,TYPE,FSTYPE,MOUNTPOINT,LABEL
+ echo ""
+
+ prompt_yn "Mount drives now? (y/n):" "n" MOUNT_NOW
+
+ if [ "$MOUNT_NOW" = "y" ] || [ "$MOUNT_NOW" = "Y" ]; then
+ echo ""
+ echo "Enter device paths (e.g., /dev/sdb1) or leave blank to skip"
+ echo ""
+
+ read -p "Primary drive ($PRIMARY_NAME) device: " PRIMARY_DEV
+
+ declare -a BACKUP_DEVS
+ for i in $(seq 1 $NUM_BACKUPS); do
+ read -p "Backup drive ${BACKUP_NAMES[$i]} device: " "BACKUP_DEV_$i"
+ eval "BACKUP_DEVS[$i]=\$BACKUP_DEV_$i"
+ done
+
+ # Mount primary
+ if [ -n "$PRIMARY_DEV" ] && [ -b "$PRIMARY_DEV" ]; then
+ echo "Mounting $PRIMARY_DEV to $ACTUAL_HOME/drives/$PRIMARY_NAME..."
+ mount "$PRIMARY_DEV" "$ACTUAL_HOME/drives/$PRIMARY_NAME" && echo "✓ $PRIMARY_NAME mounted" || echo "✗ Failed to mount $PRIMARY_NAME"
+ fi
+
+ # Mount backups
+ for i in $(seq 1 $NUM_BACKUPS); do
+ if [ -n "${BACKUP_DEVS[$i]}" ] && [ -b "${BACKUP_DEVS[$i]}" ]; then
+ echo "Mounting ${BACKUP_DEVS[$i]} to $ACTUAL_HOME/drives/${BACKUP_NAMES[$i]}..."
+ mount "${BACKUP_DEVS[$i]}" "$ACTUAL_HOME/drives/${BACKUP_NAMES[$i]}" && echo "✓ ${BACKUP_NAMES[$i]} mounted" || echo "✗ Failed to mount ${BACKUP_NAMES[$i]}"
+ fi
+ done
+
+ echo ""
+ echo "Current mounts:"
+ df -h | grep "$ACTUAL_HOME/drives" || echo " (no drives currently mounted)"
+
+ echo ""
+ prompt_yn "Add to /etc/fstab for auto-mount at boot? (y/n):" "n" ADD_FSTAB
+
+ if [ "$ADD_FSTAB" = "y" ] || [ "$ADD_FSTAB" = "Y" ]; then
+ echo ""
+ echo "Adding entries to /etc/fstab..."
+ cp /etc/fstab /etc/fstab.backup-$(date +%Y%m%d-%H%M%S)
+
+ if [ -n "$PRIMARY_DEV" ] && [ -b "$PRIMARY_DEV" ]; then
+ PRIMARY_UUID=$(blkid -s UUID -o value "$PRIMARY_DEV")
+ if [ -n "$PRIMARY_UUID" ]; then
+ echo "UUID=$PRIMARY_UUID $ACTUAL_HOME/drives/$PRIMARY_NAME auto defaults,nofail 0 2" >> /etc/fstab
+ echo "✓ Added $PRIMARY_NAME to fstab"
+ fi
+ fi
+
+ for i in $(seq 1 $NUM_BACKUPS); do
+ if [ -n "${BACKUP_DEVS[$i]}" ] && [ -b "${BACKUP_DEVS[$i]}" ]; then
+ BACKUP_UUID=$(blkid -s UUID -o value "${BACKUP_DEVS[$i]}")
+ if [ -n "$BACKUP_UUID" ]; then
+ echo "UUID=$BACKUP_UUID $ACTUAL_HOME/drives/${BACKUP_NAMES[$i]} auto defaults,nofail 0 2" >> /etc/fstab
+ echo "✓ Added ${BACKUP_NAMES[$i]} to fstab"
+ fi
+ fi
+ done
+ fi
+ fi
+
+ # Create rsync backup script
+ echo ""
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo "CREATING BACKUP SCRIPT"
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+
+ # Build backup destinations list
+ BACKUP_DESTS=""
+ for i in $(seq 1 $NUM_BACKUPS); do
+ BACKUP_DESTS="$BACKUP_DESTS \"$ACTUAL_HOME/drives/${BACKUP_NAMES[$i]}\""
+ done
+
+ cat > /usr/local/bin/backup-scripts/rsync-backup.sh << BACKUP_SCRIPT
+#!/bin/bash
+################################################################################
+# rsync Local Backup Script
+# Backs up primary drive to all backup drives
+################################################################################
+
+PRIMARY="$ACTUAL_HOME/drives/$PRIMARY_NAME"
+BACKUP_DRIVES=($BACKUP_DESTS)
+LOG="/var/log/rsync-backup.log"
+
+echo "=== rsync Backup Started: \$(date) ===" | tee -a "\$LOG"
+echo "Source: \$PRIMARY" | tee -a "\$LOG"
+echo "" | tee -a "\$LOG"
+
+if [ ! -d "\$PRIMARY" ] || [ -z "\$(ls -A \$PRIMARY 2>/dev/null)" ]; then
+ echo "ERROR: Primary drive not mounted or empty at \$PRIMARY" | tee -a "\$LOG"
+ exit 1
+fi
+
+for BACKUP in "\${BACKUP_DRIVES[@]}"; do
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" | tee -a "\$LOG"
+ echo "Backing up to: \$BACKUP" | tee -a "\$LOG"
+
+ if [ ! -d "\$BACKUP" ]; then
+ echo "⚠️ WARNING: \$BACKUP not mounted, skipping" | tee -a "\$LOG"
+ continue
+ fi
+
+ # rsync options:
+ # -a = archive mode (preserves permissions, timestamps, etc.)
+ # -v = verbose
+ # -h = human-readable sizes
+ # --delete = remove files from backup that don't exist on primary
+ # --progress = show progress
+
+ rsync -avh --delete --progress "\$PRIMARY/" "\$BACKUP/" 2>&1 | tee -a "\$LOG"
+
+ if [ \$? -eq 0 ]; then
+ echo "✓ Backup to \$BACKUP completed" | tee -a "\$LOG"
+ else
+ echo "✗ Backup to \$BACKUP FAILED" | tee -a "\$LOG"
+ fi
+done
+
+echo "" | tee -a "\$LOG"
echo "=== Backup Completed: \$(date) ===" | tee -a "\$LOG"
BACKUP_SCRIPT
-chmod +x /usr/local/bin/backup-scripts/rclone-backup.sh
+ chmod +x /usr/local/bin/backup-scripts/rsync-backup.sh
-# Create systemd service for automatic backups (optional)
-cat > /etc/systemd/system/rclone-backup.service << 'SERVICE'
+ # Create systemd service
+ cat > /etc/systemd/system/rsync-backup.service << SERVICE
[Unit]
-Description=Rclone Backup Service
+Description=rsync Local Backup Service
After=network.target
[Service]
Type=oneshot
-ExecStart=/usr/local/bin/backup-scripts/rclone-backup.sh
+ExecStart=/usr/local/bin/backup-scripts/rsync-backup.sh
User=root
SERVICE
-# Create systemd timer for daily backups at 2 AM (optional)
-cat > /etc/systemd/system/rclone-backup.timer << 'TIMER'
+ # Create systemd timer for daily backups at 2 AM
+ cat > /etc/systemd/system/rsync-backup.timer << TIMER
[Unit]
-Description=Daily Rclone Backup Timer
-Requires=rclone-backup.service
+Description=Daily rsync Backup Timer
+Requires=rsync-backup.service
[Timer]
-OnCalendar=daily
-OnCalendar=02:00
+OnCalendar=*-*-* 02:00:00
Persistent=true
[Install]
WantedBy=timers.target
TIMER
-echo "✓ Rclone backup script created at /usr/local/bin/backup-scripts/rclone-backup.sh"
-echo "✓ Systemd service/timer created (disabled by default)"
+ echo ""
+ echo "✓ Backup script created: /usr/local/bin/backup-scripts/rsync-backup.sh"
+ echo "✓ Systemd service/timer created (disabled by default)"
+ echo ""
+ echo "Quick start:"
+ echo " Test backup: sudo /usr/local/bin/backup-scripts/rsync-backup.sh"
+ echo " Enable daily: sudo systemctl enable --now rsync-backup.timer"
+ echo " View log: tail -f /var/log/rsync-backup.log"
+else
+ echo "Skipping local backup setup."
+fi
+fi # End WHIPTAIL_USED check for local backup
-
-# Full system upgrade
+# Cloud Backup with rclone (Optional)
+if [ "$WHIPTAIL_USED" != true ]; then
echo ""
-echo "Performing full system upgrade..."
-apt upgrade -y
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+echo "CLOUD BACKUP (Optional)"
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+echo ""
+echo "Set up encrypted cloud backup using rclone."
+echo "Supports: Google Drive, OneDrive, Dropbox, and 40+ other providers."
+echo ""
+echo "Your files are encrypted BEFORE upload - the cloud provider cannot read them."
+echo ""
+
+prompt_yn "Set up encrypted cloud backup? (y/n):" "n" SETUP_CLOUD_BACKUP
+
+if [ "$SETUP_CLOUD_BACKUP" = "y" ] || [ "$SETUP_CLOUD_BACKUP" = "Y" ]; then
+ echo ""
+
+ # Install rclone if needed
+ if ! is_rclone_installed; then
+ echo "Installing rclone..."
+ run_cmd apt install -y rclone || echo "Warning: rclone installation failed"
+ fi
+
+ if is_rclone_installed; then
+ echo ""
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo "CHOOSE CLOUD PROVIDER"
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo ""
+ echo " [1] Google Drive (15GB free)"
+ echo " [2] Microsoft OneDrive (5GB free, 1TB with Microsoft 365)"
+ echo " [3] Other (manual rclone config)"
+ echo ""
+ prompt_text "Select provider [1/2/3]:" "1" CLOUD_PROVIDER
+
+ echo ""
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo "RCLONE CONFIGURATION"
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo ""
+
+ case $CLOUD_PROVIDER in
+ 1)
+ echo "Setting up Google Drive..."
+ echo ""
+ echo "Step 1: Run 'rclone config' to create a Google Drive remote"
+ echo "Step 2: When prompted:"
+ echo " - Choose 'n' for new remote"
+ echo " - Name it 'gdrive'"
+ echo " - Choose 'drive' (Google Drive)"
+ echo " - Leave client_id and client_secret blank"
+ echo " - Choose scope '1' (full access)"
+ echo " - Leave root_folder_id blank"
+ echo " - Leave service_account_file blank"
+ echo " - Choose 'n' for advanced config"
+ echo " - Choose 'y' for auto config (opens browser)"
+ echo " - Choose 'n' for team drive"
+ echo " - Confirm with 'y'"
+ echo ""
+ CLOUD_REMOTE="gdrive"
+ ;;
+ 2)
+ echo "Setting up Microsoft OneDrive..."
+ echo ""
+ echo "Step 1: Run 'rclone config' to create a OneDrive remote"
+ echo "Step 2: When prompted:"
+ echo " - Choose 'n' for new remote"
+ echo " - Name it 'onedrive'"
+ echo " - Choose 'onedrive' (Microsoft OneDrive)"
+ echo " - Leave client_id and client_secret blank"
+ echo " - Choose region (usually 'global')"
+ echo " - Choose 'n' for advanced config"
+ echo " - Choose 'y' for auto config (opens browser)"
+ echo " - Choose 'onedrive' for account type"
+ echo " - Choose your drive from the list (usually option 0)"
+ echo " - Confirm with 'y'"
+ echo ""
+ CLOUD_REMOTE="onedrive"
+ ;;
+ *)
+ echo "Manual configuration selected."
+ echo "Run 'rclone config' to set up your cloud provider."
+ echo ""
+ prompt_text "Enter the remote name you will create:" "cloud" CLOUD_REMOTE
+ ;;
+ esac
+
+ if [ "$UNATTENDED" != true ]; then
+ echo "Press Enter to launch rclone config..."
+ read
+ sudo -u "$ACTUAL_USER" rclone config
+ else
+ echo "Skipping interactive rclone config (unattended mode)"
+ echo "Run 'rclone config' manually to complete setup"
+ fi
+
+ echo ""
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo "ENCRYPTION SETUP"
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo ""
+ echo "Now we'll create an encrypted wrapper around your cloud storage."
+ echo "This encrypts file names AND contents before upload."
+ echo ""
+ echo "⚠️ IMPORTANT: You will set a password. Without this password and"
+ echo " the rclone config file, your files CANNOT be recovered!"
+ echo ""
+
+ if [ "$UNATTENDED" != true ]; then
+ echo "In rclone config:"
+ echo " - Choose 'n' for new remote"
+ echo " - Name it '${CLOUD_REMOTE}-crypt'"
+ echo " - Choose 'crypt' (Encrypt/Decrypt)"
+ echo " - Remote: '${CLOUD_REMOTE}:backup' (folder on cloud storage)"
+ echo " - Choose 'standard' for filename encryption"
+ echo " - Choose 'true' for directory name encryption"
+ echo " - Choose 'y' to enter your own password"
+ echo " - Enter a STRONG password (you'll need this to decrypt!)"
+ echo " - Choose 'y' for salt password (or 'n' to skip)"
+ echo " - Confirm with 'y'"
+ echo ""
+ echo "Press Enter to continue rclone config..."
+ read
+ sudo -u "$ACTUAL_USER" rclone config
+ fi
+
+ # Backup the rclone config to local drives
+ echo ""
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo "BACKUP YOUR RCLONE CONFIG"
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo ""
+ echo "Your rclone config contains your encryption keys."
+ echo "WITHOUT IT, YOUR ENCRYPTED CLOUD FILES CANNOT BE DECRYPTED!"
+ echo ""
+ echo "The config file is at: $ACTUAL_HOME/.config/rclone/rclone.conf"
+ echo ""
+
+ # Copy config to any mounted backup drives
+ if [ -d "$ACTUAL_HOME/drives" ]; then
+ for drive_dir in "$ACTUAL_HOME/drives"/*/; do
+ if [ -d "$drive_dir" ] && mountpoint -q "$drive_dir" 2>/dev/null; then
+ mkdir -p "${drive_dir}.rclone-config-backup"
+ if [ -f "$ACTUAL_HOME/.config/rclone/rclone.conf" ]; then
+ cp "$ACTUAL_HOME/.config/rclone/rclone.conf" "${drive_dir}.rclone-config-backup/rclone.conf.backup"
+ echo "✓ Config backed up to: ${drive_dir}.rclone-config-backup/"
+ fi
+ fi
+ done
+ fi
+
+ echo ""
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo "SECURE OFF-SITE BACKUP OF CONFIG"
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo ""
+ echo "For disaster recovery, store a copy of rclone.conf OFF-SITE:"
+ echo ""
+ echo "Recommended secure methods:"
+ echo " • Signal (end-to-end encrypted, send to yourself or trusted contact)"
+ echo " • Box.com (better privacy policy than Dropbox)"
+ echo " • Password manager (1Password, Bitwarden, etc.)"
+ echo " • Encrypted USB drive stored at another location"
+ echo ""
+ echo "⚠️ Dropbox: Works but has broader data access policies."
+ echo " Consider encrypting the config file before uploading."
+ echo ""
+ echo "To use on another computer:"
+ echo " 1. Install rclone"
+ echo " 2. Copy rclone.conf to ~/.config/rclone/"
+ echo " 3. Run: rclone ls ${CLOUD_REMOTE}-crypt:"
+ echo ""
+
+ # Create cloud backup script
+ mkdir -p /usr/local/bin/backup-scripts
+
+ SOURCE_PATH="${PRIMARY_NAME:-primary}"
+
+ cat > /usr/local/bin/backup-scripts/cloud-backup.sh << CLOUD_SCRIPT
+#!/bin/bash
+################################################################################
+# rclone Encrypted Cloud Backup Script
+################################################################################
+
+SOURCE="$ACTUAL_HOME/drives/$SOURCE_PATH"
+REMOTE="${CLOUD_REMOTE:-gdrive}-crypt"
+LOG="/var/log/cloud-backup.log"
+
+echo "=== Cloud Backup Started: \$(date) ===" | tee -a "\$LOG"
+echo "Source: \$SOURCE" | tee -a "\$LOG"
+echo "Destination: \$REMOTE:" | tee -a "\$LOG"
+echo "" | tee -a "\$LOG"
+
+if [ ! -d "\$SOURCE" ] || [ -z "\$(ls -A \$SOURCE 2>/dev/null)" ]; then
+ echo "ERROR: Source not mounted or empty at \$SOURCE" | tee -a "\$LOG"
+ exit 1
+fi
+
+# Sync to encrypted cloud storage
+rclone sync "\$SOURCE" "\$REMOTE:" \\
+ --progress \\
+ --stats=30s \\
+ --log-file="\$LOG" \\
+ --log-level INFO
+
+if [ \$? -eq 0 ]; then
+ echo "" | tee -a "\$LOG"
+ echo "✓ Cloud backup completed: \$(date)" | tee -a "\$LOG"
+else
+ echo "" | tee -a "\$LOG"
+ echo "✗ Cloud backup FAILED: \$(date)" | tee -a "\$LOG"
+fi
+CLOUD_SCRIPT
+
+ chmod +x /usr/local/bin/backup-scripts/cloud-backup.sh
+ chown "$ACTUAL_USER:$ACTUAL_USER" /usr/local/bin/backup-scripts/cloud-backup.sh
+
+ echo ""
+ echo "✓ Cloud backup script created: /usr/local/bin/backup-scripts/cloud-backup.sh"
+ echo ""
+ echo "Quick start:"
+ echo " Test backup: sudo /usr/local/bin/backup-scripts/cloud-backup.sh"
+ echo " View log: tail -f /var/log/cloud-backup.log"
+ else
+ echo "rclone installation failed. Skipping cloud backup setup."
+ fi
+else
+ echo "Skipping cloud backup setup."
+fi
+fi # End WHIPTAIL_USED check for cloud backup
+
+# UFW Firewall Configuration (Optional)
+if [ "$WHIPTAIL_USED" != true ]; then
+echo ""
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+echo "UFW FIREWALL (Optional)"
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+echo ""
+echo "UFW (Uncomplicated Firewall) provides a simple interface for managing"
+echo "iptables firewall rules."
+echo ""
+
+UFW_ACTIVE=false
+if is_ufw_installed && ufw status 2>/dev/null | grep -q "Status: active"; then
+ UFW_ACTIVE=true
+ echo "UFW is already enabled."
+ ufw status 2>/dev/null | head -20
+ echo ""
+ prompt_yn "Reconfigure UFW? (y/n):" "n" CONFIGURE_UFW
+else
+ if is_ufw_installed; then
+ echo "UFW is installed but not enabled."
+ else
+ echo "UFW is not installed."
+ fi
+ echo ""
+ prompt_yn "Enable and configure UFW firewall? (y/n):" "y" CONFIGURE_UFW
+fi
+
+if [ "$CONFIGURE_UFW" = "y" ] || [ "$CONFIGURE_UFW" = "Y" ]; then
+ echo ""
+ echo "Configuring UFW firewall..."
+
+ if [ "$DRY_RUN" = true ]; then
+ echo "[DRY-RUN] Would install ufw if needed"
+ echo "[DRY-RUN] Would allow SSH (port 22)"
+ if [ "$INSTALL_SAMBA" = "y" ] || [ "$INSTALL_SAMBA" = "Y" ]; then
+ echo "[DRY-RUN] Would allow Samba"
+ fi
+ echo "[DRY-RUN] Would enable UFW"
+ else
+ # Install UFW if not present
+ if ! is_ufw_installed; then
+ apt install -y ufw || echo "Warning: UFW installation failed"
+ fi
+
+ if is_ufw_installed; then
+ # Always allow SSH first (before enabling!)
+ ufw allow ssh
+ echo "✓ Allowed SSH (port 22)"
+
+ # Allow Samba if installed
+ if [ "$INSTALL_SAMBA" = "y" ] || [ "$INSTALL_SAMBA" = "Y" ] || is_samba_installed; then
+ ufw allow samba
+ echo "✓ Allowed Samba"
+ fi
+
+ # Allow Docker service ports (only if Docker was installed)
+ if [ "$INSTALL_DOCKER" = "y" ] || [ "$INSTALL_DOCKER" = "Y" ]; then
+ echo ""
+ echo "Opening firewall ports for Docker services..."
+
+ prompt_yn "Open firewall ports for installed Docker services? (y/n):" "y" OPEN_DOCKER_PORTS
+ if [ "$OPEN_DOCKER_PORTS" = "y" ] || [ "$OPEN_DOCKER_PORTS" = "Y" ]; then
+
+ # Reverse proxies (NPM or Caddy)
+ if [ "$INSTALL_CADDY" = "y" ] || [ "$INSTALL_CADDY" = "Y" ]; then
+ ufw allow 80/tcp comment 'HTTP' 2>/dev/null
+ ufw allow 443/tcp comment 'HTTPS' 2>/dev/null
+ echo " ✓ Allowed HTTP/HTTPS (80, 443)"
+ fi
+
+ # Media servers
+ if [ "$INSTALL_IMMICH" = "y" ] || [ "$INSTALL_IMMICH" = "Y" ]; then
+ ufw allow 2283/tcp comment 'Immich' 2>/dev/null
+ echo " ✓ Allowed Immich (2283)"
+ fi
+ if [ "$INSTALL_JELLYFIN" = "y" ] || [ "$INSTALL_JELLYFIN" = "Y" ]; then
+ ufw allow 8096/tcp comment 'Jellyfin' 2>/dev/null
+ echo " ✓ Allowed Jellyfin (8096)"
+ fi
+ if [ "$INSTALL_EMBY" = "y" ] || [ "$INSTALL_EMBY" = "Y" ]; then
+ ufw allow 8096/tcp comment 'Emby' 2>/dev/null
+ echo " ✓ Allowed Emby (8096)"
+ fi
+
+ # NVR
+ if [ "$INSTALL_FRIGATE" = "y" ] || [ "$INSTALL_FRIGATE" = "Y" ]; then
+ ufw allow 5000/tcp comment 'Frigate' 2>/dev/null
+ ufw allow 8554/tcp comment 'Frigate RTSP' 2>/dev/null
+ ufw allow 8555/tcp comment 'Frigate WebRTC' 2>/dev/null
+ ufw allow 8555/udp comment 'Frigate WebRTC UDP' 2>/dev/null
+ echo " ✓ Allowed Frigate (5000, 8554, 8555)"
+ fi
+
+ # Utilities
+ if [ "$INSTALL_PORTAINER" = "y" ] || [ "$INSTALL_PORTAINER" = "Y" ]; then
+ ufw allow 9000/tcp comment 'Portainer HTTP' 2>/dev/null
+ ufw allow 9443/tcp comment 'Portainer HTTPS' 2>/dev/null
+ echo " ✓ Allowed Portainer (9000, 9443)"
+ fi
+ if [ "$INSTALL_UPTIMEKUMA" = "y" ] || [ "$INSTALL_UPTIMEKUMA" = "Y" ]; then
+ ufw allow 3001/tcp comment 'Uptime Kuma' 2>/dev/null
+ echo " ✓ Allowed Uptime Kuma (3001)"
+ fi
+
+ # VPN
+ if [ "$INSTALL_WGEASY" = "y" ] || [ "$INSTALL_WGEASY" = "Y" ]; then
+ ufw allow 51820/udp comment 'WireGuard VPN' 2>/dev/null
+ ufw allow 51821/tcp comment 'WG-Easy Web UI' 2>/dev/null
+ echo " ✓ Allowed WireGuard (51820/udp, 51821)"
+ fi
+
+ # GPS Tracking
+ if [ "$INSTALL_TRACCAR" = "y" ] || [ "$INSTALL_TRACCAR" = "Y" ]; then
+ ufw allow 8082/tcp comment 'Traccar' 2>/dev/null
+ ufw allow 5055/tcp comment 'Traccar OsmAnd' 2>/dev/null
+ echo " ✓ Allowed Traccar (8082, 5055)"
+ fi
+
+ # Music server
+ if [ "$INSTALL_LMS" = "y" ] || [ "$INSTALL_LMS" = "Y" ]; then
+ ufw allow 9000/tcp comment 'Lyrion Music Server' 2>/dev/null
+ ufw allow 3483/tcp comment 'LMS Players' 2>/dev/null
+ ufw allow 3483/udp comment 'LMS Players UDP' 2>/dev/null
+ echo " ✓ Allowed Lyrion Music Server (9000, 3483)"
+ fi
+
+ # Notifications
+ if [ "$INSTALL_NTFY" = "y" ] || [ "$INSTALL_NTFY" = "Y" ]; then
+ ufw allow 8090/tcp comment 'ntfy' 2>/dev/null
+ echo " ✓ Allowed ntfy (8090)"
+ fi
+
+ # Minecraft
+ if [ "$INSTALL_MINECRAFT" = "y" ] || [ "$INSTALL_MINECRAFT" = "Y" ]; then
+ ufw allow 25565/tcp comment 'Minecraft' 2>/dev/null
+ echo " ✓ Allowed Minecraft (25565)"
+ fi
+
+ # Other services
+ if [ "$INSTALL_FILEBROWSER" = "y" ] || [ "$INSTALL_FILEBROWSER" = "Y" ]; then
+ ufw allow 8085/tcp comment 'Filebrowser' 2>/dev/null
+ echo " ✓ Allowed Filebrowser (8085)"
+ fi
+ if [ "$INSTALL_FMD" = "y" ] || [ "$INSTALL_FMD" = "Y" ]; then
+ ufw allow 8084/tcp comment 'FindMyDevice' 2>/dev/null
+ echo " ✓ Allowed FindMyDevice (8084)"
+ fi
+ if [ "$INSTALL_MEALIE" = "y" ] || [ "$INSTALL_MEALIE" = "Y" ]; then
+ ufw allow 9925/tcp comment 'Mealie' 2>/dev/null
+ echo " ✓ Allowed Mealie (9925)"
+ fi
+ if [ "$INSTALL_MAGICMIRROR" = "y" ] || [ "$INSTALL_MAGICMIRROR" = "Y" ]; then
+ ufw allow 8081:8083/tcp comment 'MagicMirror' 2>/dev/null
+ echo " ✓ Allowed MagicMirror (8081-8083)"
+ fi
+ if [ "$INSTALL_ARM" = "y" ] || [ "$INSTALL_ARM" = "Y" ]; then
+ ufw allow 8080/tcp comment 'A.R.M.' 2>/dev/null
+ echo " ✓ Allowed A.R.M. (8080)"
+ fi
+ if [ "$INSTALL_AUDIOBOOKSHELF" = "y" ] || [ "$INSTALL_AUDIOBOOKSHELF" = "Y" ]; then
+ ufw allow 13378/tcp comment 'Audiobookshelf' 2>/dev/null
+ echo " ✓ Allowed Audiobookshelf (13378)"
+ fi
+
+ echo ""
+ fi
+ fi
+
+ # Enable UFW (with --force to avoid prompt)
+ ufw --force enable
+ echo "✓ UFW firewall enabled"
+
+ echo ""
+ echo "Current UFW status:"
+ ufw status
+ else
+ echo "✗ UFW installation failed, skipping configuration"
+ fi
+ fi
+else
+ echo "Skipping UFW configuration."
+fi
+
+CONFIGURE_UFW="${CONFIGURE_UFW:-n}"
+fi # End WHIPTAIL_USED check for UFW
+
+# Full system upgrade (optional)
+echo ""
+prompt_yn "Perform full system upgrade? (y/n):" "n" DO_SYSTEM_UPGRADE
+if [ "$DO_SYSTEM_UPGRADE" = "y" ] || [ "$DO_SYSTEM_UPGRADE" = "Y" ]; then
+ echo "Performing full system upgrade..."
+ run_cmd apt upgrade -y
+else
+ echo "Skipping system upgrade."
+fi
# Clean up
echo ""
echo "Cleaning up..."
-apt autoremove -y
-apt autoclean
+run_cmd apt autoremove -y
+run_cmd apt autoclean
echo ""
echo "=== Installation Complete! ==="
echo ""
echo "Installed Software:"
-echo " ✓ net-tools, ncdu, git, curl, wget, vim, htop, tree, zip/unzip"
-echo " ✓ rclone - Backup/sync tool"
+echo " ✓ net-tools, ncdu, git, curl, wget, htop, tree, zip/unzip"
echo " ✓ OpenSSH Server - SSH remote access"
-echo " ✓ Samba - File sharing (Primary drive shared)"
-echo " ✓ Docker Engine + Docker Compose"
-echo " ✓ NetBird - Mesh VPN"
-echo " ✓ RustDesk - Remote desktop"
+if [ "$INSTALL_FAIL2BAN" = "y" ] || [ "$INSTALL_FAIL2BAN" = "Y" ]; then
+ echo " ✓ fail2ban - SSH brute-force protection"
+fi
+if [ "$INSTALL_DOCKER" = "y" ] || [ "$INSTALL_DOCKER" = "Y" ]; then
+ echo " ✓ Docker Engine + Docker Compose"
+fi
+if [ "$INSTALL_SAMBA" = "y" ] || [ "$INSTALL_SAMBA" = "Y" ]; then
+ echo " ✓ Samba - File sharing (Primary drive shared)"
+fi
+if [ "$INSTALL_NETBIRD" = "y" ] || [ "$INSTALL_NETBIRD" = "Y" ]; then
+ echo " ✓ NetBird - Mesh VPN"
+fi
+if [ "$INSTALL_WIREGUARD" = "y" ] || [ "$INSTALL_WIREGUARD" = "Y" ]; then
+ echo " ✓ WireGuard - VPN"
+fi
+if [ "$INSTALL_TAILSCALE" = "y" ] || [ "$INSTALL_TAILSCALE" = "Y" ]; then
+ echo " ✓ Tailscale - Mesh VPN"
+fi
+if [ "$INSTALL_RUSTDESK" = "y" ] || [ "$INSTALL_RUSTDESK" = "Y" ]; then
+ echo " ✓ RustDesk - Remote desktop"
+fi
+if [ "$INSTALL_TEAMVIEWER" = "y" ] || [ "$INSTALL_TEAMVIEWER" = "Y" ]; then
+ echo " ✓ TeamViewer - Remote desktop"
+fi
+if [ "$INSTALL_MESHCENTRAL" = "y" ] || [ "$INSTALL_MESHCENTRAL" = "Y" ]; then
+ echo " ✓ MeshCentral Agent - Remote management"
+fi
+if [ "$SETUP_BACKUP" = "y" ] || [ "$SETUP_BACKUP" = "Y" ]; then
+ echo " ✓ Local backup configured (rsync)"
+fi
+if [ "$SETUP_CLOUD_BACKUP" = "y" ] || [ "$SETUP_CLOUD_BACKUP" = "Y" ]; then
+ echo " ✓ Cloud backup configured (rclone)"
+fi
+if [ "$CONFIGURE_UFW" = "y" ] || [ "$CONFIGURE_UFW" = "Y" ]; then
+ echo " ✓ UFW Firewall - enabled"
+fi
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "SSH AUTHENTICATION SETUP"
@@ -642,238 +8810,81 @@ echo " ✓ Just GitHub or just Launchpad"
echo " ✓ Just NetBird SSH"
echo " ✓ None (password auth only - if no keys imported)"
echo ""
+if [ "$SETUP_BACKUP" = "y" ] || [ "$SETUP_BACKUP" = "Y" ]; then
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
-echo "RCLONE BACKUP SETUP - Exact Drive Mirroring"
+echo "BACKUP SYSTEM - $BACKUP_TOOL ($BACKUP_MODE mode)"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
-echo " Mount points created at:"
+echo " Mount points:"
echo " $ACTUAL_HOME/drives/primary"
echo " $ACTUAL_HOME/drives/backup1"
+if [ "$BACKUP_MODE" = "split" ]; then
echo " $ACTUAL_HOME/drives/backup2"
+fi
echo ""
-if [ "$MOUNT_NOW" = "y" ] || [ "$MOUNT_NOW" = "Y" ]; then
-echo " ✓ Drives have been mounted (if you provided valid device paths)"
+echo " Backup script: /usr/local/bin/backup-scripts/${BACKUP_TOOL}-backup.sh"
+echo " Log file: /var/log/${BACKUP_TOOL}-backup.log"
echo ""
-else
-echo " → Drives NOT mounted yet. To mount manually:"
-echo " See available drives: lsblk -f"
-echo " Mount: sudo mount /dev/sdX1 $ACTUAL_HOME/drives/primary"
-echo " Make permanent: Add to /etc/fstab (see instructions below)"
+if [ "$BACKUP_MODE" = "split" ]; then
+echo " ⚠️ CONFIGURE BEFORE RUNNING:"
+echo " Edit the script to set which folders go to which backup drive."
+echo " sudo nano /usr/local/bin/backup-scripts/${BACKUP_TOOL}-backup.sh"
echo ""
fi
-echo " ⚠️ CRITICAL: CONFIGURE BEFORE RUNNING ⚠️"
-echo " The backup script uses SPLIT BACKUP strategy"
-echo " You decide which folders go to which backup drive"
-echo ""
-echo " ┌─────────────────────────────────────────────────────────────┐"
-echo " │ STEP 1: CONFIGURE THE SPLIT BACKUP SCRIPT │"
-echo " └─────────────────────────────────────────────────────────────┘"
-echo ""
-echo " Edit the backup script:"
-echo " sudo nano /usr/local/bin/backup-scripts/rclone-backup.sh"
-echo ""
-echo " ╔═══════════════════════════════════════════════════════════════╗"
-echo " ║ SPLIT BACKUP EXPLAINED: ║"
-echo " ║ ║"
-echo " ║ Your PRIMARY drive is bigger than either backup drive ║"
-echo " ║ Example: Primary=4TB, Backup1=2TB, Backup2=2TB ║"
-echo " ║ ║"
-echo " ║ Solution: DIVIDE your folders between the two backups ║"
-echo " ║ • Some folders → backup1 only ║"
-echo " ║ • Other folders → backup2 only ║"
-echo " ║ ║"
-echo " ║ Each folder is backed up to ONE drive, NOT both ║"
-echo " ╚═══════════════════════════════════════════════════════════════╝"
-echo ""
-echo " Step 1a: Check how much space your folders use"
-echo " du -sh $ACTUAL_HOME/drives/primary/*"
-echo ""
-echo " Example output:"
-echo " 500G primary/work"
-echo " 800G primary/photos"
-echo " 1.2T primary/videos"
-echo " 500G primary/music"
-echo ""
-echo " Step 1b: Divide folders so each backup drive has enough space"
-echo ""
-echo " In the script, find these two lists:"
-echo ""
-echo " # Folders to backup to BACKUP1 only (example: 1.3TB total)"
-echo " BACKUP1_DIRS=("
-echo " \"work\" # 500G"
-echo " \"photos\" # 800G"
-echo " )"
-echo ""
-echo " # Folders to backup to BACKUP2 only (example: 1.7TB total)"
-echo " BACKUP2_DIRS=("
-echo " \"videos\" # 1.2T"
-echo " \"music\" # 500G"
-echo " )"
-echo ""
-echo " ⚠️ What this means:"
-echo " primary/work/ → backup1/work/ (backup1 ONLY)"
-echo " primary/photos/ → backup1/photos/ (backup1 ONLY)"
-echo " primary/videos/ → backup2/videos/ (backup2 ONLY)"
-echo " primary/music/ → backup2/music/ (backup2 ONLY)"
-echo ""
-echo " ✓ Benefit: Your 4TB primary fits across two 2TB backups"
-echo " ⚠️ Risk: If backup1 fails, you lose work/ and photos/ backups"
-echo " (but original data on primary is still safe!)"
-echo ""
-echo " Save with: Ctrl+O, then Enter, then Ctrl+X to exit"
-echo ""
-echo " ┌─────────────────────────────────────────────────────────────┐"
-echo " │ STEP 2: TEST WITH DRY-RUN (DO THIS FIRST!) │"
-echo " └─────────────────────────────────────────────────────────────┘"
-echo ""
-echo " Test what would sync to BACKUP1 (without actually copying):"
-echo ""
-echo " rclone sync $ACTUAL_HOME/drives/primary/work \\"
-echo " $ACTUAL_HOME/drives/backup1/work \\"
-echo " --checksum --dry-run -v"
-echo ""
-echo " Test what would sync to BACKUP2:"
-echo ""
-echo " rclone sync $ACTUAL_HOME/drives/primary/videos \\"
-echo " $ACTUAL_HOME/drives/backup2/videos \\"
-echo " --checksum --dry-run -v"
-echo ""
-echo " The dry-run shows:"
-echo " • Files that would copy FROM primary TO backup"
-echo " • Files that would be DELETED from backup (not on primary)"
-echo " • Total data that would transfer"
-echo ""
-echo " ⚠️ Read carefully! Make sure the right folders go to the right drives!"
-echo ""
-echo " ┌─────────────────────────────────────────────────────────────┐"
-echo " │ STEP 3: RUN FIRST BACKUP MANUALLY │"
-echo " └─────────────────────────────────────────────────────────────┘"
-echo ""
-echo " Only after dry-run looks correct:"
-echo " sudo /usr/local/bin/backup-scripts/rclone-backup.sh"
-echo ""
-echo " The script will sync:"
-echo " FROM: $ACTUAL_HOME/drives/primary/[BACKUP1_DIRS]"
-echo " TO: $ACTUAL_HOME/drives/backup1/[same folders]"
-echo ""
-echo " FROM: $ACTUAL_HOME/drives/primary/[BACKUP2_DIRS]"
-echo " TO: $ACTUAL_HOME/drives/backup2/[same folders]"
-echo ""
-echo " Each folder goes to its assigned backup drive only!"
-echo ""
-echo " Monitor live progress:"
-echo " tail -f /var/log/rclone-backup.log"
-echo ""
-echo " The log shows which folders sync to which backup drives"
-echo ""
-echo " ┌─────────────────────────────────────────────────────────────┐"
-echo " │ STEP 4: ENABLE AUTOMATIC DAILY BACKUPS (Optional) │"
-echo " └─────────────────────────────────────────────────────────────┘"
-echo ""
-echo " Only enable after successful manual backup:"
-echo " sudo systemctl enable rclone-backup.timer"
-echo " sudo systemctl start rclone-backup.timer"
-echo ""
-echo " This will automatically run the split backup daily at 2 AM"
-echo " (Each folder syncs to its assigned backup drive)"
-echo ""
-echo " Check status:"
-echo " sudo systemctl status rclone-backup.timer"
-echo " sudo systemctl list-timers"
-echo ""
-echo " Change schedule (default: 2 AM daily):"
-echo " sudo systemctl edit rclone-backup.timer"
-echo ""
-echo " ┌─────────────────────────────────────────────────────────────┐"
-echo " │ STEP 5: MANUAL MOUNT INSTRUCTIONS (if you skipped earlier) │"
-echo " └─────────────────────────────────────────────────────────────┘"
-echo ""
-echo " Find drive UUIDs:"
-echo " sudo blkid"
-echo ""
-echo " Edit /etc/fstab for permanent mounts:"
-echo " sudo nano /etc/fstab"
-echo ""
-echo " Add lines like (replace UUID with actual values from blkid):"
-echo " UUID=xxxx-xxxx $ACTUAL_HOME/drives/primary auto defaults 0 2"
-echo " UUID=yyyy-yyyy $ACTUAL_HOME/drives/backup1 auto defaults 0 2"
-echo " UUID=zzzz-zzzz $ACTUAL_HOME/drives/backup2 auto defaults 0 2"
-echo ""
-echo " ┌─────────────────────────────────────────────────────────────┐"
-echo " │ STEP 6: DRIVE FAILURE & RECOVERY (Split Backup Strategy) │"
-echo " └─────────────────────────────────────────────────────────────┘"
-echo ""
-echo " If SOURCE (primary) drive fails - CRITICAL SITUATION:"
-echo " ⚠️ With split backup, you need BOTH backup drives to restore!"
-echo ""
-echo " 1. Get a new drive (same size or larger than primary)"
-echo " 2. Format it: sudo mkfs.ext4 /dev/sdX1"
-echo " 3. Mount as primary: sudo mount /dev/sdX1 $ACTUAL_HOME/drives/primary"
-echo " 4. Restore from BOTH backups:"
-echo " rclone sync $ACTUAL_HOME/drives/backup1/ $ACTUAL_HOME/drives/primary/ --checksum"
-echo " rclone sync $ACTUAL_HOME/drives/backup2/ $ACTUAL_HOME/drives/primary/ --checksum"
-echo " 5. Update /etc/fstab with new UUID"
-echo ""
-echo " If DESTINATION (backup1 or backup2) drive fails:"
-echo " ⚠️ You lose backup of those specific folders until drive is replaced!"
-echo ""
-echo " Example: backup1 fails (had work/ and photos/ backups)"
-echo " • Your PRIMARY still has work/ and photos/ (original data is safe)"
-echo " • backup2 still works (videos/ and music/ are still backed up)"
-echo " • But work/ and photos/ have NO backup until you fix backup1"
-echo ""
-echo " Recovery:"
-echo " 1. Replace the failed drive"
-echo " 2. Format: sudo mkfs.ext4 /dev/sdX1"
-echo " 3. Mount: sudo mount /dev/sdX1 $ACTUAL_HOME/drives/backup1"
-echo " 4. Update /etc/fstab if needed"
-echo " 5. Run backup script - rclone will sync the assigned folders back"
-echo ""
-echo " ⚠️ IMPORTANT: Replace failed backup drives quickly!"
-echo " While a backup drive is down, those folders have no redundancy."
-echo ""
-echo " ┌─────────────────────────────────────────────────────────────┐"
-echo " │ STEP 7: VERIFY BACKUPS - Check Sync Status │"
-echo " └─────────────────────────────────────────────────────────────┘"
-echo ""
-echo " ┌─────────────────────────────────────────────────────────────┐"
-echo " │ VERIFY BACKUPS - Check if PRIMARY and BACKUP match │"
-echo " └─────────────────────────────────────────────────────────────┘"
-echo ""
-echo " Compare SOURCE (primary) vs DESTINATION (backup):"
-echo " rclone check $ACTUAL_HOME/drives/primary/documents \\"
-echo " $ACTUAL_HOME/drives/backup1/documents"
-echo ""
-echo " This shows any differences between the two locations."
-echo " If they match perfectly, you'll see: \"0 differences found\""
-echo ""
-echo " One-way check (files that exist on primary but not backup):"
-echo " rclone check $ACTUAL_HOME/drives/primary/documents \\"
-echo " $ACTUAL_HOME/drives/backup1/documents \\"
-echo " --checksum --one-way"
+echo " Quick start:"
+echo " 1. Test (dry-run): sudo ${BACKUP_TOOL}-backup.sh --dry-run # (edit script first)"
+echo " 2. Run manually: sudo /usr/local/bin/backup-scripts/${BACKUP_TOOL}-backup.sh"
+echo " 3. Enable auto: sudo systemctl enable ${BACKUP_TOOL}-backup.timer"
+echo " sudo systemctl start ${BACKUP_TOOL}-backup.timer"
echo ""
+echo " Monitor: tail -f /var/log/${BACKUP_TOOL}-backup.log"
+fi
+
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "OTHER IMPORTANT NOTES"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
-echo " Samba File Sharing:"
-if command -v smbd &> /dev/null && grep -q "\[Primary\]" /etc/samba/smb.conf 2>/dev/null; then
-echo " ✓ Share 'Primary' is accessible at:"
-echo " Windows: \\\\$(hostname)\\Primary"
-echo " Mac/Linux: smb://$(hostname)/Primary"
-echo " • Username: $ACTUAL_USER"
-echo " • Use the Samba password you just set"
-echo ""
-else
-echo " Not configured (Samba installation may have failed)"
-echo ""
+if [ "$INSTALL_SAMBA" = "y" ] || [ "$INSTALL_SAMBA" = "Y" ]; then
+ echo " Samba File Sharing:"
+ if command -v smbd &> /dev/null && grep -q "\[Primary\]" /etc/samba/smb.conf 2>/dev/null; then
+ echo " ✓ Share 'Primary' is accessible at:"
+ echo " Windows: \\\\$(hostname)\\Primary"
+ echo " Mac/Linux: smb://$(hostname)/Primary"
+ echo " • Username: $ACTUAL_USER"
+ echo " • Use the Samba password you just set"
+ echo ""
+ else
+ echo " ✗ Installation may have failed - check 'systemctl status smbd'"
+ echo ""
+ fi
fi
-echo " Docker: Log out and back in for group membership to take effect"
-echo ""
-echo " NetBird:"
-echo " 1. Run 'netbird up' (opens browser for authentication)"
-echo " 2. View connected peers: netbird status"
-echo " 3. Configure ACLs in dashboard: https://app.netbird.io"
-echo ""
-echo " RustDesk: Launch from applications menu or run 'rustdesk'"
-echo ""
\ No newline at end of file
+if [ "$INSTALL_DOCKER" = "y" ] || [ "$INSTALL_DOCKER" = "Y" ]; then
+ echo " Docker: Log out and back in for group membership to take effect"
+ echo ""
+fi
+if [ "$INSTALL_NETBIRD" = "y" ] || [ "$INSTALL_NETBIRD" = "Y" ]; then
+ echo " NetBird:"
+ echo " 1. Run 'netbird up' (opens browser for authentication)"
+ echo " 2. View connected peers: netbird status"
+ echo " 3. Configure ACLs in dashboard: https://app.netbird.io"
+ echo ""
+fi
+if [ "$INSTALL_RUSTDESK" = "y" ] || [ "$INSTALL_RUSTDESK" = "Y" ]; then
+ echo " RustDesk: Launch from applications menu or run 'rustdesk'"
+ echo ""
+fi
+if [ "$INSTALL_FAIL2BAN" = "y" ] || [ "$INSTALL_FAIL2BAN" = "Y" ]; then
+ echo " fail2ban:"
+ echo " • Check status: sudo fail2ban-client status sshd"
+ echo " • View banned IPs: sudo fail2ban-client status sshd"
+ echo " • Unban IP: sudo fail2ban-client set sshd unbanip "
+ echo ""
+fi
+if [ "$CONFIGURE_UFW" = "y" ] || [ "$CONFIGURE_UFW" = "Y" ]; then
+ echo " UFW Firewall:"
+ echo " • Check status: sudo ufw status"
+ echo " • Allow port: sudo ufw allow "
+ echo " • Deny port: sudo ufw deny "
+ echo " • Disable: sudo ufw disable"
+ echo ""
+fi
\ No newline at end of file