From 8a276337abf67191517ed07cdda9a5363126fd11 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 20:43:24 +0000 Subject: [PATCH] Add ubuntu-post-install-26.04.sh for Ubuntu 26.04 LTS (Resolute Raccoon) Based on the 24.04 script with the following updates: - Updated all version references from 24.04 to 26.04 - Fixed Tailscale APT repo from hardcoded 'jammy' to 'resolute' codename (was a latent bug even on 24.04; jammy is 22.04) - All other repos (Docker, Kopia, NodeSource) already use dynamic VERSION_CODENAME detection and work as-is on 26.04 https://claude.ai/code/session_017jFG5YuHf2CCGS5HiheoeM --- ubuntu-post-install-26.04.sh | 7649 ++++++++++++++++++++++++++++++++++ 1 file changed, 7649 insertions(+) create mode 100644 ubuntu-post-install-26.04.sh diff --git a/ubuntu-post-install-26.04.sh b/ubuntu-post-install-26.04.sh new file mode 100644 index 0000000..a85ea45 --- /dev/null +++ b/ubuntu-post-install-26.04.sh @@ -0,0 +1,7649 @@ +#!/bin/bash + +# Ubuntu 26.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 26.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 26.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 + echo "Please run as root (use sudo)" + exit 1 +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 +} + +# 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" + echo " [M] Migration - Import existing Docker containers" + echo " [R] Disaster recovery - Restore from Kopia backup" + echo "" + read -p "Select mode (N/M/R) [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 "" + ;; + *) + 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..." +run_cmd apt update + +# Install basic utilities +echo "" +echo "Installing basic utilities..." +echo " - net-tools: Network configuration tools (ifconfig, netstat, etc.)" +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 " - 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 "" + +run_cmd apt install -y \ + net-tools \ + ncdu \ + git \ + curl \ + wget \ + htop \ + tree \ + zip \ + unzip \ + rclone || echo "Warning: Some utilities failed to install, continuing..." + +# Install OpenSSH Server +echo "" +echo "Installing OpenSSH Server..." +echo " - openssh-server: SSH server for remote access" +echo "" + +apt install -y openssh-server || echo "Warning: OpenSSH server installation failed, continuing..." + +# Start and enable SSH service +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 "" +prompt_yn "Generate a new SSH key for this computer? (y/n):" "n" GENERATE_KEY + +if [ "$GENERATE_KEY" = "y" ] || [ "$GENERATE_KEY" = "Y" ]; then + echo "" + read -p "Enter a label/comment for the key (e.g., email or hostname) [default: $ACTUAL_USER@$(hostname)]: " KEY_COMMENT + + if [ -z "$KEY_COMMENT" ]; then + KEY_COMMENT="$ACTUAL_USER@$(hostname)" + fi + + # Check if key already exists + if [ -f "$ACTUAL_HOME/.ssh/id_rsa" ]; then + echo "" + echo "⚠️ WARNING: SSH key already exists at $ACTUAL_HOME/.ssh/id_rsa" + read -p "Overwrite existing key? This cannot be undone! (y/n): " OVERWRITE_KEY + + if [ "$OVERWRITE_KEY" != "y" ] && [ "$OVERWRITE_KEY" != "Y" ]; then + echo "Skipping key generation." + GENERATE_KEY="n" + fi + fi + + if [ "$GENERATE_KEY" = "y" ] || [ "$GENERATE_KEY" = "Y" ]; then + echo "" + echo "Generating 4096-bit RSA key pair..." + echo "This may take a moment..." + + # Generate key as the actual user, not root + sudo -u "$ACTUAL_USER" ssh-keygen -t rsa -b 4096 -C "$KEY_COMMENT" -f "$ACTUAL_HOME/.ssh/id_rsa" -N "" + + if [ $? -eq 0 ]; then + echo "" + echo "✓ SSH key generated successfully!" + echo "" + echo "Private key: $ACTUAL_HOME/.ssh/id_rsa (keep this secret!)" + echo "Public key: $ACTUAL_HOME/.ssh/id_rsa.pub" + echo "" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "Your PUBLIC key (safe to share):" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + cat "$ACTUAL_HOME/.ssh/id_rsa.pub" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + echo "To use this key:" + echo " • Add to GitHub: Settings → SSH and GPG keys → New SSH key" + echo " • Add to servers: Copy above key to remote ~/.ssh/authorized_keys" + echo " • View anytime: cat ~/.ssh/id_rsa.pub" + echo "" + else + echo "✗ Failed to generate SSH key" + fi + fi +else + echo "Skipping SSH key generation." +fi + +# Import SSH keys from GitHub/Launchpad +echo "" +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" +touch "$ACTUAL_HOME/.ssh/authorized_keys" +chmod 700 "$ACTUAL_HOME/.ssh" +chmod 600 "$ACTUAL_HOME/.ssh/authorized_keys" + +if [ -n "$GITHUB_USER" ]; then + echo "Importing SSH keys from GitHub user: $GITHUB_USER" + if curl -fsSL "https://github.com/$GITHUB_USER.keys" >> "$ACTUAL_HOME/.ssh/authorized_keys" 2>/dev/null; then + echo "✓ GitHub keys imported successfully" + KEYS_IMPORTED=true + else + echo "✗ Failed to import GitHub keys" + fi +fi + +if [ -n "$LAUNCHPAD_USER" ]; then + echo "Importing SSH keys from Launchpad user: $LAUNCHPAD_USER" + if curl -fsSL "https://launchpad.net/~$LAUNCHPAD_USER/+sshkeys" >> "$ACTUAL_HOME/.ssh/authorized_keys" 2>/dev/null; then + echo "✓ Launchpad keys imported successfully" + KEYS_IMPORTED=true + else + echo "✗ Failed to import Launchpad keys" + fi +fi + +# Fix ownership +chown -R "$ACTUAL_USER:$ACTUAL_USER" "$ACTUAL_HOME/.ssh" + +# Disable password authentication if keys were imported +if [ "$KEYS_IMPORTED" = true ]; then + echo "" + echo "SSH keys imported. Disabling password authentication..." + + # Backup sshd_config + cp /etc/ssh/sshd_config /etc/ssh/sshd_config.backup + + # Disable password authentication + sed -i 's/^#*PasswordAuthentication yes/PasswordAuthentication no/' /etc/ssh/sshd_config + sed -i 's/^#*PasswordAuthentication no/PasswordAuthentication no/' /etc/ssh/sshd_config + + # Ensure these settings are also set + grep -q "^PasswordAuthentication" /etc/ssh/sshd_config || echo "PasswordAuthentication no" >> /etc/ssh/sshd_config + + # Restart SSH service to apply changes + systemctl restart ssh + + echo "✓ Password authentication disabled. SSH key authentication required." + echo "✓ Backup saved to /etc/ssh/sshd_config.backup" +else + echo "" + echo "No SSH keys imported. Password authentication remains enabled." + + # Offer fail2ban since password auth is still enabled + echo "" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "FAIL2BAN (Recommended with password SSH)" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + echo "Since password authentication is enabled, fail2ban is recommended" + echo "to protect against brute-force SSH attacks." + echo "" + + prompt_yn "Install and enable fail2ban? (y/n):" "y" INSTALL_FAIL2BAN + + if [ "$INSTALL_FAIL2BAN" = "y" ] || [ "$INSTALL_FAIL2BAN" = "Y" ]; then + echo "" + 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 + 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 + + # 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 "Skipping Docker installation." +fi + +# 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 "" + +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 + 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 "✗ Samba installation failed, skipping configuration" + fi + 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/resolute.noarmor.gpg | tee /usr/share/keyrings/tailscale-archive-keyring.gpg >/dev/null + curl -fsSL https://pkgs.tailscale.com/stable/ubuntu/resolute.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 +} + +# 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/rsync-backup.sh + + # Create systemd service + cat > /etc/systemd/system/rsync-backup.service << SERVICE +[Unit] +Description=rsync Local Backup Service +After=network.target + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/backup-scripts/rsync-backup.sh +User=root +SERVICE + + # Create systemd timer for daily backups at 2 AM + cat > /etc/systemd/system/rsync-backup.timer << TIMER +[Unit] +Description=Daily rsync Backup Timer +Requires=rsync-backup.service + +[Timer] +OnCalendar=*-*-* 02:00:00 +Persistent=true + +[Install] +WantedBy=timers.target +TIMER + + 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 + +# Cloud Backup with rclone (Optional) +if [ "$WHIPTAIL_USED" != true ]; then +echo "" +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..." +run_cmd apt autoremove -y +run_cmd apt autoclean + +echo "" +echo "=== Installation Complete! ===" +echo "" +echo "Installed Software:" +echo " ✓ net-tools, ncdu, git, curl, wget, htop, tree, zip/unzip" +echo " ✓ OpenSSH Server - SSH remote access" +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" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "" +echo " SSH Key for This Computer:" +if [ "$GENERATE_KEY" = "y" ] || [ "$GENERATE_KEY" = "Y" ]; then + if [ -f "$ACTUAL_HOME/.ssh/id_rsa.pub" ]; then +echo " ✓ 4096-bit RSA key generated" +echo " Public key: $ACTUAL_HOME/.ssh/id_rsa.pub" +echo " View with: cat ~/.ssh/id_rsa.pub" + else +echo " ✗ Key generation was attempted but may have failed" + fi +else +echo " Not generated (skipped during install)" +echo " Generate later: ssh-keygen -t rsa -b 4096 -C \"your@email.com\"" +fi +echo "" +echo " SSH Server Status:" +if [ "$KEYS_IMPORTED" = true ]; then +echo " Password authentication: DISABLED (key-only access)" +echo " Imported SSH keys: ~/.ssh/authorized_keys" +else +echo " Password authentication: ENABLED" +fi +echo "" +echo " Traditional SSH Access:" +echo " - Uses keys from GitHub/Launchpad (if imported)" +echo " - Connect with: ssh user@hostname" +if [ "$KEYS_IMPORTED" = true ]; then +echo " - Password login: DISABLED (keys required)" +else +echo " - Password login: ENABLED" +fi +echo "" +echo " NetBird SSH Access (independent of traditional SSH):" +echo " - NetBird manages its own keys automatically" +echo " - Works even with password auth disabled" +echo " - Connect with: netbird ssh " +echo " - Enable in NetBird dashboard first" +echo "" +echo " You can use ANY combination:" +echo " ✓ GitHub + Launchpad + NetBird SSH" +echo " ✓ GitHub + NetBird SSH" +echo " ✓ Launchpad + NetBird SSH" +echo " ✓ GitHub + Launchpad (no NetBird)" +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 "BACKUP SYSTEM - $BACKUP_TOOL ($BACKUP_MODE mode)" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "" +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 "" +echo " Backup script: /usr/local/bin/backup-scripts/${BACKUP_TOOL}-backup.sh" +echo " Log file: /var/log/${BACKUP_TOOL}-backup.log" +echo "" +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 " 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 "" +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 +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