Context management for local models with limited windows: - Context Tracker (community function): shows tokens used vs available, progress bar, percentage remaining — so you see the cliff coming - Checkpoint Summarization Filter: auto-summarizes old messages when context fills up, like Claude's auto-compaction - Both added as recommended post-install links in setup output (Open WebUI Functions install with one click from the UI) 6GB GPU tier (Quadro P3300, GTX 1060, etc.): - Qwen 3.5 4B at Q4_K_M = ~2.5GB weights, leaves 3.5GB for KV cache - With Q8 KV cache: ~32K usable context on 6GB - Better than squeezing 9B into nothing — more context > slightly smarter https://claude.ai/code/session_01PtYTPherSJaxDEVPgF6Nxu
8909 lines
363 KiB
Bash
8909 lines
363 KiB
Bash
#!/bin/bash
|
||
|
||
# Ubuntu 24.04 Post-Installation Script
|
||
# Run with: sudo bash post-install.sh
|
||
# This script is rerunnable - it detects existing installations
|
||
|
||
# ============================================================================
|
||
# COMMAND-LINE ARGUMENT PARSING
|
||
# ============================================================================
|
||
|
||
DRY_RUN=false
|
||
UNATTENDED=false
|
||
LOG_FILE="/var/log/post-install.log"
|
||
WHIPTAIL_USED=false
|
||
|
||
show_help() {
|
||
echo "Ubuntu 24.04 Post-Installation Script"
|
||
echo ""
|
||
echo "Usage: sudo ./post-install.sh [OPTIONS]"
|
||
echo ""
|
||
echo "Options:"
|
||
echo " --dry-run Preview what would be installed without making changes"
|
||
echo " --unattended Run with default options (no prompts)"
|
||
echo " Defaults: skip key generation, no SSH imports,"
|
||
echo " install Docker, skip VPNs/Remote Desktop/Backup"
|
||
echo " --restore Disaster recovery mode - restore from backup"
|
||
echo " --help Show this help message"
|
||
echo ""
|
||
echo "Examples:"
|
||
echo " sudo ./post-install.sh # Interactive mode"
|
||
echo " sudo ./post-install.sh --dry-run # Preview installations"
|
||
echo " sudo ./post-install.sh --unattended # Automated install"
|
||
echo " sudo ./post-install.sh --restore # Disaster recovery"
|
||
echo ""
|
||
exit 0
|
||
}
|
||
|
||
RESTORE_MODE=false
|
||
|
||
for arg in "$@"; do
|
||
case $arg in
|
||
--dry-run)
|
||
DRY_RUN=true
|
||
;;
|
||
--unattended)
|
||
UNATTENDED=true
|
||
;;
|
||
--restore)
|
||
RESTORE_MODE=true
|
||
;;
|
||
--help|-h)
|
||
show_help
|
||
;;
|
||
*)
|
||
echo "Unknown option: $arg"
|
||
echo "Use --help for usage information"
|
||
exit 1
|
||
;;
|
||
esac
|
||
done
|
||
|
||
# ============================================================================
|
||
# LOGGING SETUP
|
||
# ============================================================================
|
||
|
||
# Create log file and tee all output
|
||
exec > >(tee -a "$LOG_FILE") 2>&1
|
||
echo ""
|
||
echo "=== Post-Install Log Started: $(date) ===" >> "$LOG_FILE"
|
||
|
||
echo "=== Ubuntu 24.04 Post-Installation Script ==="
|
||
echo ""
|
||
|
||
if [ "$DRY_RUN" = true ]; then
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
echo "DRY RUN MODE - No changes will be made"
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
echo ""
|
||
fi
|
||
|
||
if [ "$UNATTENDED" = true ]; then
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
echo "UNATTENDED MODE - Using default options"
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
echo ""
|
||
fi
|
||
|
||
# Check if running as root
|
||
if [ "$EUID" -ne 0 ]; then
|
||
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
|
||
}
|
||
|
||
# ============================================================================
|
||
# LOCAL AI STACK INSTALLER
|
||
# ============================================================================
|
||
|
||
install_ai_stack() {
|
||
echo ""
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
echo "LOCAL AI STACK INSTALLATION"
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
echo ""
|
||
echo "Installs the full local AI stack:"
|
||
echo " • Ollama (LLM engine) • Open WebUI (chat interface)"
|
||
echo " • RAG server (document search) • MCP server (Claude integration)"
|
||
echo " • Kiwix (offline wiki) • Gitea (self-hosted git)"
|
||
echo " • SearXNG (private search) • InvokeAI (image generation)"
|
||
echo " • Portainer (Docker management)"
|
||
echo ""
|
||
|
||
# Ensure Docker is available
|
||
if ! command -v docker &>/dev/null; then
|
||
echo "Docker is required but not installed."
|
||
echo "Run option [N] first to install system services (includes Docker)."
|
||
echo ""
|
||
read -p "Press Enter to continue..." _dummy
|
||
return 1
|
||
fi
|
||
|
||
# --- Storage selection ------------------------------------------------
|
||
echo ""
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
echo "STORAGE FOR AI MODELS & WIKI FILES"
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
echo ""
|
||
echo "Ollama models (7B=4GB, 14B=9GB) and Kiwix ZIM files (Wikipedia"
|
||
echo "~46GB, Gutenberg ~60GB) can live on a dedicated data drive."
|
||
echo ""
|
||
echo "Currently mounted drives:"
|
||
echo ""
|
||
df -h --output=target,size,avail 2>/dev/null \
|
||
| tail -n +2 \
|
||
| grep -v -E '^(/sys|/proc|/dev|/run|/snap|/boot|efi|tmpfs|udev)' \
|
||
| grep -v ' /$' \
|
||
| awk '{printf " %-38s %-8s %s free\n", $1, $2, $3}' \
|
||
| head -12
|
||
echo ""
|
||
|
||
AI_EXTRA_DRIVE=""
|
||
AI_MODEL_FOLDER="ollama-models"
|
||
AI_ZIM_FOLDER="zims"
|
||
|
||
read -p "Store models/ZIMs on a specific drive? (y/n) [n]: " _USE_DRIVE
|
||
if [ "$_USE_DRIVE" = "y" ] || [ "$_USE_DRIVE" = "Y" ]; then
|
||
read -p " Drive path (e.g., $ACTUAL_HOME/drives/primary): " AI_EXTRA_DRIVE
|
||
AI_EXTRA_DRIVE="${AI_EXTRA_DRIVE/#\~/$ACTUAL_HOME}"
|
||
|
||
if [ -n "$AI_EXTRA_DRIVE" ] && [ -d "$AI_EXTRA_DRIVE" ]; then
|
||
read -p " Folder name for Ollama models [ollama-models]: " _MODEL_F
|
||
read -p " Folder name for Kiwix ZIM files [zims]: " _ZIM_F
|
||
AI_MODEL_FOLDER="${_MODEL_F:-ollama-models}"
|
||
AI_ZIM_FOLDER="${_ZIM_F:-zims}"
|
||
|
||
mkdir -p "$AI_EXTRA_DRIVE/$AI_MODEL_FOLDER" "$AI_EXTRA_DRIVE/$AI_ZIM_FOLDER"
|
||
chown -R "$ACTUAL_USER:$ACTUAL_USER" \
|
||
"$AI_EXTRA_DRIVE/$AI_MODEL_FOLDER" \
|
||
"$AI_EXTRA_DRIVE/$AI_ZIM_FOLDER" 2>/dev/null || true
|
||
|
||
echo ""
|
||
echo " ✓ Models → $AI_EXTRA_DRIVE/$AI_MODEL_FOLDER"
|
||
echo " ✓ ZIMs → $AI_EXTRA_DRIVE/$AI_ZIM_FOLDER"
|
||
else
|
||
echo " ⚠ Path not found — using defaults under ~/docker/ai-stack/"
|
||
AI_EXTRA_DRIVE=""
|
||
fi
|
||
fi
|
||
|
||
# --- Write embedded local-ai-setup.sh to temp file and run it --------
|
||
echo ""
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
echo "Running AI stack installer as $ACTUAL_USER ..."
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
echo ""
|
||
AI_SETUP_SCRIPT=$(mktemp /tmp/local-ai-setup.XXXXXX.sh)
|
||
cat > "$AI_SETUP_SCRIPT" << 'LOCAL_AI_SETUP_EOF'
|
||
#!/usr/bin/env bash
|
||
# Local AI Stack — single script, new install or update
|
||
# Usage: ./local-ai-setup.sh [--force] [--no-pull]
|
||
set -euo pipefail
|
||
|
||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
|
||
CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m'
|
||
info() { echo -e "${CYAN}[..]${NC} $*"; }
|
||
ok() { echo -e "${GREEN}[OK]${NC} $*"; }
|
||
warn() { echo -e "${YELLOW}[!!]${NC} $*"; }
|
||
section() { echo -e "\n${BOLD}━━━ $* ━━━${NC}"; }
|
||
|
||
FORCE=false; NO_PULL=false
|
||
for a in "$@"; do [[ "$a" == "--force" ]] && FORCE=true; [[ "$a" == "--no-pull" ]] && NO_PULL=true; done
|
||
|
||
BASE="$HOME/docker/ai-stack"
|
||
LOCAL_IP=$(ip route get 1.1.1.1 2>/dev/null | grep -oP 'src \K\S+' || hostname -I | awk '{print $1}')
|
||
[[ -z "$LOCAL_IP" ]] && read -rp "Enter LAN IP: " LOCAL_IP
|
||
|
||
IS_UPDATE=false; [[ -f "$BASE/docker-compose.yml" ]] && IS_UPDATE=true
|
||
|
||
# ── detect VRAM and set models accordingly ────────────────────────────────────
|
||
VRAM_GB=$(nvidia-smi --query-gpu=memory.total --format=csv,noheader,nounits 2>/dev/null \
|
||
| head -1 | awk '{printf "%d", $1/1024}' 2>/dev/null || echo "0")
|
||
GPU_COUNT=$(nvidia-smi --query-gpu=name --format=csv,noheader 2>/dev/null | wc -l || echo "0")
|
||
TOTAL_VRAM=$((VRAM_GB * GPU_COUNT))
|
||
|
||
# Ollama optimization flags (stacked — see docs/gpu-setup-research.md)
|
||
OLLAMA_KV_CACHE="q8_0" # halves KV cache VRAM (q4_0 for aggressive)
|
||
OLLAMA_FLASH="1" # flash attention: less VRAM, no quality loss
|
||
|
||
if [[ "$TOTAL_VRAM" -ge 40 ]]; then
|
||
CHAT_MODEL="qwen3.5:27b"; CODE_MODEL="qwen3.5:27b"
|
||
CTX=131072; TIER="${TOTAL_VRAM}GB — 27B dense, 128K context"
|
||
elif [[ "$TOTAL_VRAM" -ge 28 ]]; then
|
||
CHAT_MODEL="qwen3.5-35b-a3b"; CODE_MODEL="qwen3.5-35b-a3b"
|
||
CTX=131072; TIER="${TOTAL_VRAM}GB — 35B MoE, 128K context"
|
||
elif [[ "$TOTAL_VRAM" -ge 14 ]]; then
|
||
CHAT_MODEL="qwen3.5-35b-a3b"; CODE_MODEL="qwen3.5-35b-a3b"
|
||
CTX=65536; TIER="${TOTAL_VRAM}GB — 35B MoE + KV quant, 64K context"
|
||
elif [[ "$TOTAL_VRAM" -ge 8 ]]; then
|
||
CHAT_MODEL="qwen3.5:9b"; CODE_MODEL="qwen3.5:9b"
|
||
CTX=32768; TIER="${TOTAL_VRAM}GB — 9B dense, 32K context"
|
||
elif [[ "$TOTAL_VRAM" -ge 6 ]]; then
|
||
CHAT_MODEL="qwen3.5:4b"; CODE_MODEL="qwen3.5:4b"
|
||
CTX=32768; TIER="${TOTAL_VRAM}GB — 4B + KV quant, 32K context"
|
||
elif [[ "$TOTAL_VRAM" -ge 4 ]]; then
|
||
CHAT_MODEL="qwen3.5:4b"; CODE_MODEL="qwen3.5:4b"
|
||
CTX=16384; TIER="${TOTAL_VRAM}GB — 4B models, 16K context"
|
||
else
|
||
CHAT_MODEL="qwen3.5:4b"; CODE_MODEL="qwen3.5:4b"
|
||
CTX=4096; OLLAMA_KV_CACHE="q4_0"; TIER="CPU-only — 4B models, 4K context"
|
||
fi
|
||
EMBED_MODEL="nomic-embed-text"
|
||
|
||
section "Local AI Stack — $($IS_UPDATE && echo UPDATE || echo NEW INSTALL)"
|
||
info "Base : $BASE"
|
||
info "IP : $LOCAL_IP"
|
||
info "GPU : ${VRAM_GB}GB VRAM → $TIER"
|
||
|
||
write_if_new() {
|
||
local dest="$1"; local body; body=$(cat)
|
||
if [[ ! -f "$dest" ]] || $FORCE; then
|
||
printf '%s\n' "$body" > "$dest"; ok "Wrote $(basename "$dest")"
|
||
else
|
||
info "Kept $(basename "$dest") (--force to overwrite)"
|
||
fi
|
||
}
|
||
|
||
# ── prereqs (new install only) ────────────────────────────────────────────────
|
||
if ! $IS_UPDATE; then
|
||
section "Prerequisites"
|
||
if ! command -v docker &>/dev/null; then
|
||
info "Installing Docker..."
|
||
curl -fsSL https://get.docker.com | sh
|
||
sudo usermod -aG docker "$USER"
|
||
warn "Run: newgrp docker (or log out/in)"
|
||
else
|
||
ok "Docker: $(docker --version | cut -d' ' -f3)"
|
||
fi
|
||
|
||
if command -v nvidia-smi &>/dev/null && ! dpkg -l 2>/dev/null | grep -q nvidia-container-toolkit; then
|
||
info "Installing NVIDIA Container Toolkit..."
|
||
dist=$(. /etc/os-release && echo "$ID$VERSION_ID")
|
||
curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey \
|
||
| sudo gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg
|
||
curl -fsSL "https://nvidia.github.io/libnvidia-container/$dist/libnvidia-container.list" \
|
||
| sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' \
|
||
| sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list
|
||
sudo apt-get update -qq && sudo apt-get install -y nvidia-container-toolkit
|
||
sudo nvidia-ctk runtime configure --runtime=docker && sudo systemctl restart docker
|
||
ok "NVIDIA Container Toolkit installed"
|
||
fi
|
||
command -v rg &>/dev/null || sudo apt-get install -y ripgrep
|
||
fi
|
||
|
||
# ── directories ───────────────────────────────────────────────────────────────
|
||
section "Directories"
|
||
for d in papers repos workspace index searxng invokeai-data invokeai-outputs kiwix gitea portainer-data logs; do
|
||
mkdir -p "$BASE/$d"
|
||
done
|
||
ok "Ready under $BASE"
|
||
|
||
# =============================================================================
|
||
section "Writing server.py (RAG)"
|
||
# =============================================================================
|
||
write_if_new "$BASE/server.py" << 'PY'
|
||
import ast, fnmatch, hashlib, json, logging, os, re, subprocess, threading, time
|
||
from pathlib import Path
|
||
from typing import Any
|
||
import chromadb, httpx
|
||
from chromadb.utils.embedding_functions import OllamaEmbeddingFunction
|
||
from fastapi import FastAPI, HTTPException, Request, BackgroundTasks
|
||
from fastapi.middleware.cors import CORSMiddleware
|
||
from fastapi.responses import StreamingResponse
|
||
from pydantic import BaseModel
|
||
|
||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||
log = logging.getLogger("rag")
|
||
|
||
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://ollama:11434")
|
||
CHROMA_URL = os.getenv("CHROMA_URL", "http://chromadb:8000")
|
||
EMBED_MODEL = os.getenv("EMBED_MODEL", "nomic-embed-text")
|
||
CHAT_MODEL = os.getenv("CHAT_MODEL", "qwen2.5:14b")
|
||
PAPERS_DIR = Path(os.getenv("PAPERS_DIR", "/papers"))
|
||
REPOS_DIR = Path(os.getenv("REPOS_DIR", "/repos"))
|
||
TOP_K = int(os.getenv("TOP_K", "6"))
|
||
|
||
CODE_EXTS = {".py",".js",".ts",".tsx",".jsx",".go",".rs",".java",".c",".cpp",
|
||
".h",".cs",".rb",".sh",".yaml",".yml",".toml",".sql",".md"}
|
||
SKIP_DIRS = {"node_modules",".git","__pycache__","dist","build",".venv","venv","target"}
|
||
MAX_BYTES = 400_000
|
||
|
||
app = FastAPI(title="RAG Server")
|
||
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
|
||
|
||
def _embed_fn():
|
||
return OllamaEmbeddingFunction(url=f"{OLLAMA_URL}/api/embeddings", model_name=EMBED_MODEL)
|
||
|
||
def _chroma():
|
||
host, port = CHROMA_URL.replace("http://","").split(":")
|
||
return chromadb.HttpClient(host=host, port=int(port))
|
||
|
||
def get_col(name):
|
||
return _chroma().get_or_create_collection(name, embedding_function=_embed_fn())
|
||
|
||
def _doc_id(text, key):
|
||
return hashlib.md5(f"{key}|{text[:200]}".encode()).hexdigest()
|
||
|
||
def _sliding(text, size=1000, overlap=150):
|
||
chunks, i = [], 0
|
||
while i < len(text):
|
||
chunks.append(text[i:i+size]); i += size - overlap
|
||
return [c for c in chunks if c.strip()]
|
||
|
||
def _chunk_python(src):
|
||
try: tree = ast.parse(src)
|
||
except SyntaxError: return []
|
||
lines = src.splitlines(); out = []
|
||
for node in ast.iter_child_nodes(tree):
|
||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
||
out.append((node.name, "\n".join(lines[node.lineno-1:node.end_lineno])[:4000]))
|
||
return out
|
||
|
||
def _chunk_file(path, src):
|
||
if path.suffix == ".py":
|
||
pairs = _chunk_python(src)
|
||
if pairs: return pairs
|
||
pat = re.compile(r'(?:^|\n)(?=(?:export\s+)?(?:async\s+)?(?:function|class)|^func |^type |^impl |^pub fn |^fn )', re.M)
|
||
parts = [p.strip() for p in pat.split(src) if p.strip()]
|
||
if len(parts) > 1: return [(f"s{i}", p[:4000]) for i,p in enumerate(parts)]
|
||
return [(f"c{i}", c) for i,c in enumerate(_sliding(src, 1200, 200))]
|
||
|
||
def ingest_file(col, fpath, repo=""):
|
||
if fpath.stat().st_size > MAX_BYTES: return
|
||
if any(fnmatch.fnmatch(fpath.name, p) for p in ("*.min.js","*.map","package-lock.json","yarn.lock")): return
|
||
try: src = fpath.read_text(encoding="utf-8", errors="ignore")
|
||
except: return
|
||
if not src.strip(): return
|
||
rel = str(fpath)
|
||
pairs = _chunk_file(fpath, src) if fpath.suffix in CODE_EXTS else \
|
||
[(f"c{i}",c) for i,c in enumerate(_sliding(src))]
|
||
ids,docs,metas = [],[],[]
|
||
for label,chunk in pairs:
|
||
if not chunk.strip(): continue
|
||
ids.append(_doc_id(chunk, rel+label)); docs.append(chunk)
|
||
metas.append({"source":rel,"label":label,"repo":repo,"lang":fpath.suffix.lstrip(".")})
|
||
if ids: col.upsert(ids=ids, documents=docs, metadatas=metas)
|
||
|
||
def ingest_dir(col, directory, repo=""):
|
||
count = 0
|
||
for f in directory.rglob("*"):
|
||
if not f.is_file() or any(p in f.parts for p in SKIP_DIRS): continue
|
||
ingest_file(col, f, repo); count += 1
|
||
log.info("Indexed %d files from %s", count, directory); return count
|
||
|
||
def ingest_pdfs(col):
|
||
try: import pypdf
|
||
except ImportError: return 0
|
||
n = 0
|
||
for pdf in PAPERS_DIR.glob("*.pdf"):
|
||
try:
|
||
text = "\n".join(p.extract_text() or "" for p in pypdf.PdfReader(str(pdf)).pages)
|
||
for i,chunk in enumerate(_sliding(text)):
|
||
col.upsert(ids=[_doc_id(chunk,str(pdf)+str(i))], documents=[chunk],
|
||
metadatas=[{"source":str(pdf),"label":f"p{i}","repo":"","lang":"pdf"}])
|
||
n += 1
|
||
except Exception as e: log.warning("PDF %s: %s", pdf.name, e)
|
||
return n
|
||
|
||
def _startup():
|
||
for _ in range(40):
|
||
try:
|
||
r = httpx.get(f"{OLLAMA_URL}/api/tags", timeout=5)
|
||
if any(EMBED_MODEL in m["name"] for m in r.json().get("models",[])): break
|
||
except: pass
|
||
log.info("Waiting for embed model..."); time.sleep(5)
|
||
code_col = get_col("code"); papers_col = get_col("papers")
|
||
for d in REPOS_DIR.iterdir():
|
||
if d.is_dir(): ingest_dir(code_col, d, d.name)
|
||
ingest_pdfs(papers_col)
|
||
for f in PAPERS_DIR.glob("*.txt"): ingest_file(papers_col, f)
|
||
log.info("Startup index done")
|
||
|
||
@app.on_event("startup")
|
||
async def on_startup(): threading.Thread(target=_startup, daemon=True).start()
|
||
|
||
@app.get("/health")
|
||
async def health():
|
||
try:
|
||
cc = _chroma()
|
||
return {"status":"ok",
|
||
"code": cc.get_collection("code", embedding_function=_embed_fn()).count(),
|
||
"papers": cc.get_collection("papers", embedding_function=_embed_fn()).count()}
|
||
except Exception as e: return {"status":"error","detail":str(e)}
|
||
|
||
class RepoReq(BaseModel):
|
||
url: str; name: str = ""; branch: str = "main"
|
||
|
||
@app.post("/ingest/repo")
|
||
async def ingest_repo(req: RepoReq):
|
||
name = req.name or req.url.rstrip("/").split("/")[-1].removesuffix(".git")
|
||
dest = REPOS_DIR / name
|
||
try:
|
||
if dest.exists(): subprocess.run(["git","pull"], cwd=dest, check=True, timeout=120)
|
||
else: subprocess.run(["git","clone","--depth=1","-b",req.branch,req.url,str(dest)], check=True, timeout=300)
|
||
except subprocess.CalledProcessError as e: raise HTTPException(400, str(e))
|
||
return {"status":"ok","repo":name,"files":ingest_dir(get_col("code"),dest,name)}
|
||
|
||
@app.post("/ingest/papers")
|
||
async def trigger_papers(bg: BackgroundTasks):
|
||
bg.add_task(ingest_pdfs, get_col("papers")); return {"status":"queued"}
|
||
|
||
async def _webhook(payload):
|
||
repo = payload.get("repository") or {}
|
||
url = repo.get("clone_url") or repo.get("html_url",""); name = repo.get("name","unknown")
|
||
if not url: return {"status":"ignored"}
|
||
dest = REPOS_DIR / name
|
||
if dest.exists(): subprocess.run(["git","pull"], cwd=dest, timeout=120)
|
||
else: subprocess.run(["git","clone","--depth=1",url,str(dest)], timeout=300)
|
||
return {"status":"ok","repo":name,"files":ingest_dir(get_col("code"),dest,name)}
|
||
|
||
@app.post("/webhook/gitea")
|
||
async def wh_gitea(r: Request): return await _webhook(await r.json())
|
||
@app.post("/webhook/github")
|
||
async def wh_github(r: Request): return await _webhook(await r.json())
|
||
|
||
def _ctx(query, cols):
|
||
parts = []
|
||
for cn in cols:
|
||
try:
|
||
col = get_col(cn)
|
||
if col.count() == 0: continue
|
||
res = col.query(query_texts=[query], n_results=min(TOP_K, col.count()))
|
||
for doc,meta in zip(res["documents"][0], res["metadatas"][0]):
|
||
parts.append(f"### {meta.get('source','')}:{meta.get('label','')}\n```{meta.get('lang','')}\n{doc}\n```")
|
||
except Exception as e: log.warning("col %s: %s", cn, e)
|
||
return "\n\n".join(parts)
|
||
|
||
class ChatReq(BaseModel):
|
||
model: str = CHAT_MODEL; messages: list[dict[str,Any]]
|
||
stream: bool = False; collections: list[str] = ["code","papers"]
|
||
|
||
@app.post("/v1/chat/completions")
|
||
async def chat(req: ChatReq):
|
||
query = next((m["content"] for m in reversed(req.messages) if m.get("role")=="user"), "")
|
||
ctx = _ctx(query, req.collections)
|
||
msgs = [{"role":"system","content":f"You are a coding assistant. Use context below.\n\n## Context\n{ctx}"}] + req.messages
|
||
payload = {"model":req.model,"messages":msgs,"stream":req.stream}
|
||
if req.stream:
|
||
async def gen():
|
||
async with httpx.AsyncClient(timeout=300) as c:
|
||
async with c.stream("POST",f"{OLLAMA_URL}/v1/chat/completions",json=payload) as r:
|
||
async for chunk in r.aiter_bytes(): yield chunk
|
||
return StreamingResponse(gen(), media_type="text/event-stream")
|
||
async with httpx.AsyncClient(timeout=300) as c:
|
||
r = await c.post(f"{OLLAMA_URL}/v1/chat/completions", json=payload)
|
||
return r.json()
|
||
|
||
if __name__ == "__main__":
|
||
import uvicorn; uvicorn.run("server:app", host="0.0.0.0", port=8001, reload=False)
|
||
PY
|
||
|
||
# =============================================================================
|
||
section "Writing mcp_server.py"
|
||
# =============================================================================
|
||
write_if_new "$BASE/mcp_server.py" << 'PY'
|
||
import json, os, re, subprocess
|
||
from pathlib import Path
|
||
import httpx
|
||
from mcp.server.fastmcp import FastMCP
|
||
|
||
WORKSPACE = Path(os.getenv("WORKSPACE_DIR", "/workspace"))
|
||
REPOS_DIR = Path(os.getenv("REPOS_DIR", "/repos"))
|
||
GITEA_URL = os.getenv("GITEA_URL", "http://gitea:3000")
|
||
GITEA_TOKEN = os.getenv("GITEA_TOKEN", "")
|
||
GITHUB_TOKEN = os.getenv("GITHUB_TOKEN","")
|
||
RAG_URL = os.getenv("RAG_URL", "http://rag-server:8001")
|
||
|
||
mcp = FastMCP("local-dev-tools")
|
||
|
||
@mcp.tool()
|
||
def bash(command: str, cwd: str = "") -> str:
|
||
"""Run a shell command. Default cwd is /workspace."""
|
||
work = Path(cwd) if cwd else WORKSPACE
|
||
work.mkdir(parents=True, exist_ok=True)
|
||
try:
|
||
r = subprocess.run(command, shell=True, cwd=work, timeout=120, capture_output=True, text=True)
|
||
out = r.stdout + (f"\n[stderr]\n{r.stderr}" if r.stderr else "")
|
||
if r.returncode != 0: out += f"\n[exit {r.returncode}]"
|
||
return out or "(no output)"
|
||
except subprocess.TimeoutExpired: return "[timeout]"
|
||
except Exception as e: return f"[error] {e}"
|
||
|
||
@mcp.tool()
|
||
def read_file(path: str) -> str:
|
||
"""Read a file. Absolute or relative to /workspace."""
|
||
p = Path(path) if Path(path).is_absolute() else WORKSPACE / path
|
||
if not p.exists(): return f"[not found] {p}"
|
||
if p.stat().st_size > 500_000: return f"[too large: {p.stat().st_size//1024}KB]"
|
||
return p.read_text(encoding="utf-8", errors="replace")
|
||
|
||
@mcp.tool()
|
||
def write_file(path: str, content: str) -> str:
|
||
"""Write content to a file. Relative to /workspace."""
|
||
p = Path(path) if Path(path).is_absolute() else WORKSPACE / path
|
||
p.parent.mkdir(parents=True, exist_ok=True)
|
||
p.write_text(content, encoding="utf-8")
|
||
return f"Wrote {len(content)} chars to {p}"
|
||
|
||
@mcp.tool()
|
||
def list_files(path: str = "", pattern: str = "**/*") -> str:
|
||
"""List files matching a glob pattern."""
|
||
base = Path(path) if path else WORKSPACE
|
||
if not base.exists(): return f"[not found] {base}"
|
||
files = sorted(str(f.relative_to(base)) for f in base.glob(pattern) if f.is_file())
|
||
return "\n".join(files[:500]) or "(empty)"
|
||
|
||
@mcp.tool()
|
||
def search_code(query: str, path: str = "", glob: str = "", case_sensitive: bool = False) -> str:
|
||
"""Search file contents with ripgrep."""
|
||
base = path or str(WORKSPACE)
|
||
cmd = ["rg", "--line-number", "--no-heading"]
|
||
if not case_sensitive: cmd.append("-i")
|
||
if glob: cmd += ["-g", glob]
|
||
cmd += [query, base]
|
||
try:
|
||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||
lines = r.stdout.strip().splitlines()
|
||
if len(lines) > 200: lines = lines[:200] + [f"...({len(r.stdout.splitlines())-200} more)"]
|
||
return "\n".join(lines) or "(no matches)"
|
||
except FileNotFoundError:
|
||
r = subprocess.run(["grep","-rn",query,base], capture_output=True, text=True, timeout=30)
|
||
return r.stdout[:8000] or "(no matches)"
|
||
|
||
@mcp.tool()
|
||
def fetch_url(url: str, extract_text: bool = True) -> str:
|
||
"""Fetch the content of a URL."""
|
||
try:
|
||
r = httpx.get(url, timeout=30, follow_redirects=True, headers={"User-Agent":"Mozilla/5.0"})
|
||
content = r.text
|
||
if extract_text:
|
||
content = re.sub(r'<script[^>]*>.*?</script>', '', content, flags=re.DOTALL)
|
||
content = re.sub(r'<style[^>]*>.*?</style>', '', content, flags=re.DOTALL)
|
||
content = re.sub(r'<[^>]+>', '', content)
|
||
content = re.sub(r'\n{3,}', '\n\n', content).strip()
|
||
return content[:20000]
|
||
except Exception as e: return f"[error] {e}"
|
||
|
||
def _git(args, repo=""):
|
||
cwd = Path(repo) if repo else WORKSPACE
|
||
r = subprocess.run(["git"]+args, cwd=cwd, capture_output=True, text=True, timeout=60)
|
||
return (r.stdout + r.stderr).strip() or "(no output)"
|
||
|
||
@mcp.tool()
|
||
def git_status(repo: str = "") -> str:
|
||
"""Show git status."""
|
||
return _git(["status","--short"], repo)
|
||
|
||
@mcp.tool()
|
||
def git_diff(repo: str = "", cached: bool = False) -> str:
|
||
"""Show git diff."""
|
||
flag = ["--cached"] if cached else []
|
||
return _git(["diff","--stat"]+flag, repo) + "\n\n" + _git(["diff"]+flag, repo)
|
||
|
||
@mcp.tool()
|
||
def git_log(repo: str = "", n: int = 10) -> str:
|
||
"""Show last n commits."""
|
||
return _git(["log",f"-{n}","--oneline","--decorate"], repo)
|
||
|
||
@mcp.tool()
|
||
def git_commit(message: str, repo: str = "", add_all: bool = True) -> str:
|
||
"""Stage all and commit."""
|
||
if add_all: _git(["add","-A"], repo)
|
||
return _git(["commit","-m",message], repo)
|
||
|
||
@mcp.tool()
|
||
def git_checkout(branch: str, repo: str = "", create: bool = False) -> str:
|
||
"""Checkout or create a branch."""
|
||
return _git(["checkout","-b",branch] if create else ["checkout",branch], repo)
|
||
|
||
def _gitea(method, path, body=None):
|
||
if not GITEA_TOKEN: return {"error":"GITEA_TOKEN not set in .env"}
|
||
r = httpx.request(method, f"{GITEA_URL}/api/v1{path}",
|
||
json=body, headers={"Authorization":f"token {GITEA_TOKEN}"}, timeout=30)
|
||
try: return r.json()
|
||
except: return {"status":r.status_code,"text":r.text}
|
||
|
||
@mcp.tool()
|
||
def gitea_list_repos() -> str:
|
||
"""List your Gitea repos."""
|
||
d = _gitea("GET", "/repos/search?limit=50")
|
||
if "error" in d: return d["error"]
|
||
return "\n".join(f"{r['full_name']} — {r.get('description','')}" for r in d.get("data",[]))
|
||
|
||
@mcp.tool()
|
||
def gitea_create_repo(name: str, private: bool = True, description: str = "") -> str:
|
||
"""Create a Gitea repo."""
|
||
r = _gitea("POST","/user/repos",{"name":name,"private":private,"description":description,"auto_init":True,"default_branch":"main"})
|
||
return r.get("html_url") or str(r)
|
||
|
||
@mcp.tool()
|
||
def gitea_create_issue(repo: str, title: str, body: str = "") -> str:
|
||
"""Create a Gitea issue (owner/repo)."""
|
||
r = _gitea("POST",f"/repos/{repo}/issues",{"title":title,"body":body})
|
||
return r.get("html_url") or str(r)
|
||
|
||
@mcp.tool()
|
||
def github_api(method: str, endpoint: str, body: str = "") -> str:
|
||
"""Call GitHub REST API. endpoint e.g. /repos/owner/repo/issues"""
|
||
if not GITHUB_TOKEN: return "GITHUB_TOKEN not set in .env"
|
||
r = httpx.request(method.upper(), f"https://api.github.com{endpoint}",
|
||
json=json.loads(body) if body else None,
|
||
headers={"Authorization":f"Bearer {GITHUB_TOKEN}","Accept":"application/vnd.github+json"},
|
||
timeout=30)
|
||
try: return json.dumps(r.json(), indent=2)
|
||
except: return r.text
|
||
|
||
@mcp.tool()
|
||
def ingest_repo(url: str, name: str = "", branch: str = "main") -> str:
|
||
"""Clone a repo and index it in RAG."""
|
||
r = httpx.post(f"{RAG_URL}/ingest/repo", json={"url":url,"name":name,"branch":branch}, timeout=300)
|
||
return r.text
|
||
|
||
@mcp.tool()
|
||
def rag_health() -> str:
|
||
"""Check RAG server status."""
|
||
try: return httpx.get(f"{RAG_URL}/health", timeout=10).text
|
||
except Exception as e: return f"RAG unreachable: {e}"
|
||
|
||
if __name__ == "__main__":
|
||
import uvicorn
|
||
uvicorn.run(mcp.get_asgi_app(), host="0.0.0.0", port=8002)
|
||
PY
|
||
|
||
# =============================================================================
|
||
section "Requirements"
|
||
# =============================================================================
|
||
cat > "$BASE/requirements.txt" << 'REQ'
|
||
fastapi
|
||
uvicorn[standard]
|
||
httpx
|
||
pydantic
|
||
chromadb
|
||
pypdf
|
||
python-multipart
|
||
REQ
|
||
|
||
cat > "$BASE/mcp_requirements.txt" << 'REQ'
|
||
mcp[cli]
|
||
fastapi
|
||
uvicorn[standard]
|
||
httpx
|
||
duckduckgo-search
|
||
REQ
|
||
ok "requirements.txt + mcp_requirements.txt"
|
||
|
||
# =============================================================================
|
||
section "SearXNG Config"
|
||
# =============================================================================
|
||
write_if_new "$BASE/searxng/settings.yml" << SEARXNG
|
||
use_default_settings: true
|
||
general:
|
||
instance_name: "Local Search"
|
||
server:
|
||
secret_key: "$(openssl rand -hex 32)"
|
||
limiter: false
|
||
search:
|
||
safe_search: 0
|
||
default_lang: "en"
|
||
formats: [html, json]
|
||
SEARXNG
|
||
|
||
# =============================================================================
|
||
section ".env (tokens — never overwritten)"
|
||
# =============================================================================
|
||
if [[ ! -f "$BASE/.env" ]]; then
|
||
cat > "$BASE/.env" << ENV
|
||
# Local AI Stack — edit to add your API tokens
|
||
GITEA_TOKEN=your-gitea-token-here
|
||
GITHUB_TOKEN=your-github-token-here
|
||
ENV
|
||
ok "Created .env"
|
||
else
|
||
info "Kept .env"
|
||
fi
|
||
|
||
# =============================================================================
|
||
section "Docker Compose"
|
||
# =============================================================================
|
||
cat > "$BASE/docker-compose.yml" << COMPOSE
|
||
# Local AI Stack — 2026-03-20
|
||
# GPU: OLLAMA_NUM_GPU=999 uses all available VRAM automatically (V100/RTX/any)
|
||
# Context: OLLAMA_NUM_CTX= set by detected VRAM (GB)
|
||
|
||
services:
|
||
|
||
ollama:
|
||
image: ollama/ollama:latest
|
||
container_name: ollama
|
||
restart: unless-stopped
|
||
ports: ["0.0.0.0:11434:11434"]
|
||
volumes: [ollama-models:/root/.ollama]
|
||
environment:
|
||
- OLLAMA_NUM_GPU=999
|
||
- OLLAMA_NUM_CTX=$CTX
|
||
- OLLAMA_KEEP_ALIVE=24h
|
||
- OLLAMA_MAX_LOADED_MODELS=1
|
||
- OLLAMA_KV_CACHE_TYPE=$OLLAMA_KV_CACHE
|
||
- OLLAMA_FLASH_ATTENTION=$OLLAMA_FLASH
|
||
deploy:
|
||
resources:
|
||
reservations:
|
||
devices:
|
||
- driver: nvidia
|
||
count: all
|
||
capabilities: [gpu]
|
||
healthcheck:
|
||
test: ["CMD","ollama","list"]
|
||
interval: 30s; timeout: 10s; retries: 5
|
||
|
||
open-webui:
|
||
image: ghcr.io/open-webui/open-webui:main
|
||
container_name: open-webui
|
||
restart: unless-stopped
|
||
ports: ["0.0.0.0:3000:8080"]
|
||
volumes: [open-webui-data:/app/backend/data]
|
||
environment:
|
||
- OLLAMA_BASE_URL=http://ollama:11434
|
||
- OPENAI_API_BASE_URL=http://rag-server:8001/v1
|
||
- OPENAI_API_KEY=local-rag
|
||
- ENABLE_OPENAI_API=true
|
||
- ENABLE_RAG_WEB_SEARCH=true
|
||
- RAG_WEB_SEARCH_ENGINE=duckduckgo
|
||
- SEARXNG_QUERY_URL=http://searxng:8080/search?q=<query>&format=json
|
||
- WEBUI_AUTH=false
|
||
depends_on:
|
||
ollama: {condition: service_healthy}
|
||
|
||
chromadb:
|
||
image: chromadb/chroma:latest
|
||
container_name: chromadb
|
||
restart: unless-stopped
|
||
ports: ["0.0.0.0:8000:8000"]
|
||
volumes: [$BASE/index:/chroma/chroma]
|
||
environment:
|
||
- IS_PERSISTENT=TRUE
|
||
- ANONYMIZED_TELEMETRY=FALSE
|
||
healthcheck:
|
||
test: ["CMD-SHELL","wget -qO- http://localhost:8000/api/v2/heartbeat || exit 1"]
|
||
interval: 15s; timeout: 5s; retries: 5
|
||
|
||
rag-server:
|
||
image: python:3.11-slim
|
||
container_name: rag-server
|
||
restart: unless-stopped
|
||
ports: ["0.0.0.0:8001:8001"]
|
||
volumes:
|
||
- $BASE/papers:/papers
|
||
- $BASE/repos:/repos
|
||
- $BASE/index:/index
|
||
- $BASE/server.py:/app/server.py
|
||
- $BASE/requirements.txt:/app/requirements.txt
|
||
working_dir: /app
|
||
environment:
|
||
- OLLAMA_URL=http://ollama:11434
|
||
- CHROMA_URL=http://chromadb:8000
|
||
- EMBED_MODEL=nomic-embed-text
|
||
- CHAT_MODEL=
|
||
command: >
|
||
bash -c "apt-get update -qq && apt-get install -y --no-install-recommends git &&
|
||
pip install --no-cache-dir -r requirements.txt &&
|
||
uvicorn server:app --host 0.0.0.0 --port 8001"
|
||
depends_on:
|
||
chromadb: {condition: service_healthy}
|
||
ollama: {condition: service_healthy}
|
||
|
||
mcp-server:
|
||
image: python:3.11-slim
|
||
container_name: mcp-server
|
||
restart: unless-stopped
|
||
ports: ["0.0.0.0:8002:8002"]
|
||
volumes:
|
||
- $BASE/workspace:/workspace
|
||
- $BASE/repos:/repos
|
||
- $BASE/mcp_server.py:/app/mcp_server.py
|
||
- $BASE/mcp_requirements.txt:/app/mcp_requirements.txt
|
||
working_dir: /app
|
||
env_file: $BASE/.env
|
||
environment:
|
||
- WORKSPACE_DIR=/workspace
|
||
- REPOS_DIR=/repos
|
||
- GITEA_URL=http://gitea:3000
|
||
- RAG_URL=http://rag-server:8001
|
||
- KIWIX_URL=http://kiwix:80
|
||
command: >
|
||
bash -c "apt-get update -qq && apt-get install -y --no-install-recommends git ripgrep curl &&
|
||
pip install --no-cache-dir -r mcp_requirements.txt &&
|
||
python mcp_server.py"
|
||
depends_on: [rag-server]
|
||
|
||
searxng:
|
||
image: searxng/searxng:latest
|
||
container_name: searxng
|
||
restart: unless-stopped
|
||
ports: ["0.0.0.0:8888:8080"]
|
||
volumes: [$BASE/searxng:/etc/searxng]
|
||
cap_drop: [ALL]
|
||
cap_add: [CHOWN, SETGID, SETUID]
|
||
|
||
kiwix:
|
||
image: ghcr.io/kiwix/kiwix-serve:latest
|
||
container_name: kiwix
|
||
restart: unless-stopped
|
||
ports: ["0.0.0.0:8181:8080"]
|
||
volumes: [$BASE/kiwix:/data]
|
||
command: "*.zim"
|
||
|
||
gitea:
|
||
image: gitea/gitea:latest
|
||
container_name: gitea
|
||
restart: unless-stopped
|
||
ports: ["0.0.0.0:3001:3000","0.0.0.0:2222:22"]
|
||
volumes:
|
||
- $BASE/gitea:/data
|
||
- /etc/timezone:/etc/timezone:ro
|
||
- /etc/localtime:/etc/localtime:ro
|
||
environment:
|
||
- USER_UID=1000
|
||
- USER_GID=1000
|
||
- GITEA__database__DB_TYPE=sqlite3
|
||
- GITEA__database__PATH=/data/gitea/gitea.db
|
||
- GITEA__webhook__ALLOWED_HOST_LIST=rag-server,mcp-server
|
||
|
||
invokeai:
|
||
image: ghcr.io/invoke-ai/invokeai:latest
|
||
container_name: invokeai
|
||
restart: unless-stopped
|
||
ports: ["0.0.0.0:9090:9090"]
|
||
volumes:
|
||
- invokeai-models:/invokeai/models
|
||
- $BASE/invokeai-outputs:/invokeai/outputs
|
||
- $BASE/invokeai-data:/invokeai/databases
|
||
environment:
|
||
- INVOKEAI_HOST=0.0.0.0
|
||
- INVOKEAI_PORT=9090
|
||
- INVOKEAI_PRECISION=float16
|
||
deploy:
|
||
resources:
|
||
reservations:
|
||
devices:
|
||
- driver: nvidia
|
||
count: all
|
||
capabilities: [gpu]
|
||
|
||
portainer:
|
||
image: portainer/portainer-ce:latest
|
||
container_name: portainer
|
||
restart: unless-stopped
|
||
ports: ["0.0.0.0:9000:9000","0.0.0.0:9443:9443"]
|
||
volumes:
|
||
- /var/run/docker.sock:/var/run/docker.sock
|
||
- $BASE/portainer-data:/data
|
||
|
||
volumes:
|
||
ollama-models:
|
||
open-webui-data:
|
||
invokeai-models:
|
||
COMPOSE
|
||
ok "docker-compose.yml"
|
||
|
||
# =============================================================================
|
||
section "Firewall"
|
||
# =============================================================================
|
||
if command -v ufw &>/dev/null && [[ ! -f "$BASE/.ufw-done" ]] || $FORCE; then
|
||
read -rp " LAN subnet [192.168.1.0/24]: " LAN; LAN="${LAN:-192.168.1.0/24}"
|
||
[[ "$LAN" =~ /[0-9]+$ ]] || LAN="${LAN}/24"
|
||
for pc in "3000:Open WebUI" "11434:Ollama" "8001:RAG" "8002:MCP" \
|
||
"8000:ChromaDB" "8888:SearXNG" "8181:Kiwix" \
|
||
"3001:Gitea" "2222:Gitea SSH" "9090:InvokeAI" \
|
||
"9000:Portainer" "9443:Portainer S"; do
|
||
sudo ufw allow from "$LAN" to any port "${pc%%:*}" proto tcp comment "${pc##*:}" >/dev/null
|
||
done
|
||
sudo ufw reload >/dev/null; ok "UFW rules set for $LAN"; touch "$BASE/.ufw-done"
|
||
fi
|
||
|
||
# =============================================================================
|
||
section "Helper Scripts"
|
||
# =============================================================================
|
||
cat > "$BASE/start.sh" << STARTSH
|
||
#!/bin/bash
|
||
cd "$BASE"
|
||
docker compose pull --quiet 2>/dev/null
|
||
docker compose up -d
|
||
echo ""
|
||
echo " Open WebUI → http://$LOCAL_IP:3000"
|
||
echo " InvokeAI → http://$LOCAL_IP:9090"
|
||
echo " SearXNG → http://$LOCAL_IP:8888"
|
||
echo " Kiwix → http://$LOCAL_IP:8181 (run kiwix_download.sh first)"
|
||
echo " Gitea → http://$LOCAL_IP:3001"
|
||
echo " RAG → http://$LOCAL_IP:8001/health"
|
||
echo " MCP SSE → http://$LOCAL_IP:8002/sse"
|
||
echo " Portainer → https://$LOCAL_IP:9443"
|
||
echo ""
|
||
echo " claude mcp add local http://$LOCAL_IP:8002/sse"
|
||
STARTSH
|
||
chmod +x "$BASE/start.sh"
|
||
|
||
cat > "$BASE/stop.sh" << STOPSH
|
||
#!/bin/bash
|
||
cd "$BASE" && docker compose down
|
||
STOPSH
|
||
chmod +x "$BASE/stop.sh"
|
||
|
||
cat > "$BASE/status.sh" << 'STATUSSH'
|
||
#!/bin/bash
|
||
echo "=== GPU ===" && nvidia-smi --query-gpu=name,memory.used,memory.total \
|
||
--format=csv,noheader 2>/dev/null || echo "(no GPU)"
|
||
echo "" && echo "=== Containers ===" && docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
|
||
echo "" && echo "=== Ollama ===" && docker exec ollama ollama ps 2>/dev/null || echo "(not running)"
|
||
echo "" && echo "=== RAG ===" && curl -s http://localhost:8001/health | python3 -m json.tool 2>/dev/null
|
||
STATUSSH
|
||
chmod +x "$BASE/status.sh"
|
||
|
||
cat > "$BASE/pull-models.sh" << PULLSH
|
||
#!/bin/bash
|
||
echo "Waiting for Ollama..."
|
||
until docker exec ollama ollama list &>/dev/null; do sleep 3; done
|
||
|
||
echo "Embed model (RAG — required)..."
|
||
docker exec ollama ollama pull $EMBED_MODEL
|
||
|
||
echo "Fast chat model..."
|
||
docker exec ollama ollama pull qwen2.5:7b
|
||
|
||
echo "Smart chat model..."
|
||
docker exec ollama ollama pull $CHAT_MODEL
|
||
|
||
echo "Code model..."
|
||
docker exec ollama ollama pull $CODE_MODEL
|
||
|
||
echo "Reasoning model (DeepSeek-R1 14B — optional)..."
|
||
read -rp "Pull DeepSeek-R1:14b for planning/reasoning? [y/N]: " DR
|
||
[[ "\${DR,,}" == "y" ]] && docker exec ollama ollama pull deepseek-r1:14b
|
||
|
||
echo "" && docker exec ollama ollama list
|
||
PULLSH
|
||
chmod +x "$BASE/pull-models.sh"
|
||
ok "start.sh stop.sh status.sh pull-models.sh"
|
||
|
||
# =============================================================================
|
||
section "Systemd"
|
||
# =============================================================================
|
||
sudo tee /etc/systemd/system/local-ai.service >/dev/null << SYSD
|
||
[Unit]
|
||
Description=Local AI Stack
|
||
After=docker.service network-online.target
|
||
Requires=docker.service
|
||
|
||
[Service]
|
||
Type=oneshot
|
||
RemainAfterExit=yes
|
||
User=$USER
|
||
WorkingDirectory=$BASE
|
||
ExecStart=/bin/bash $BASE/start.sh
|
||
ExecStop=/bin/bash $BASE/stop.sh
|
||
TimeoutStartSec=300
|
||
|
||
[Install]
|
||
WantedBy=multi-user.target
|
||
SYSD
|
||
sudo systemctl daemon-reload && sudo systemctl enable local-ai.service
|
||
ok "Systemd: local-ai.service enabled"
|
||
|
||
# =============================================================================
|
||
section "Starting Stack"
|
||
# =============================================================================
|
||
cd "$BASE"
|
||
info "Pulling images..."
|
||
docker compose pull --quiet
|
||
docker compose up -d
|
||
ok "Stack running"
|
||
|
||
if ! $IS_UPDATE && ! $NO_PULL; then
|
||
echo ""
|
||
read -rp "Pull Ollama models now? (~15-30 min) [Y/n]: " DO_PULL
|
||
[[ "${DO_PULL,,}" != "n" ]] && bash "$BASE/pull-models.sh"
|
||
fi
|
||
|
||
# =============================================================================
|
||
echo ""
|
||
echo -e "${GREEN}${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||
echo -e "${GREEN}${BOLD} Done! GPU: ${VRAM_GB}GB → $TIER${NC}"
|
||
echo -e "${GREEN}${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||
echo ""
|
||
echo -e " ${CYAN}Open WebUI${NC} → http://$LOCAL_IP:3000"
|
||
echo -e " ${CYAN}InvokeAI${NC} → http://$LOCAL_IP:9090"
|
||
echo -e " ${CYAN}SearXNG${NC} → http://$LOCAL_IP:8888"
|
||
echo -e " ${CYAN}Kiwix${NC} → http://$LOCAL_IP:8181"
|
||
echo -e " ${CYAN}Gitea${NC} → http://$LOCAL_IP:3001"
|
||
echo -e " ${CYAN}RAG${NC} → http://$LOCAL_IP:8001/health"
|
||
echo -e " ${CYAN}MCP SSE${NC} → http://$LOCAL_IP:8002/sse"
|
||
echo -e " ${CYAN}Portainer${NC} → https://$LOCAL_IP:9443"
|
||
echo ""
|
||
echo -e " ${YELLOW}Add MCP to Claude Code:${NC}"
|
||
echo " claude mcp add local http://$LOCAL_IP:8002/sse"
|
||
echo ""
|
||
echo -e " ${YELLOW}Tokens:${NC} $BASE/.env"
|
||
echo -e " ${YELLOW}PDFs:${NC} $BASE/papers/"
|
||
echo -e " ${YELLOW}Workspace:${NC} $BASE/workspace/"
|
||
echo -e " ${YELLOW}ZIMs:${NC} ./kiwix_download.sh"
|
||
echo ""
|
||
echo -e " ${YELLOW}Save Claude usage:${NC} use local 14B for boilerplate, docs,"
|
||
echo " simple fixes. Use Claude Sonnet 4.6 for hard bugs,"
|
||
echo " multi-file refactoring, architecture decisions."
|
||
echo ""
|
||
LOCAL_AI_SETUP_EOF
|
||
chmod +x "$AI_SETUP_SCRIPT"
|
||
sudo -u "$ACTUAL_USER" bash "$AI_SETUP_SCRIPT"
|
||
AI_EXIT=$?
|
||
rm -f "$AI_SETUP_SCRIPT"
|
||
|
||
# --- Patch docker-compose.yml for alternate drive --------------------
|
||
if [ -n "$AI_EXTRA_DRIVE" ] && [ "$AI_EXIT" -eq 0 ]; then
|
||
COMPOSE="$ACTUAL_HOME/docker/ai-stack/docker-compose.yml"
|
||
if [ -f "$COMPOSE" ]; then
|
||
echo ""
|
||
echo "Patching docker-compose.yml for alternate storage..."
|
||
cp "$COMPOSE" "$COMPOSE.bak"
|
||
MODEL_PATH="$AI_EXTRA_DRIVE/$AI_MODEL_FOLDER"
|
||
ZIM_PATH="$AI_EXTRA_DRIVE/$AI_ZIM_FOLDER"
|
||
AI_BASE="$ACTUAL_HOME/docker/ai-stack"
|
||
|
||
# Ollama: swap Docker-managed volume for host path
|
||
sed -i "s|ollama-models:/root/.ollama|${MODEL_PATH}:/root/.ollama|g" "$COMPOSE"
|
||
sed -i '/^ ollama-models:$/d' "$COMPOSE"
|
||
|
||
# Kiwix: add external ZIM mount if single-line volume style
|
||
if grep -q "\[$AI_BASE/kiwix:/data\]" "$COMPOSE" 2>/dev/null; then
|
||
sed -i "s|\[$AI_BASE/kiwix:/data\]|volumes:\n - $AI_BASE/kiwix:/data\n - $ZIM_PATH:/data/external|" "$COMPOSE"
|
||
fi
|
||
chown "$ACTUAL_USER:$ACTUAL_USER" "$COMPOSE"
|
||
|
||
echo "✓ Backup saved → docker-compose.yml.bak"
|
||
echo ""
|
||
echo " Ollama models → $MODEL_PATH"
|
||
echo " Kiwix ZIMs → $ZIM_PATH (served at /data/external in Kiwix)"
|
||
echo ""
|
||
echo "Restart to apply:"
|
||
echo " cd ~/docker/ai-stack && docker compose down && docker compose up -d"
|
||
fi
|
||
fi
|
||
|
||
echo ""
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
[ "$AI_EXIT" -eq 0 ] \
|
||
&& echo "✓ Local AI stack installed successfully" \
|
||
|| echo "⚠ Installer exited with code $AI_EXIT — check output above"
|
||
echo ""
|
||
read -p "Press Enter to continue..." _dummy
|
||
return $AI_EXIT
|
||
}
|
||
|
||
# ============================================================================
|
||
# KIWIX ZIM DOWNLOADER
|
||
# ============================================================================
|
||
|
||
download_zims() {
|
||
echo ""
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
echo "KIWIX ZIM DOWNLOAD"
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
echo ""
|
||
echo "Downloads offline wiki files (background, resumable with -c flag):"
|
||
echo " • Wikipedia (no images) ~46GB • Wikisource ~4GB"
|
||
echo " • Project Gutenberg ~60GB • Stack Overflow ~3GB"
|
||
echo " • TED Talks ~5GB • iFixit ~2GB"
|
||
echo " • + 12 more collections (Wiktionary, Wikibooks, DevDocs, etc.)"
|
||
echo ""
|
||
echo " Total estimate: ~130GB — ensure you have enough disk space!"
|
||
echo ""
|
||
|
||
# Ensure wget is available
|
||
if ! command -v wget &>/dev/null; then
|
||
echo "Installing wget..."
|
||
apt-get install -y wget 2>/dev/null || true
|
||
fi
|
||
|
||
# Optional custom download destination
|
||
echo "Default ZIM storage: $ACTUAL_HOME/docker/ai-stack/kiwix/"
|
||
echo ""
|
||
echo "Currently mounted drives:"
|
||
df -h --output=target,size,avail 2>/dev/null \
|
||
| tail -n +2 \
|
||
| grep -v -E '^(/sys|/proc|/dev|/run|/snap|/boot|efi|tmpfs|udev)' \
|
||
| grep -v ' /$' \
|
||
| awk '{printf " %-38s %-8s %s free\n", $1, $2, $3}' \
|
||
| head -10
|
||
echo ""
|
||
|
||
read -p "Download ZIMs to a different location? (y/n) [n]: " _CUSTOM_ZIM
|
||
if [ "$_CUSTOM_ZIM" = "y" ] || [ "$_CUSTOM_ZIM" = "Y" ]; then
|
||
read -p " ZIM download path: " _ZIM_PATH
|
||
_ZIM_PATH="${_ZIM_PATH/#\~/$ACTUAL_HOME}"
|
||
if [ -n "$_ZIM_PATH" ]; then
|
||
mkdir -p "$_ZIM_PATH"
|
||
chown "$ACTUAL_USER:$ACTUAL_USER" "$_ZIM_PATH" 2>/dev/null || true
|
||
export KIWIX_DIR="$_ZIM_PATH"
|
||
echo " ✓ ZIMs will download to: $_ZIM_PATH"
|
||
fi
|
||
fi
|
||
|
||
echo ""
|
||
echo "Running ZIM downloader as $ACTUAL_USER ..."
|
||
echo ""
|
||
ZIM_SCRIPT=$(mktemp /tmp/kiwix_download.XXXXXX.sh)
|
||
cat > "$ZIM_SCRIPT" << 'KIWIX_DOWNLOAD_EOF'
|
||
#!/bin/bash
|
||
# =============================================================================
|
||
# Kiwix ZIM Download Script
|
||
# Downloads all ZIM files to ~/docker/ai-stack/kiwix/
|
||
# Finds latest version of each file automatically
|
||
# Usage: bash kiwix-download.sh
|
||
# =============================================================================
|
||
|
||
KIWIX_DIR="$HOME/docker/ai-stack/kiwix"
|
||
MIRROR="https://ftp.fau.de/kiwix/zim"
|
||
MIRROR2="https://download.kiwix.org/zim" # fallback for files missing on fau.de
|
||
LOG="$HOME/docker/ai-stack/logs/kiwix-download.log"
|
||
mkdir -p "$KIWIX_DIR" "$(dirname "$LOG")"
|
||
|
||
G='\033[0;32m'; Y='\033[1;33m'; C='\033[0;36m'; N='\033[0m'
|
||
ok() { echo -e "${G}[OK]${N} $1" | tee -a "$LOG"; }
|
||
inf() { echo -e "${C}[..]${N} $1" | tee -a "$LOG"; }
|
||
wrn() { echo -e "${Y}[!!]${N} $1" | tee -a "$LOG"; }
|
||
|
||
# Find latest version of a ZIM file from mirror
|
||
# Usage: latest_zim "base_url" "pattern"
|
||
latest_zim() {
|
||
local base_url="$1"
|
||
local pattern="$2"
|
||
curl -s "$base_url/" | \
|
||
grep -oP "${pattern}_[\d-]+\.zim" | \
|
||
sort -u | tail -1
|
||
}
|
||
|
||
# Download a ZIM file if not already present
|
||
# Usage: download_zim "category" "filename" "description" "size" ["mirror"]
|
||
download_zim() {
|
||
local category="$1"
|
||
local filename="$2"
|
||
local description="$3"
|
||
local size="$4"
|
||
local mirror="${5:-$MIRROR}"
|
||
local url="$mirror/$category/$filename"
|
||
local dest="$KIWIX_DIR/$filename"
|
||
|
||
if [[ -f "$dest" ]]; then
|
||
ok "$description already downloaded — skipping"
|
||
return
|
||
fi
|
||
|
||
inf "Queuing: $description (~$size)"
|
||
inf " URL: $url"
|
||
|
||
nohup wget -c "$url" -O "$dest" \
|
||
>> "$LOG" 2>&1 &
|
||
echo $! >> "$KIWIX_DIR/.download_pids"
|
||
ok "Started download PID $! — $description"
|
||
}
|
||
|
||
echo "" | tee -a "$LOG"
|
||
echo "=== Kiwix Download Started: $(date) ===" | tee -a "$LOG"
|
||
echo "" | tee -a "$LOG"
|
||
echo -e "${C}Finding latest versions...${N}"
|
||
echo ""
|
||
|
||
# ── Find latest filenames ──────────────────────────────────────────────────
|
||
inf "Checking Wikipedia..."
|
||
WIKI=$(latest_zim "$MIRROR/wikipedia" "wikipedia_en_all_nopic")
|
||
|
||
inf "Checking Wiktionary..."
|
||
WIKT=$(latest_zim "$MIRROR/wiktionary" "wiktionary_en_all_nopic")
|
||
|
||
inf "Checking Wikiquote..."
|
||
WIKQ=$(latest_zim "$MIRROR/wikiquote" "wikiquote_en_all_nopic")
|
||
|
||
inf "Checking Wikisource..."
|
||
WIKS=$(latest_zim "$MIRROR/wikisource" "wikisource_en_all_nopic")
|
||
|
||
inf "Checking Wikibooks..."
|
||
WIKB=$(latest_zim "$MIRROR/wikibooks" "wikibooks_en_all_nopic")
|
||
|
||
inf "Checking Wikivoyage..."
|
||
WIKV=$(latest_zim "$MIRROR/wikivoyage" "wikivoyage_en_all_nopic")
|
||
|
||
inf "Checking Wikiversity..."
|
||
WIKUNI=$(latest_zim "$MIRROR/wikiversity" "wikiversity_en_all_nopic")
|
||
|
||
inf "Checking WikiNews..."
|
||
WIKNEWS=$(latest_zim "$MIRROR/wikinews" "wikinews_en_all_nopic")
|
||
|
||
inf "Checking Vikidia (kids K-8)..."
|
||
VIKIDIA=$(latest_zim "$MIRROR/vikidia" "vikidia_en_all_nopic")
|
||
|
||
# Stack Overflow: domain-style filename in stack_exchange/
|
||
inf "Checking Stack Overflow..."
|
||
SO=$(latest_zim "$MIRROR/stack_exchange" "stackoverflow.com_en_all")
|
||
|
||
# Arch Wiki: _maxi is part of the base name, not the date stamp
|
||
inf "Checking Arch Wiki..."
|
||
ARCH=$(latest_zim "$MIRROR/other" "archlinux_en_all_maxi")
|
||
|
||
# TED: lives in ted/ folder, mul_youth variant
|
||
inf "Checking TED Talks..."
|
||
TED=$(latest_zim "$MIRROR/ted" "ted_mul_youth")
|
||
|
||
# PhET: own folder
|
||
inf "Checking PhET Simulations..."
|
||
PHET=$(latest_zim "$MIRROR/phet" "phet_en_all")
|
||
|
||
# DevDocs: variant is zig not all
|
||
inf "Checking DevDocs..."
|
||
DEVDOCS=$(latest_zim "$MIRROR/devdocs" "devdocs_en_zig")
|
||
|
||
inf "Checking FreeCodeCamp..."
|
||
FCC=$(latest_zim "$MIRROR/freecodecamp" "freecodecamp_en_all")
|
||
|
||
inf "Checking iFixit..."
|
||
IFIX=$(latest_zim "$MIRROR/ifixit" "ifixit_en_all")
|
||
|
||
# LibreTexts: own folder, workforce variant (no _all on this mirror)
|
||
inf "Checking LibreTexts..."
|
||
LIBRE=$(latest_zim "$MIRROR/libretexts" "libretexts.org_en_workforce")
|
||
|
||
# Gutenberg: en_all exists on download.kiwix.org, not ftp.fau.de
|
||
inf "Checking Project Gutenberg..."
|
||
GUT=$(latest_zim "$MIRROR2/gutenberg" "gutenberg_en_all")
|
||
|
||
echo ""
|
||
echo -e "${C}═══════════════════════════════════════════════════${N}"
|
||
echo -e "${C} Download Queue${N}"
|
||
echo -e "${C}═══════════════════════════════════════════════════${N}"
|
||
echo ""
|
||
echo " Wikipedia (no images) ${WIKI:-NOT FOUND} ~46GB"
|
||
echo " Wiktionary ${WIKT:-NOT FOUND} ~2GB"
|
||
echo " Wikiquote ${WIKQ:-NOT FOUND} ~300MB"
|
||
echo " Wikisource ${WIKS:-NOT FOUND} ~4GB"
|
||
echo " Wikibooks ${WIKB:-NOT FOUND} ~500MB"
|
||
echo " Wikivoyage ${WIKV:-NOT FOUND} ~200MB"
|
||
echo " Wikiversity ${WIKUNI:-NOT FOUND} ~500MB"
|
||
echo " WikiNews ${WIKNEWS:-NOT FOUND} ~300MB"
|
||
echo " Vikidia (kids K-8) ${VIKIDIA:-NOT FOUND} ~66MB"
|
||
echo " Stack Overflow ${SO:-NOT FOUND} ~3GB"
|
||
echo " Arch Linux Wiki ${ARCH:-NOT FOUND} ~30MB"
|
||
echo " TED Talks ${TED:-NOT FOUND} ~5GB"
|
||
echo " PhET Simulations ${PHET:-NOT FOUND} ~500MB"
|
||
echo " DevDocs ${DEVDOCS:-NOT FOUND} ~1GB"
|
||
echo " FreeCodeCamp ${FCC:-NOT FOUND} ~small"
|
||
echo " iFixit (repair guides) ${IFIX:-NOT FOUND} ~2GB"
|
||
echo " LibreTexts (textbooks) ${LIBRE:-NOT FOUND} ~varies"
|
||
echo " Project Gutenberg ${GUT:-NOT FOUND} ~60GB"
|
||
echo ""
|
||
echo -e "${Y} Total estimate: ~130GB — make sure you have space!${N}"
|
||
echo ""
|
||
|
||
df -h "$KIWIX_DIR" | tail -1 | awk '{print " Available disk space: " $4}'
|
||
echo ""
|
||
|
||
read -rp " Proceed with all downloads? (Y/n): " CONFIRM
|
||
[[ "${CONFIRM,,}" == "n" ]] && exit 0
|
||
|
||
# ── Start downloads ────────────────────────────────────────────────────────
|
||
echo ""
|
||
rm -f "$KIWIX_DIR/.download_pids"
|
||
|
||
[[ -n "$WIKI" ]] && download_zim "wikipedia" "$WIKI" "Wikipedia (no images)" "46GB"
|
||
[[ -n "$WIKT" ]] && download_zim "wiktionary" "$WIKT" "Wiktionary" "2GB"
|
||
[[ -n "$WIKQ" ]] && download_zim "wikiquote" "$WIKQ" "Wikiquote" "300MB"
|
||
[[ -n "$WIKS" ]] && download_zim "wikisource" "$WIKS" "Wikisource" "4GB"
|
||
[[ -n "$WIKB" ]] && download_zim "wikibooks" "$WIKB" "Wikibooks" "500MB"
|
||
[[ -n "$WIKV" ]] && download_zim "wikivoyage" "$WIKV" "Wikivoyage" "200MB"
|
||
[[ -n "$WIKUNI" ]] && download_zim "wikiversity" "$WIKUNI" "Wikiversity" "500MB"
|
||
[[ -n "$WIKNEWS" ]] && download_zim "wikinews" "$WIKNEWS" "WikiNews" "300MB"
|
||
[[ -n "$VIKIDIA" ]] && download_zim "vikidia" "$VIKIDIA" "Vikidia (kids K-8)" "66MB"
|
||
[[ -n "$SO" ]] && download_zim "stack_exchange" "$SO" "Stack Overflow" "3GB"
|
||
[[ -n "$ARCH" ]] && download_zim "other" "$ARCH" "Arch Linux Wiki" "30MB"
|
||
[[ -n "$TED" ]] && download_zim "ted" "$TED" "TED Talks" "5GB"
|
||
[[ -n "$PHET" ]] && download_zim "phet" "$PHET" "PhET Simulations" "500MB"
|
||
[[ -n "$DEVDOCS" ]] && download_zim "devdocs" "$DEVDOCS" "DevDocs" "1GB"
|
||
[[ -n "$FCC" ]] && download_zim "freecodecamp" "$FCC" "FreeCodeCamp" "small"
|
||
[[ -n "$IFIX" ]] && download_zim "ifixit" "$IFIX" "iFixit (repair guides)" "2GB"
|
||
[[ -n "$LIBRE" ]] && download_zim "libretexts" "$LIBRE" "LibreTexts (textbooks)" "varies"
|
||
[[ -n "$GUT" ]] && download_zim "gutenberg" "$GUT" "Project Gutenberg" "60GB" "$MIRROR2"
|
||
|
||
echo ""
|
||
echo -e "${G}════════════════════════════════════════════════════${N}"
|
||
echo -e "${G} All downloads started in background${N}"
|
||
echo -e "${G}════════════════════════════════════════════════════${N}"
|
||
echo ""
|
||
echo " Monitor progress:"
|
||
echo " tail -f $LOG"
|
||
echo ""
|
||
echo " Check file sizes growing:"
|
||
echo " watch -n 60 'ls -lh $KIWIX_DIR/'"
|
||
echo ""
|
||
echo " Check active downloads:"
|
||
echo " jobs -l"
|
||
echo " ps aux | grep wget"
|
||
echo ""
|
||
echo " Once all downloads complete, restart Kiwix:"
|
||
echo " cd ~/docker/ai-stack"
|
||
echo " docker compose up -d kiwix"
|
||
echo ""
|
||
echo -e "${Y} NOTE: Downloads resume automatically if interrupted (-c flag)${N}"
|
||
KIWIX_DOWNLOAD_EOF
|
||
chmod +x "$ZIM_SCRIPT"
|
||
sudo -u "$ACTUAL_USER" -E bash "$ZIM_SCRIPT"
|
||
rm -f "$ZIM_SCRIPT"
|
||
echo ""
|
||
read -p "Press Enter to continue..." _dummy
|
||
}
|
||
|
||
# ============================================================================
|
||
# MODE SELECTOR
|
||
# ============================================================================
|
||
|
||
# Check for restore mode flag
|
||
if [ "$RESTORE_MODE" = true ]; then
|
||
run_disaster_recovery
|
||
exit $?
|
||
fi
|
||
|
||
# Interactive mode selector (if not unattended)
|
||
if [ "$UNATTENDED" != true ]; then
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
echo "INSTALLATION MODE"
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
echo ""
|
||
echo " [N] Normal install - Fresh install or modify existing services"
|
||
echo " [M] Migration - Import existing Docker containers"
|
||
echo " [R] Disaster recovery - Restore from Kopia backup"
|
||
echo " [A] Local AI stack - Ollama, Open WebUI, RAG, MCP, Kiwix..."
|
||
echo " [Z] Download ZIMs - Offline Wikipedia, Stack Overflow & more"
|
||
echo ""
|
||
read -p "Select mode (N/M/R/A/Z) [N]: " MODE_SELECT
|
||
|
||
case "${MODE_SELECT^^}" in
|
||
R)
|
||
run_disaster_recovery
|
||
exit $?
|
||
;;
|
||
M)
|
||
run_migration
|
||
# If migration returns 0 and INSTALL_MODE is post-migration, continue
|
||
if [ $? -ne 0 ]; then
|
||
exit 1
|
||
fi
|
||
if [ "$INSTALL_MODE" != "post-migration" ]; then
|
||
exit 0
|
||
fi
|
||
echo ""
|
||
echo "Continuing with additional service installation..."
|
||
echo ""
|
||
;;
|
||
A)
|
||
install_ai_stack
|
||
exit $?
|
||
;;
|
||
Z)
|
||
download_zims
|
||
exit $?
|
||
;;
|
||
*)
|
||
INSTALL_MODE="normal"
|
||
;;
|
||
esac
|
||
echo ""
|
||
fi
|
||
|
||
# ============================================================================
|
||
# DRIVE SETUP (Runs before everything else)
|
||
# ============================================================================
|
||
|
||
setup_drives() {
|
||
echo ""
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
echo "DRIVE SETUP"
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
echo ""
|
||
echo "This script uses ~/drives/ for data storage:"
|
||
echo " ~/drives/primary - Main data drive (media, photos, etc.)"
|
||
echo " ~/drives/backup1 - Backup drive(s)"
|
||
echo ""
|
||
|
||
# Create base drives directory
|
||
mkdir -p "$ACTUAL_HOME/drives"
|
||
chown "$ACTUAL_USER:$ACTUAL_USER" "$ACTUAL_HOME/drives"
|
||
|
||
# Check for existing mounts
|
||
if mount | grep -q "$ACTUAL_HOME/drives/"; then
|
||
echo "Existing mounted drives:"
|
||
df -h | grep "$ACTUAL_HOME/drives" | awk '{print " " $6 " (" $2 " total, " $4 " free)"}'
|
||
echo ""
|
||
prompt_yn "Configure additional drives? (y/n):" "n" SETUP_DRIVES
|
||
else
|
||
echo "No drives currently mounted in ~/drives/"
|
||
echo ""
|
||
prompt_yn "Set up drives now? (y/n):" "y" SETUP_DRIVES
|
||
fi
|
||
|
||
if [ "$SETUP_DRIVES" != "y" ] && [ "$SETUP_DRIVES" != "Y" ]; then
|
||
echo "Skipping drive setup. You can run this script again later."
|
||
return 0
|
||
fi
|
||
|
||
echo ""
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
echo "AVAILABLE BLOCK DEVICES"
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
echo ""
|
||
lsblk -o NAME,SIZE,TYPE,FSTYPE,MOUNTPOINT,LABEL
|
||
echo ""
|
||
|
||
# Detect drives without partition tables (new drives)
|
||
echo "Checking for new/unpartitioned drives..."
|
||
NEW_DRIVES=()
|
||
for disk in /dev/sd? /dev/nvme?n?; do
|
||
[ -b "$disk" ] || continue
|
||
# Check if disk has no partitions
|
||
if ! lsblk -n "$disk" | grep -q "part"; then
|
||
# Check if it has no partition table
|
||
if ! blkid "$disk" &>/dev/null; then
|
||
size=$(lsblk -n -d -o SIZE "$disk" 2>/dev/null)
|
||
NEW_DRIVES+=("$disk ($size)")
|
||
fi
|
||
fi
|
||
done
|
||
|
||
if [ ${#NEW_DRIVES[@]} -gt 0 ]; then
|
||
echo ""
|
||
echo "⚠️ Found unpartitioned drives (no filesystem):"
|
||
for drive in "${NEW_DRIVES[@]}"; do
|
||
echo " • $drive"
|
||
done
|
||
echo ""
|
||
prompt_yn "Would you like to partition and format any of these? (y/n):" "n" FORMAT_DRIVES
|
||
|
||
if [ "$FORMAT_DRIVES" = "y" ] || [ "$FORMAT_DRIVES" = "Y" ]; then
|
||
for drive_info in "${NEW_DRIVES[@]}"; do
|
||
drive=$(echo "$drive_info" | cut -d' ' -f1)
|
||
echo ""
|
||
prompt_yn "Format $drive_info as ext4? (ALL DATA WILL BE ERASED) (y/n):" "n" FORMAT_THIS
|
||
|
||
if [ "$FORMAT_THIS" = "y" ] || [ "$FORMAT_THIS" = "Y" ]; then
|
||
echo " Creating partition table on $drive..."
|
||
parted -s "$drive" mklabel gpt
|
||
parted -s "$drive" mkpart primary ext4 0% 100%
|
||
|
||
# Wait for partition to appear
|
||
sleep 2
|
||
|
||
# Format the partition
|
||
part="${drive}1"
|
||
[ -b "${drive}p1" ] && part="${drive}p1" # nvme drives
|
||
|
||
if [ -b "$part" ]; then
|
||
echo " Formatting $part as ext4..."
|
||
mkfs.ext4 -F "$part"
|
||
echo " ✓ Formatted $part"
|
||
else
|
||
echo " ⚠ Could not find partition after creation"
|
||
fi
|
||
fi
|
||
done
|
||
echo ""
|
||
echo "Updated block devices:"
|
||
lsblk -o NAME,SIZE,TYPE,FSTYPE,MOUNTPOINT,LABEL
|
||
echo ""
|
||
fi
|
||
fi
|
||
|
||
# Configure mount points
|
||
echo ""
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
echo "CONFIGURE MOUNT POINTS"
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
echo ""
|
||
|
||
# Primary drive
|
||
echo "PRIMARY DRIVE (for media, photos, docker data):"
|
||
prompt_text " Mount point name [primary]:" "primary" PRIMARY_NAME
|
||
PRIMARY_NAME="${PRIMARY_NAME:-primary}"
|
||
mkdir -p "$ACTUAL_HOME/drives/$PRIMARY_NAME"
|
||
|
||
# Check if already mounted
|
||
if mount | grep -q "$ACTUAL_HOME/drives/$PRIMARY_NAME"; then
|
||
echo " ✓ Already mounted"
|
||
else
|
||
echo " Enter device (e.g., /dev/sdb1) or leave blank to skip:"
|
||
read -p " Device for $PRIMARY_NAME: " PRIMARY_DEV
|
||
|
||
if [ -n "$PRIMARY_DEV" ] && [ -b "$PRIMARY_DEV" ]; then
|
||
# Add to fstab
|
||
PRIMARY_UUID=$(blkid -s UUID -o value "$PRIMARY_DEV" 2>/dev/null)
|
||
if [ -n "$PRIMARY_UUID" ]; then
|
||
# Check if not already in fstab
|
||
if ! grep -q "$PRIMARY_UUID" /etc/fstab 2>/dev/null; then
|
||
cp /etc/fstab /etc/fstab.backup-$(date +%Y%m%d-%H%M%S) 2>/dev/null || true
|
||
echo "UUID=$PRIMARY_UUID $ACTUAL_HOME/drives/$PRIMARY_NAME auto defaults,nofail 0 2" >> /etc/fstab
|
||
echo " ✓ Added to fstab"
|
||
fi
|
||
fi
|
||
fi
|
||
fi
|
||
|
||
# Backup drives
|
||
echo ""
|
||
echo "BACKUP DRIVES (optional, for rsync backups):"
|
||
prompt_text " How many backup drives? [0-4, default: 0]:" "0" NUM_BACKUPS
|
||
NUM_BACKUPS="${NUM_BACKUPS:-0}"
|
||
|
||
# Validate number
|
||
case $NUM_BACKUPS in
|
||
0|1|2|3|4) ;;
|
||
*) NUM_BACKUPS=0 ;;
|
||
esac
|
||
|
||
declare -a BACKUP_NAMES
|
||
declare -a BACKUP_DEVS
|
||
for i in $(seq 1 $NUM_BACKUPS); do
|
||
echo ""
|
||
prompt_text " Backup drive $i name [backup$i]:" "backup$i" "BACKUP_NAME"
|
||
BACKUP_NAMES[$i]="${BACKUP_NAME:-backup$i}"
|
||
mkdir -p "$ACTUAL_HOME/drives/${BACKUP_NAMES[$i]}"
|
||
|
||
if ! mount | grep -q "$ACTUAL_HOME/drives/${BACKUP_NAMES[$i]}"; then
|
||
read -p " Device for ${BACKUP_NAMES[$i]}: " "BACKUP_DEV"
|
||
BACKUP_DEVS[$i]="$BACKUP_DEV"
|
||
|
||
if [ -n "$BACKUP_DEV" ] && [ -b "$BACKUP_DEV" ]; then
|
||
BACKUP_UUID=$(blkid -s UUID -o value "$BACKUP_DEV" 2>/dev/null)
|
||
if [ -n "$BACKUP_UUID" ]; then
|
||
if ! grep -q "$BACKUP_UUID" /etc/fstab 2>/dev/null; then
|
||
echo "UUID=$BACKUP_UUID $ACTUAL_HOME/drives/${BACKUP_NAMES[$i]} auto defaults,nofail 0 2" >> /etc/fstab
|
||
echo " ✓ Added ${BACKUP_NAMES[$i]} to fstab"
|
||
fi
|
||
fi
|
||
fi
|
||
else
|
||
echo " ✓ Already mounted"
|
||
fi
|
||
done
|
||
|
||
# Set permissions
|
||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$ACTUAL_HOME/drives"
|
||
|
||
# Mount all from fstab
|
||
echo ""
|
||
echo "Mounting drives from fstab..."
|
||
mount -a 2>/dev/null || true
|
||
|
||
echo ""
|
||
echo "Current mounts in ~/drives/:"
|
||
df -h | grep "$ACTUAL_HOME/drives" | awk '{print " " $6 " - " $2 " total, " $4 " free"}' || echo " (none)"
|
||
echo ""
|
||
|
||
# Export drive names for later use
|
||
export PRIMARY_DRIVE_NAME="$PRIMARY_NAME"
|
||
export BACKUP_DRIVE_COUNT="$NUM_BACKUPS"
|
||
for i in $(seq 1 $NUM_BACKUPS); do
|
||
export "BACKUP_DRIVE_${i}_NAME=${BACKUP_NAMES[$i]}"
|
||
done
|
||
|
||
echo "✓ Drive setup complete"
|
||
echo ""
|
||
}
|
||
|
||
# Run drive setup for normal installs (not migration/recovery)
|
||
if [ "$INSTALL_MODE" = "normal" ] && [ "$UNATTENDED" != true ]; then
|
||
setup_drives
|
||
fi
|
||
|
||
# ============================================================================
|
||
# SOFTWARE DETECTION FUNCTIONS
|
||
# ============================================================================
|
||
|
||
is_docker_installed() {
|
||
command -v docker &> /dev/null && systemctl is-active --quiet docker 2>/dev/null
|
||
}
|
||
|
||
is_samba_installed() {
|
||
command -v smbd &> /dev/null && systemctl is-active --quiet smbd 2>/dev/null
|
||
}
|
||
|
||
is_netbird_installed() {
|
||
command -v netbird &> /dev/null
|
||
}
|
||
|
||
is_rustdesk_installed() {
|
||
command -v rustdesk &> /dev/null || dpkg -l rustdesk &> /dev/null
|
||
}
|
||
|
||
is_rclone_installed() {
|
||
command -v rclone &> /dev/null
|
||
}
|
||
|
||
is_rsync_installed() {
|
||
command -v rsync &> /dev/null
|
||
}
|
||
|
||
is_ufw_installed() {
|
||
command -v ufw &> /dev/null
|
||
}
|
||
|
||
is_wireguard_installed() {
|
||
command -v wg &> /dev/null
|
||
}
|
||
|
||
is_tailscale_installed() {
|
||
command -v tailscale &> /dev/null
|
||
}
|
||
|
||
is_teamviewer_installed() {
|
||
command -v teamviewer &> /dev/null || dpkg -l teamviewer &> /dev/null 2>&1
|
||
}
|
||
|
||
is_meshcentral_installed() {
|
||
# MeshCentral agent is typically installed as meshagent
|
||
command -v meshagent &> /dev/null || [ -f /usr/local/mesh_services/meshagent/meshagent ]
|
||
}
|
||
|
||
# ============================================================================
|
||
# DRY-RUN AND UNATTENDED HELPER FUNCTIONS
|
||
# ============================================================================
|
||
|
||
# Run a command, but skip it in dry-run mode
|
||
run_cmd() {
|
||
if [ "$DRY_RUN" = true ]; then
|
||
echo "[DRY-RUN] Would execute: $*"
|
||
return 0
|
||
else
|
||
"$@"
|
||
fi
|
||
}
|
||
|
||
# Ensure Docker directories are owned by the actual user (not root)
|
||
# Usage: ensure_docker_dir_ownership /path/to/dir [additional_paths...]
|
||
ensure_docker_dir_ownership() {
|
||
if [ "$DRY_RUN" = true ]; then
|
||
echo "[DRY-RUN] Would set ownership of $* to $ACTUAL_USER:$ACTUAL_USER"
|
||
return 0
|
||
fi
|
||
|
||
for dir in "$@"; do
|
||
if [ -d "$dir" ]; then
|
||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$dir" 2>/dev/null || true
|
||
fi
|
||
done
|
||
}
|
||
|
||
# Generate a secure random password with alphanumeric characters only (no special chars)
|
||
# Usage: generate_password [length]
|
||
# Default length: 32
|
||
generate_password() {
|
||
local length="${1:-32}"
|
||
openssl rand -base64 48 | tr -dc 'a-zA-Z0-9' | head -c "$length"
|
||
}
|
||
|
||
# Validate password for Keycloak (alphanumeric only, minimum length)
|
||
# Usage: validate_password "password" [min_length]
|
||
# Returns 0 if valid, 1 if invalid
|
||
validate_password() {
|
||
local password="$1"
|
||
local min_length="${2:-12}"
|
||
|
||
# Check length
|
||
if [ ${#password} -lt "$min_length" ]; then
|
||
echo " ⚠ Password must be at least $min_length characters long"
|
||
return 1
|
||
fi
|
||
|
||
# Check for special characters (not allowed for Keycloak)
|
||
if echo "$password" | grep -q '[^a-zA-Z0-9]'; then
|
||
echo " ⚠ Password must contain only letters and numbers (no special characters)"
|
||
return 1
|
||
fi
|
||
|
||
return 0
|
||
}
|
||
|
||
# Prompt for yes/no, with unattended default
|
||
# Usage: prompt_yn "Question?" "default" VARNAME
|
||
# default can be "y" or "n"
|
||
prompt_yn() {
|
||
local question="$1"
|
||
local default="$2"
|
||
local varname="$3"
|
||
|
||
if [ "$UNATTENDED" = true ]; then
|
||
eval "$varname='$default'"
|
||
echo "$question [auto: $default]"
|
||
return
|
||
fi
|
||
|
||
read -p "$question " response
|
||
eval "$varname='$response'"
|
||
}
|
||
|
||
# Prompt for text input, with unattended default
|
||
# Usage: prompt_text "Question?" "default" VARNAME
|
||
prompt_text() {
|
||
local question="$1"
|
||
local default="$2"
|
||
local varname="$3"
|
||
|
||
if [ "$UNATTENDED" = true ]; then
|
||
eval "$varname='$default'"
|
||
echo "$question [auto: $default]"
|
||
return
|
||
fi
|
||
|
||
read -p "$question " response
|
||
eval "$varname='$response'"
|
||
}
|
||
|
||
# ============================================================================
|
||
# CADDY CONFIGURATION HELPER
|
||
# ============================================================================
|
||
configure_caddy_for_service() {
|
||
local SERVICE_NAME="$1"
|
||
local SERVICE_PORT="$2"
|
||
local DEFAULT_SUBDOMAIN="$3"
|
||
local EXTRA_CONFIG="${4:-}" # Optional extra Caddy directives
|
||
|
||
# Check if Caddy is installed
|
||
if [ ! -d "$DOCKER_DIR/caddy" ]; then
|
||
return 0 # Caddy not installed, skip configuration
|
||
fi
|
||
|
||
echo ""
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
echo " CADDY REVERSE PROXY CONFIGURATION"
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
echo ""
|
||
echo "Caddy is installed. You can configure reverse proxy for $SERVICE_NAME."
|
||
echo ""
|
||
|
||
local CONFIGURE_CADDY=""
|
||
prompt_yn "Configure Caddy reverse proxy for $SERVICE_NAME? (y/n):" "n" CONFIGURE_CADDY
|
||
|
||
if [ "$CONFIGURE_CADDY" != "y" ] && [ "$CONFIGURE_CADDY" != "Y" ]; then
|
||
echo " Skipping Caddy configuration."
|
||
echo " Access $SERVICE_NAME at: http://localhost:$SERVICE_PORT"
|
||
return 0
|
||
fi
|
||
|
||
# Get domain/subdomain
|
||
echo ""
|
||
echo "Enter the full domain for $SERVICE_NAME:"
|
||
echo " Examples: $DEFAULT_SUBDOMAIN.example.com, $DEFAULT_SUBDOMAIN.yourdomain.com"
|
||
echo ""
|
||
local SERVICE_DOMAIN=""
|
||
prompt_text "Domain:" "" SERVICE_DOMAIN
|
||
|
||
if [ -z "$SERVICE_DOMAIN" ]; then
|
||
echo " ⚠ No domain provided, skipping Caddy configuration."
|
||
return 0
|
||
fi
|
||
|
||
# Backup Caddyfile
|
||
local CADDY_DIR="$DOCKER_DIR/caddy"
|
||
local CADDYFILE="$CADDY_DIR/Caddyfile"
|
||
local BACKUP_FILE="$CADDY_DIR/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)"
|
||
|
||
if [ -f "$CADDYFILE" ]; then
|
||
echo " Backing up Caddyfile to: $(basename $BACKUP_FILE)"
|
||
cp "$CADDYFILE" "$BACKUP_FILE"
|
||
else
|
||
echo " Creating new Caddyfile"
|
||
touch "$CADDYFILE"
|
||
fi
|
||
|
||
# Check if service already configured
|
||
if grep -q "^${SERVICE_DOMAIN}" "$CADDYFILE" 2>/dev/null; then
|
||
echo " ⚠ $SERVICE_DOMAIN already exists in Caddyfile"
|
||
local OVERWRITE=""
|
||
prompt_yn "Overwrite existing configuration? (y/n):" "n" OVERWRITE
|
||
if [ "$OVERWRITE" != "y" ] && [ "$OVERWRITE" != "Y" ]; then
|
||
echo " Keeping existing configuration."
|
||
return 0
|
||
fi
|
||
# Remove existing configuration
|
||
sed -i "/^${SERVICE_DOMAIN}/,/^}/d" "$CADDYFILE"
|
||
fi
|
||
|
||
# Add service configuration to Caddyfile
|
||
echo " Adding $SERVICE_NAME configuration to Caddyfile..."
|
||
|
||
cat >> "$CADDYFILE" << CADDY_BLOCK
|
||
|
||
# $SERVICE_NAME
|
||
$SERVICE_DOMAIN {
|
||
reverse_proxy localhost:$SERVICE_PORT
|
||
|
||
# Security headers
|
||
header {
|
||
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||
X-Content-Type-Options "nosniff"
|
||
X-Frame-Options "SAMEORIGIN"
|
||
Referrer-Policy "strict-origin-when-cross-origin"
|
||
}
|
||
|
||
# Logging for fail2ban
|
||
log {
|
||
output file /var/log/caddy/${SERVICE_DOMAIN}.log
|
||
format json
|
||
}
|
||
$EXTRA_CONFIG
|
||
}
|
||
CADDY_BLOCK
|
||
|
||
echo " ✓ Configuration added to Caddyfile"
|
||
|
||
# Reload Caddy
|
||
echo " Reloading Caddy configuration..."
|
||
if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then
|
||
echo " ✓ Caddy reloaded successfully"
|
||
else
|
||
echo " ⚠ Failed to reload Caddy (will retry after formatting)"
|
||
fi
|
||
|
||
# Format Caddyfile
|
||
echo " Formatting Caddyfile..."
|
||
if docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null; then
|
||
echo " ✓ Caddyfile formatted"
|
||
else
|
||
echo " ⚠ Failed to format Caddyfile"
|
||
fi
|
||
|
||
# Final reload after formatting
|
||
echo " Final reload..."
|
||
if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then
|
||
echo " ✓ Caddy configuration active"
|
||
echo ""
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
echo " $SERVICE_NAME is now accessible at:"
|
||
echo " https://$SERVICE_DOMAIN"
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
else
|
||
echo " ⚠ Failed final reload. Check Caddy logs:"
|
||
echo " docker logs caddy"
|
||
echo " You can restore from backup: $BACKUP_FILE"
|
||
fi
|
||
|
||
echo ""
|
||
}
|
||
|
||
# ============================================================================
|
||
# CHECK IF SERVICE EXISTS AND ASK USER ACTION
|
||
# ============================================================================
|
||
check_service_exists() {
|
||
local SERVICE_NAME="$1"
|
||
local SERVICE_DIR="$2"
|
||
local RETURN_VAR="$3" # Variable name to set (true/false)
|
||
|
||
if [ -f "$SERVICE_DIR/docker-compose.yml" ]; then
|
||
echo ""
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
echo " $SERVICE_NAME is already installed at $SERVICE_DIR"
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
echo ""
|
||
echo "Options:"
|
||
echo " 1. Skip (keep existing configuration)"
|
||
echo " 2. Reconfigure (will backup existing config)"
|
||
echo " 3. Restart containers only"
|
||
echo ""
|
||
local ACTION=""
|
||
prompt_text "Choose option [1/2/3]:" "1" ACTION
|
||
|
||
case "$ACTION" in
|
||
2)
|
||
echo " Backing up existing configuration..."
|
||
local BACKUP_NAME=$(echo "$SERVICE_NAME" | tr '[:upper:] ' '[:lower:]-')
|
||
BACKUP_DIR="$DOCKER_DIR/backups/$(date +%Y%m%d-%H%M%S)-$BACKUP_NAME"
|
||
mkdir -p "$BACKUP_DIR"
|
||
cp -r "$SERVICE_DIR" "$BACKUP_DIR/"
|
||
echo " ✓ Backup saved to: $BACKUP_DIR"
|
||
eval "$RETURN_VAR=true"
|
||
return 0
|
||
;;
|
||
3)
|
||
echo " Restarting $SERVICE_NAME containers..."
|
||
cd "$SERVICE_DIR"
|
||
docker compose restart 2>/dev/null && echo " ✓ $SERVICE_NAME restarted" || echo " ⚠ Failed to restart"
|
||
eval "$RETURN_VAR=false"
|
||
return 0
|
||
;;
|
||
*)
|
||
echo " Skipping $SERVICE_NAME (already configured)"
|
||
eval "$RETURN_VAR=false"
|
||
return 0
|
||
;;
|
||
esac
|
||
else
|
||
# Service doesn't exist, proceed with installation
|
||
eval "$RETURN_VAR=true"
|
||
return 0
|
||
fi
|
||
}
|
||
|
||
# ============================================================================
|
||
# DETECT AVAILABLE DRIVES (RUN ONCE)
|
||
# ============================================================================
|
||
detect_drives() {
|
||
# Global variables set by this function:
|
||
# - DRIVES_DETECTED: true/false
|
||
# - PRIMARY_DRIVE: name of first drive (e.g., "storage1")
|
||
# - PRIMARY_DRIVE_PATH: full path to first drive
|
||
# - DRIVES_DIR: base drives directory
|
||
# - AVAILABLE_DRIVES_COUNT: number of drives found
|
||
|
||
DRIVES_DIR="$ACTUAL_HOME/drives"
|
||
DRIVES_DETECTED=false
|
||
PRIMARY_DRIVE=""
|
||
PRIMARY_DRIVE_PATH=""
|
||
AVAILABLE_DRIVES_COUNT=0
|
||
|
||
if [ -d "$DRIVES_DIR" ]; then
|
||
echo ""
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
echo " DETECTED DRIVES"
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
echo ""
|
||
|
||
for drive in "$DRIVES_DIR"/*; do
|
||
if [ -d "$drive" ]; then
|
||
drive_name=$(basename "$drive")
|
||
drive_size=$(df -h "$drive" 2>/dev/null | awk 'NR==2 {print $2}')
|
||
drive_used=$(df -h "$drive" 2>/dev/null | awk 'NR==2 {print $3}')
|
||
drive_avail=$(df -h "$drive" 2>/dev/null | awk 'NR==2 {print $4}')
|
||
|
||
echo " ✓ $drive_name"
|
||
if [ -n "$drive_size" ]; then
|
||
echo " Path: $drive"
|
||
echo " Size: $drive_size (Used: $drive_used, Available: $drive_avail)"
|
||
else
|
||
echo " Path: $drive"
|
||
fi
|
||
|
||
# Set PRIMARY_DRIVE to the first drive found
|
||
if [ -z "$PRIMARY_DRIVE" ]; then
|
||
PRIMARY_DRIVE="$drive_name"
|
||
PRIMARY_DRIVE_PATH="$drive"
|
||
fi
|
||
|
||
AVAILABLE_DRIVES_COUNT=$((AVAILABLE_DRIVES_COUNT + 1))
|
||
fi
|
||
done
|
||
|
||
if [ $AVAILABLE_DRIVES_COUNT -gt 0 ]; then
|
||
DRIVES_DETECTED=true
|
||
echo ""
|
||
echo " Using '$PRIMARY_DRIVE' as primary drive for default paths"
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
fi
|
||
fi
|
||
|
||
# If no drives detected, use home directory
|
||
if [ "$DRIVES_DETECTED" = "false" ]; then
|
||
echo ""
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
echo " No ~/drives directory found"
|
||
echo " Using $ACTUAL_HOME for default paths"
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
PRIMARY_DRIVE_PATH="$ACTUAL_HOME"
|
||
fi
|
||
}
|
||
|
||
# ============================================================================
|
||
# SHOW CURRENT INSTALLATION STATUS
|
||
# ============================================================================
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
echo "CURRENT SYSTEM STATUS"
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
echo ""
|
||
if is_docker_installed; then
|
||
echo " ✓ Docker: Installed ($(docker --version 2>/dev/null | cut -d' ' -f3 | tr -d ','))"
|
||
else
|
||
echo " ○ Docker: Not installed"
|
||
fi
|
||
if is_samba_installed; then
|
||
echo " ✓ Samba: Installed and running"
|
||
else
|
||
echo " ○ Samba: Not installed"
|
||
fi
|
||
if is_netbird_installed; then
|
||
echo " ✓ NetBird: Installed"
|
||
else
|
||
echo " ○ NetBird: Not installed"
|
||
fi
|
||
if is_rustdesk_installed; then
|
||
echo " ✓ RustDesk: Installed"
|
||
else
|
||
echo " ○ RustDesk: Not installed"
|
||
fi
|
||
if is_teamviewer_installed; then
|
||
echo " ✓ TeamViewer: Installed"
|
||
else
|
||
echo " ○ TeamViewer: Not installed"
|
||
fi
|
||
if is_meshcentral_installed; then
|
||
echo " ✓ MeshCentral Agent: Installed"
|
||
else
|
||
echo " ○ MeshCentral Agent: Not installed"
|
||
fi
|
||
if is_wireguard_installed; then
|
||
echo " ✓ WireGuard: Installed"
|
||
else
|
||
echo " ○ WireGuard: Not installed"
|
||
fi
|
||
if is_tailscale_installed; then
|
||
echo " ✓ Tailscale: Installed"
|
||
else
|
||
echo " ○ Tailscale: Not installed"
|
||
fi
|
||
if is_rclone_installed; then
|
||
echo " ✓ rclone: Installed"
|
||
else
|
||
echo " ○ rclone: Not installed"
|
||
fi
|
||
if is_rsync_installed; then
|
||
echo " ✓ rsync: Installed"
|
||
else
|
||
echo " ○ rsync: Not installed"
|
||
fi
|
||
if is_ufw_installed; then
|
||
if ufw status 2>/dev/null | grep -q "Status: active"; then
|
||
echo " ✓ UFW Firewall: Enabled"
|
||
else
|
||
echo " ○ UFW Firewall: Installed but disabled"
|
||
fi
|
||
else
|
||
echo " ○ UFW Firewall: Not installed"
|
||
fi
|
||
echo ""
|
||
echo "This script can reinstall/reconfigure any component."
|
||
echo ""
|
||
|
||
# Update package list
|
||
echo "Updating package lists..."
|
||
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 <peer-name>' 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 = <your-private-key>"
|
||
echo " Address = 10.0.0.1/24"
|
||
echo " ListenPort = 51820"
|
||
echo ""
|
||
echo " [Peer]"
|
||
echo " PublicKey = <peer-public-key>"
|
||
echo " AllowedIPs = 10.0.0.2/32"
|
||
echo " Endpoint = peer.example.com:51820"
|
||
echo ""
|
||
echo "Start WireGuard:"
|
||
echo " sudo wg-quick up wg0"
|
||
echo " sudo systemctl enable wg-quick@wg0 # Start on boot"
|
||
echo ""
|
||
fi
|
||
else
|
||
echo "Skipping WireGuard installation."
|
||
fi
|
||
|
||
# Tailscale Installation
|
||
echo ""
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
echo "TAILSCALE VPN (Optional)"
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
echo ""
|
||
echo "Tailscale is a zero-config mesh VPN built on WireGuard."
|
||
echo " • Easy setup - just sign in"
|
||
echo " • Built on WireGuard for performance"
|
||
echo " • Automatic NAT traversal"
|
||
echo " • Built-in SSH (Tailscale SSH)"
|
||
echo ""
|
||
|
||
if is_tailscale_installed; then
|
||
echo "Tailscale is already installed."
|
||
tailscale status 2>/dev/null || true
|
||
echo ""
|
||
prompt_yn "Reinstall Tailscale? (y/n):" "n" INSTALL_TAILSCALE
|
||
else
|
||
prompt_yn "Install Tailscale? (y/n):" "n" INSTALL_TAILSCALE
|
||
fi
|
||
|
||
if [ "$INSTALL_TAILSCALE" = "y" ] || [ "$INSTALL_TAILSCALE" = "Y" ]; then
|
||
echo ""
|
||
echo "Installing Tailscale..."
|
||
echo ""
|
||
|
||
if [ "$DRY_RUN" = true ]; then
|
||
echo "[DRY-RUN] Would add Tailscale apt repository"
|
||
echo "[DRY-RUN] Would install tailscale"
|
||
else
|
||
# Add Tailscale's package signing key and repository
|
||
curl -fsSL https://pkgs.tailscale.com/stable/ubuntu/jammy.noarmor.gpg | tee /usr/share/keyrings/tailscale-archive-keyring.gpg >/dev/null
|
||
curl -fsSL https://pkgs.tailscale.com/stable/ubuntu/jammy.tailscale-keyring.list | tee /etc/apt/sources.list.d/tailscale.list
|
||
|
||
apt update
|
||
apt install -y tailscale || echo "Warning: Tailscale installation failed, continuing..."
|
||
|
||
echo ""
|
||
echo "Tailscale installed. Setup instructions:"
|
||
echo ""
|
||
echo "Connect to Tailscale network:"
|
||
echo " sudo tailscale up"
|
||
echo ""
|
||
echo "This opens a browser to authenticate. After that:"
|
||
echo " tailscale status # View connected devices"
|
||
echo " tailscale ip # Show your Tailscale IP"
|
||
echo ""
|
||
echo "Tailscale SSH (optional - enable in admin console):"
|
||
echo " • Enable 'SSH' in Tailscale admin console for this machine"
|
||
echo " • Connect with: ssh user@device-name (uses Tailscale identity)"
|
||
echo " • No SSH keys needed - Tailscale handles authentication"
|
||
echo ""
|
||
fi
|
||
else
|
||
echo "Skipping Tailscale installation."
|
||
fi
|
||
|
||
fi # End SKIP_VPN_CONFIG
|
||
|
||
# On rerun, skip remote desktop configuration if already set up
|
||
SKIP_RD_CONFIG=false
|
||
if is_rustdesk_installed || is_teamviewer_installed || is_meshcentral_installed; then
|
||
echo ""
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
echo "REMOTE DESKTOP SERVICES"
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
echo ""
|
||
echo "Remote desktop services already configured:"
|
||
is_rustdesk_installed && echo " ✓ RustDesk"
|
||
is_teamviewer_installed && echo " ✓ TeamViewer"
|
||
is_meshcentral_installed && echo " ✓ MeshCentral Agent"
|
||
echo ""
|
||
prompt_yn "Reconfigure remote desktop services? (y/n):" "n" RECONFIG_RD
|
||
if [ "$RECONFIG_RD" != "y" ] && [ "$RECONFIG_RD" != "Y" ]; then
|
||
SKIP_RD_CONFIG=true
|
||
fi
|
||
fi
|
||
|
||
if [ "$SKIP_RD_CONFIG" != true ]; then
|
||
|
||
# RustDesk Installation
|
||
echo ""
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
echo "RUSTDESK REMOTE DESKTOP (Optional)"
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
echo ""
|
||
echo "RustDesk is an open-source remote desktop software."
|
||
echo ""
|
||
|
||
if is_rustdesk_installed; then
|
||
echo "RustDesk is already installed."
|
||
echo ""
|
||
prompt_yn "Reinstall RustDesk? (y/n):" "n" INSTALL_RUSTDESK
|
||
else
|
||
prompt_yn "Install RustDesk? (y/n):" "n" INSTALL_RUSTDESK
|
||
fi
|
||
|
||
if [ "$INSTALL_RUSTDESK" = "y" ] || [ "$INSTALL_RUSTDESK" = "Y" ]; then
|
||
echo ""
|
||
echo "Installing RustDesk..."
|
||
echo " - RustDesk: Open-source remote desktop software"
|
||
echo ""
|
||
|
||
if [ "$DRY_RUN" = true ]; then
|
||
echo "[DRY-RUN] Would download latest RustDesk from GitHub"
|
||
echo "[DRY-RUN] Would install rustdesk .deb package"
|
||
else
|
||
# Download latest RustDesk .deb package
|
||
RUSTDESK_VERSION=$(curl -s https://api.github.com/repos/rustdesk/rustdesk/releases/latest | grep -oP '"tag_name": "\K(.*)(?=")')
|
||
RUSTDESK_URL="https://github.com/rustdesk/rustdesk/releases/download/${RUSTDESK_VERSION}/rustdesk-${RUSTDESK_VERSION}-x86_64.deb"
|
||
|
||
wget -O /tmp/rustdesk.deb "$RUSTDESK_URL" || echo "Warning: RustDesk download failed, continuing..."
|
||
|
||
if [ -f /tmp/rustdesk.deb ]; then
|
||
apt install -y /tmp/rustdesk.deb || echo "Warning: RustDesk installation failed, continuing..."
|
||
rm /tmp/rustdesk.deb
|
||
echo "✓ RustDesk installed"
|
||
fi
|
||
fi
|
||
else
|
||
echo "Skipping RustDesk installation."
|
||
fi
|
||
|
||
# TeamViewer Installation
|
||
echo ""
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
echo "TEAMVIEWER REMOTE DESKTOP (Optional)"
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
echo ""
|
||
echo "TeamViewer is commercial remote desktop software with a free tier."
|
||
echo " • Cross-platform (Windows, Mac, Linux, mobile)"
|
||
echo " • Easy to use - no port forwarding needed"
|
||
echo " • Requires TeamViewer account for unattended access"
|
||
echo ""
|
||
|
||
if is_teamviewer_installed; then
|
||
echo "TeamViewer is already installed."
|
||
echo ""
|
||
prompt_yn "Reinstall TeamViewer? (y/n):" "n" INSTALL_TEAMVIEWER
|
||
else
|
||
prompt_yn "Install TeamViewer? (y/n):" "n" INSTALL_TEAMVIEWER
|
||
fi
|
||
|
||
if [ "$INSTALL_TEAMVIEWER" = "y" ] || [ "$INSTALL_TEAMVIEWER" = "Y" ]; then
|
||
echo ""
|
||
echo "Installing TeamViewer..."
|
||
echo ""
|
||
|
||
if [ "$DRY_RUN" = true ]; then
|
||
echo "[DRY-RUN] Would download TeamViewer .deb package"
|
||
echo "[DRY-RUN] Would install teamviewer"
|
||
else
|
||
# Download TeamViewer .deb package
|
||
wget -O /tmp/teamviewer.deb "https://download.teamviewer.com/download/linux/teamviewer_amd64.deb" || echo "Warning: TeamViewer download failed, continuing..."
|
||
|
||
if [ -f /tmp/teamviewer.deb ]; then
|
||
apt install -y /tmp/teamviewer.deb || echo "Warning: TeamViewer installation failed, continuing..."
|
||
rm /tmp/teamviewer.deb
|
||
|
||
echo ""
|
||
echo "TeamViewer installed. Setup instructions:"
|
||
echo ""
|
||
echo "Start TeamViewer:"
|
||
echo " teamviewer"
|
||
echo ""
|
||
echo "For unattended access:"
|
||
echo " 1. Open TeamViewer"
|
||
echo " 2. Go to Extras → Options → Security"
|
||
echo " 3. Set a personal password for unattended access"
|
||
echo " 4. Note your TeamViewer ID (shown in main window)"
|
||
echo ""
|
||
echo "✓ TeamViewer installed"
|
||
fi
|
||
fi
|
||
else
|
||
echo "Skipping TeamViewer installation."
|
||
fi
|
||
|
||
# MeshCentral Installation
|
||
echo ""
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
echo "MESHCENTRAL AGENT (Optional)"
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
echo ""
|
||
echo "MeshCentral is an open-source remote management solution."
|
||
echo " • Self-hosted or use public servers"
|
||
echo " • Web-based remote desktop (no client software needed)"
|
||
echo " • Terminal, file transfer, and remote desktop"
|
||
echo " • Requires a MeshCentral server to connect to"
|
||
echo ""
|
||
|
||
if is_meshcentral_installed; then
|
||
echo "MeshCentral Agent is already installed."
|
||
echo ""
|
||
prompt_yn "Reinstall MeshCentral Agent? (y/n):" "n" INSTALL_MESHCENTRAL
|
||
else
|
||
prompt_yn "Install MeshCentral Agent? (y/n):" "n" INSTALL_MESHCENTRAL
|
||
fi
|
||
|
||
if [ "$INSTALL_MESHCENTRAL" = "y" ] || [ "$INSTALL_MESHCENTRAL" = "Y" ]; then
|
||
echo ""
|
||
echo "MeshCentral requires a server URL to connect to."
|
||
echo ""
|
||
echo "If you have a MeshCentral server, the agent install is typically done by:"
|
||
echo " 1. Log into your MeshCentral web interface"
|
||
echo " 2. Go to 'My Devices' → 'Add Agent'"
|
||
echo " 3. Download and run the Linux agent installer"
|
||
echo ""
|
||
echo "Example (replace with your server's URL):"
|
||
echo " wget -O meshagent https://your-meshcentral-server/meshagents?id=XXXXX"
|
||
echo " chmod +x meshagent"
|
||
echo " sudo ./meshagent -install"
|
||
echo ""
|
||
|
||
if [ "$DRY_RUN" = true ]; then
|
||
echo "[DRY-RUN] Would prompt for MeshCentral server URL"
|
||
echo "[DRY-RUN] Would download and install meshagent"
|
||
else
|
||
prompt_text "Enter MeshCentral agent URL (or leave blank to skip):" "" MESHCENTRAL_URL
|
||
|
||
if [ -n "$MESHCENTRAL_URL" ]; then
|
||
wget -O /tmp/meshagent "$MESHCENTRAL_URL" || echo "Warning: MeshCentral agent download failed"
|
||
|
||
if [ -f /tmp/meshagent ]; then
|
||
chmod +x /tmp/meshagent
|
||
/tmp/meshagent -install || echo "Warning: MeshCentral agent installation failed"
|
||
rm /tmp/meshagent
|
||
echo ""
|
||
echo "✓ MeshCentral Agent installed"
|
||
echo " Check your MeshCentral server - this device should appear shortly."
|
||
fi
|
||
else
|
||
echo ""
|
||
echo "No URL provided. Skipping MeshCentral agent installation."
|
||
echo "You can install later by downloading the agent from your MeshCentral server."
|
||
fi
|
||
fi
|
||
else
|
||
echo "Skipping MeshCentral installation."
|
||
fi
|
||
|
||
fi # End SKIP_RD_CONFIG
|
||
|
||
# ============================================================================
|
||
# SELF-HOSTED DOCKER APPLICATIONS (Optional)
|
||
# ============================================================================
|
||
# These applications run in Docker containers using docker-compose
|
||
# Each app is installed to ~/docker/{appname}/
|
||
|
||
echo ""
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
echo "SELF-HOSTED DOCKER APPLICATIONS (Optional)"
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
echo ""
|
||
echo "Install self-hosted applications using Docker Compose."
|
||
echo "Each app will be installed to ~/docker/{appname}/"
|
||
echo ""
|
||
echo "Note: Docker must be installed for these applications."
|
||
echo ""
|
||
|
||
if ! is_docker_installed && [ "$INSTALL_DOCKER" != "y" ] && [ "$INSTALL_DOCKER" != "Y" ]; then
|
||
echo "Docker is not installed. Skipping self-hosted applications."
|
||
echo "Install Docker first, then rerun this script."
|
||
else
|
||
# Create docker apps directory
|
||
DOCKER_DIR="$ACTUAL_HOME/docker"
|
||
if [ "$DRY_RUN" != true ]; then
|
||
mkdir -p "$DOCKER_DIR"
|
||
chown "$ACTUAL_USER:$ACTUAL_USER" "$DOCKER_DIR"
|
||
fi
|
||
|
||
# ============================================================================
|
||
# DETECT DRIVES (ONCE FOR ALL SERVICES)
|
||
# ============================================================================
|
||
detect_drives
|
||
|
||
# ============================================================================
|
||
# SERVICE SELECTION MENU
|
||
# ============================================================================
|
||
|
||
# Use whiptail for service selection if available
|
||
if command -v whiptail &> /dev/null; then
|
||
# Detect existing services
|
||
echo "Detecting existing services..."
|
||
declare -A EXISTING_SERVICES
|
||
[ -d "$DOCKER_DIR/immich" ] && EXISTING_SERVICES[IMMICH]="ON"
|
||
[ -d "$DOCKER_DIR/audiobookshelf" ] && EXISTING_SERVICES[AUDIOBOOKSHELF]="ON"
|
||
[ -d "$DOCKER_DIR/emby" ] && EXISTING_SERVICES[EMBY]="ON"
|
||
[ -d "$DOCKER_DIR/arm" ] && EXISTING_SERVICES[ARM]="ON"
|
||
[ -d "$DOCKER_DIR/filebrowser" ] && EXISTING_SERVICES[FILEBROWSER]="ON"
|
||
[ -d "$DOCKER_DIR/magicmirror" ] && EXISTING_SERVICES[MAGICMIRROR]="ON"
|
||
[ -d "$DOCKER_DIR/actualbudget" ] && EXISTING_SERVICES[ACTUALBUDGET]="ON"
|
||
[ -d "$DOCKER_DIR/keycloak" ] && EXISTING_SERVICES[KEYCLOAK]="ON"
|
||
[ -d "$DOCKER_DIR/caddy" ] && EXISTING_SERVICES[CADDY]="ON"
|
||
[ -d "$DOCKER_DIR/lms" ] && EXISTING_SERVICES[LYRION]="ON"
|
||
[ -d "$DOCKER_DIR/mealie" ] && EXISTING_SERVICES[MEALIE]="ON"
|
||
[ -d "$DOCKER_DIR/minecraft" ] && EXISTING_SERVICES[MINECRAFT]="ON"
|
||
[ -d "$DOCKER_DIR/jellyfin" ] && EXISTING_SERVICES[JELLYFIN]="ON"
|
||
[ -d "$DOCKER_DIR/frigate" ] && EXISTING_SERVICES[FRIGATE]="ON"
|
||
[ -d "$DOCKER_DIR/ntfy" ] && EXISTING_SERVICES[NTFY]="ON"
|
||
[ -d "$DOCKER_DIR/uptime-kuma" ] && EXISTING_SERVICES[UPTIMEKUMA]="ON"
|
||
[ -d "$DOCKER_DIR/wg-easy" ] && EXISTING_SERVICES[WGEASY]="ON"
|
||
[ -d "$DOCKER_DIR/traccar" ] && EXISTING_SERVICES[TRACCAR]="ON"
|
||
[ -d "$DOCKER_DIR/portainer" ] && EXISTING_SERVICES[PORTAINER]="ON"
|
||
[ -d "$DOCKER_DIR/meshcentral" ] && EXISTING_SERVICES[MESHCENTRAL]="ON"
|
||
[ -d "$DOCKER_DIR/fmd" ] && EXISTING_SERVICES[FINDMYDEVICE]="ON"
|
||
[ -d "$DOCKER_DIR/frigate-notify" ] && EXISTING_SERVICES[FRIGATE_NOTIFY]="ON"
|
||
[ -d "$DOCKER_DIR/watchtower" ] && EXISTING_SERVICES[WATCHTOWER]="ON"
|
||
command -v fail2ban-client &> /dev/null && EXISTING_SERVICES[FAIL2BAN]="ON"
|
||
|
||
# Ask user what action to perform
|
||
ACTION=$(whiptail --title "Service Management" --menu \
|
||
"Choose an action:" 15 60 3 \
|
||
"1" "Install new services" \
|
||
"2" "Uninstall existing services" \
|
||
"3" "Cancel and skip" \
|
||
3>&1 1>&2 2>&3)
|
||
|
||
case "$ACTION" in
|
||
1)
|
||
# INSTALL MODE
|
||
MENU_MODE="INSTALL"
|
||
# Build checklist - mark existing services as ON
|
||
SELECTED_SERVICES=$(whiptail --title "Select Docker Services to Install" \
|
||
--checklist "Use SPACE to select, ENTER to confirm. [*] = already installed" 25 78 17 \
|
||
"IMMICH" "Photo & video backup (like Google Photos)" ${EXISTING_SERVICES[IMMICH]:-OFF} \
|
||
"AUDIOBOOKSHELF" "Audiobook & podcast server" ${EXISTING_SERVICES[AUDIOBOOKSHELF]:-OFF} \
|
||
"EMBY" "Media server for movies, TV, music" ${EXISTING_SERVICES[EMBY]:-OFF} \
|
||
"ARM" "Automatic Ripping Machine for DVDs/Blu-rays" ${EXISTING_SERVICES[ARM]:-OFF} \
|
||
"FILEBROWSER" "Web-based file manager" ${EXISTING_SERVICES[FILEBROWSER]:-OFF} \
|
||
"MAGICMIRROR" "Smart mirror / dashboard display" ${EXISTING_SERVICES[MAGICMIRROR]:-OFF} \
|
||
"ACTUALBUDGET" "Personal finance management with bank sync" ${EXISTING_SERVICES[ACTUALBUDGET]:-OFF} \
|
||
"KEYCLOAK" "Identity & Access Management (SSO)" ${EXISTING_SERVICES[KEYCLOAK]:-OFF} \
|
||
"CADDY" "Reverse proxy with automatic HTTPS" ${EXISTING_SERVICES[CADDY]:-OFF} \
|
||
"FAIL2BAN" "Intrusion prevention system" ${EXISTING_SERVICES[FAIL2BAN]:-OFF} \
|
||
"LYRION" "Music streaming server (LMS)" ${EXISTING_SERVICES[LYRION]:-OFF} \
|
||
"MEALIE" "Recipe manager & meal planner" ${EXISTING_SERVICES[MEALIE]:-OFF} \
|
||
"MINECRAFT" "Minecraft game server" ${EXISTING_SERVICES[MINECRAFT]:-OFF} \
|
||
"JELLYFIN" "Free media server (Emby alternative)" ${EXISTING_SERVICES[JELLYFIN]:-OFF} \
|
||
"FRIGATE" "AI-powered NVR for security cameras" ${EXISTING_SERVICES[FRIGATE]:-OFF} \
|
||
"NTFY" "Push notifications server" ${EXISTING_SERVICES[NTFY]:-OFF} \
|
||
"UPTIMEKUMA" "Service monitoring dashboard" ${EXISTING_SERVICES[UPTIMEKUMA]:-OFF} \
|
||
"WGEASY" "WireGuard VPN with web UI" ${EXISTING_SERVICES[WGEASY]:-OFF} \
|
||
"TRACCAR" "GPS tracking server" ${EXISTING_SERVICES[TRACCAR]:-OFF} \
|
||
"PORTAINER" "Docker management web UI" ${EXISTING_SERVICES[PORTAINER]:-OFF} \
|
||
"MESHCENTRAL" "Remote management server" ${EXISTING_SERVICES[MESHCENTRAL]:-OFF} \
|
||
"FINDMYDEVICE" "Device tracking (like Find My)" ${EXISTING_SERVICES[FINDMYDEVICE]:-OFF} \
|
||
"FRIGATE_NOTIFY" "Push notifications for Frigate" ${EXISTING_SERVICES[FRIGATE_NOTIFY]:-OFF} \
|
||
"WATCHTOWER" "Automatic container updates" ${EXISTING_SERVICES[WATCHTOWER]:-OFF} \
|
||
3>&1 1>&2 2>&3)
|
||
;;
|
||
2)
|
||
# UNINSTALL MODE
|
||
MENU_MODE="UNINSTALL"
|
||
# Only show services that exist
|
||
UNINSTALL_OPTIONS=""
|
||
[ -n "${EXISTING_SERVICES[IMMICH]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS IMMICH \"Photo & video backup\" ON"
|
||
[ -n "${EXISTING_SERVICES[AUDIOBOOKSHELF]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS AUDIOBOOKSHELF \"Audiobook server\" ON"
|
||
[ -n "${EXISTING_SERVICES[EMBY]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS EMBY \"Media server\" ON"
|
||
[ -n "${EXISTING_SERVICES[ARM]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS ARM \"Automatic Ripping Machine\" ON"
|
||
[ -n "${EXISTING_SERVICES[FILEBROWSER]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS FILEBROWSER \"Web file manager\" ON"
|
||
[ -n "${EXISTING_SERVICES[MAGICMIRROR]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS MAGICMIRROR \"Smart mirror\" ON"
|
||
[ -n "${EXISTING_SERVICES[ACTUALBUDGET]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS ACTUALBUDGET \"Personal finance\" ON"
|
||
[ -n "${EXISTING_SERVICES[KEYCLOAK]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS KEYCLOAK \"Identity management\" ON"
|
||
[ -n "${EXISTING_SERVICES[CADDY]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS CADDY \"Reverse proxy\" ON"
|
||
[ -n "${EXISTING_SERVICES[FAIL2BAN]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS FAIL2BAN \"Intrusion prevention\" ON"
|
||
[ -n "${EXISTING_SERVICES[LYRION]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS LYRION \"Music server\" ON"
|
||
[ -n "${EXISTING_SERVICES[MEALIE]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS MEALIE \"Recipe manager\" ON"
|
||
[ -n "${EXISTING_SERVICES[MINECRAFT]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS MINECRAFT \"Game server\" ON"
|
||
[ -n "${EXISTING_SERVICES[JELLYFIN]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS JELLYFIN \"Media server\" ON"
|
||
[ -n "${EXISTING_SERVICES[FRIGATE]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS FRIGATE \"NVR cameras\" ON"
|
||
[ -n "${EXISTING_SERVICES[NTFY]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS NTFY \"Push notifications\" ON"
|
||
[ -n "${EXISTING_SERVICES[UPTIMEKUMA]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS UPTIMEKUMA \"Service monitoring\" ON"
|
||
[ -n "${EXISTING_SERVICES[WGEASY]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS WGEASY \"WireGuard VPN\" ON"
|
||
[ -n "${EXISTING_SERVICES[TRACCAR]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS TRACCAR \"GPS tracking\" ON"
|
||
[ -n "${EXISTING_SERVICES[PORTAINER]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS PORTAINER \"Docker management\" ON"
|
||
[ -n "${EXISTING_SERVICES[MESHCENTRAL]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS MESHCENTRAL \"Remote management\" ON"
|
||
[ -n "${EXISTING_SERVICES[FINDMYDEVICE]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS FINDMYDEVICE \"Device tracking\" ON"
|
||
[ -n "${EXISTING_SERVICES[FRIGATE_NOTIFY]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS FRIGATE_NOTIFY \"Frigate notifications\" ON"
|
||
[ -n "${EXISTING_SERVICES[WATCHTOWER]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS WATCHTOWER \"Container updates\" ON"
|
||
|
||
if [ -z "$UNINSTALL_OPTIONS" ]; then
|
||
whiptail --title "No Services Found" --msgbox "No services detected to uninstall." 8 50
|
||
SELECTED_SERVICES=""
|
||
else
|
||
SELECTED_SERVICES=$(eval "whiptail --title 'Select Services to Uninstall' \
|
||
--checklist 'WARNING: Data will be backed up but services will be removed' 25 78 17 \
|
||
$UNINSTALL_OPTIONS \
|
||
3>&1 1>&2 2>&3")
|
||
fi
|
||
;;
|
||
3|*)
|
||
# Cancel
|
||
SELECTED_SERVICES=""
|
||
MENU_MODE="CANCEL"
|
||
;;
|
||
esac
|
||
|
||
# Check if user cancelled
|
||
if [ $? -ne 0 ]; then
|
||
echo "Service selection cancelled. Skipping Docker applications."
|
||
SELECTED_SERVICES=""
|
||
fi
|
||
|
||
# Parse selections (whiptail returns quoted strings)
|
||
# Only set to "n" if not already set (preserve any earlier choices)
|
||
: ${INSTALL_IMMICH:="n"}
|
||
: ${INSTALL_AUDIOBOOKSHELF:="n"}
|
||
: ${INSTALL_EMBY:="n"}
|
||
: ${INSTALL_ARM:="n"}
|
||
: ${INSTALL_FILEBROWSER:="n"}
|
||
: ${INSTALL_MAGICMIRROR:="n"}
|
||
: ${INSTALL_ACTUALBUDGET:="n"}
|
||
: ${INSTALL_KEYCLOAK:="n"}
|
||
: ${INSTALL_CADDY:="n"}
|
||
: ${INSTALL_FAIL2BAN:="n"}
|
||
: ${INSTALL_LMS:="n"}
|
||
: ${INSTALL_MEALIE:="n"}
|
||
: ${INSTALL_MINECRAFT:="n"}
|
||
: ${INSTALL_JELLYFIN:="n"}
|
||
: ${INSTALL_FRIGATE:="n"}
|
||
: ${INSTALL_NTFY:="n"}
|
||
: ${INSTALL_UPTIMEKUMA:="n"}
|
||
: ${INSTALL_WGEASY:="n"}
|
||
: ${INSTALL_TRACCAR:="n"}
|
||
: ${INSTALL_PORTAINER:="n"}
|
||
: ${INSTALL_MESHCENTRAL_SERVER:="n"}
|
||
: ${INSTALL_FMD:="n"}
|
||
: ${INSTALL_FRIGATE_NOTIFY:="n"}
|
||
: ${INSTALL_WATCHTOWER:="n"}
|
||
|
||
# Set installation flags based on selections
|
||
if echo "$SELECTED_SERVICES" | grep -q "IMMICH"; then INSTALL_IMMICH="y"; fi
|
||
if echo "$SELECTED_SERVICES" | grep -q "AUDIOBOOKSHELF"; then INSTALL_AUDIOBOOKSHELF="y"; fi
|
||
if echo "$SELECTED_SERVICES" | grep -q "EMBY"; then INSTALL_EMBY="y"; fi
|
||
if echo "$SELECTED_SERVICES" | grep -q "ARM"; then INSTALL_ARM="y"; fi
|
||
if echo "$SELECTED_SERVICES" | grep -q "FILEBROWSER"; then INSTALL_FILEBROWSER="y"; fi
|
||
if echo "$SELECTED_SERVICES" | grep -q "MAGICMIRROR"; then INSTALL_MAGICMIRROR="y"; fi
|
||
if echo "$SELECTED_SERVICES" | grep -q "ACTUALBUDGET"; then INSTALL_ACTUALBUDGET="y"; fi
|
||
if echo "$SELECTED_SERVICES" | grep -q "KEYCLOAK"; then INSTALL_KEYCLOAK="y"; fi
|
||
if echo "$SELECTED_SERVICES" | grep -q "CADDY"; then INSTALL_CADDY="y"; fi
|
||
if echo "$SELECTED_SERVICES" | grep -q "FAIL2BAN"; then INSTALL_FAIL2BAN="y"; fi
|
||
if echo "$SELECTED_SERVICES" | grep -q "LYRION"; then INSTALL_LMS="y"; fi
|
||
if echo "$SELECTED_SERVICES" | grep -q "MEALIE"; then INSTALL_MEALIE="y"; fi
|
||
if echo "$SELECTED_SERVICES" | grep -q "MINECRAFT"; then INSTALL_MINECRAFT="y"; fi
|
||
if echo "$SELECTED_SERVICES" | grep -q "JELLYFIN"; then INSTALL_JELLYFIN="y"; fi
|
||
if echo "$SELECTED_SERVICES" | grep -q "FRIGATE\""; then INSTALL_FRIGATE="y"; fi
|
||
if echo "$SELECTED_SERVICES" | grep -q "NTFY"; then INSTALL_NTFY="y"; fi
|
||
if echo "$SELECTED_SERVICES" | grep -q "UPTIMEKUMA"; then INSTALL_UPTIMEKUMA="y"; fi
|
||
if echo "$SELECTED_SERVICES" | grep -q "WGEASY"; then INSTALL_WGEASY="y"; fi
|
||
if echo "$SELECTED_SERVICES" | grep -q "TRACCAR"; then INSTALL_TRACCAR="y"; fi
|
||
if echo "$SELECTED_SERVICES" | grep -q "PORTAINER"; then INSTALL_PORTAINER="y"; fi
|
||
if echo "$SELECTED_SERVICES" | grep -q "MESHCENTRAL"; then INSTALL_MESHCENTRAL_SERVER="y"; fi
|
||
if echo "$SELECTED_SERVICES" | grep -q "FINDMYDEVICE"; then INSTALL_FMD="y"; fi
|
||
if echo "$SELECTED_SERVICES" | grep -q "FRIGATE_NOTIFY"; then INSTALL_FRIGATE_NOTIFY="y"; fi
|
||
if echo "$SELECTED_SERVICES" | grep -q "WATCHTOWER"; then INSTALL_WATCHTOWER="y"; fi
|
||
|
||
if [ "$MENU_MODE" = "UNINSTALL" ]; then
|
||
echo ""
|
||
echo "Services selected for uninstallation:"
|
||
echo "$SELECTED_SERVICES" | tr '"' '\n' | grep -v '^$' | sed 's/^/ - /'
|
||
echo ""
|
||
|
||
# Process uninstalls
|
||
if [ -n "$SELECTED_SERVICES" ]; then
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
echo " UNINSTALLING SERVICES"
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
echo ""
|
||
|
||
# Define uninstall function
|
||
uninstall_service() {
|
||
local SERVICE_NAME=$1
|
||
local SERVICE_DIR=$2
|
||
local CONTAINER_NAME=$3
|
||
|
||
echo "Uninstalling $SERVICE_NAME..."
|
||
|
||
if [ -d "$SERVICE_DIR" ]; then
|
||
cd "$SERVICE_DIR" 2>/dev/null || return
|
||
|
||
# Stop and remove containers
|
||
if [ -f "docker-compose.yml" ]; then
|
||
echo " Stopping containers..."
|
||
docker compose down 2>/dev/null || docker-compose down 2>/dev/null || true
|
||
fi
|
||
|
||
# Backup data
|
||
BACKUP_DIR="$DOCKER_DIR/backups/$(date +%Y%m%d-%H%M%S)-$SERVICE_NAME"
|
||
mkdir -p "$BACKUP_DIR"
|
||
echo " Backing up to $BACKUP_DIR..."
|
||
cp -r "$SERVICE_DIR" "$BACKUP_DIR/" 2>/dev/null || true
|
||
|
||
# Remove service directory
|
||
echo " Removing $SERVICE_DIR..."
|
||
rm -rf "$SERVICE_DIR"
|
||
|
||
echo " ✓ $SERVICE_NAME uninstalled (backup: $BACKUP_DIR)"
|
||
else
|
||
echo " ⚠ $SERVICE_NAME directory not found, skipping"
|
||
fi
|
||
echo ""
|
||
}
|
||
|
||
# Uninstall selected services
|
||
if echo "$SELECTED_SERVICES" | grep -q "IMMICH"; then uninstall_service "Immich" "$DOCKER_DIR/immich" "immich"; fi
|
||
if echo "$SELECTED_SERVICES" | grep -q "AUDIOBOOKSHELF"; then uninstall_service "AudioBookshelf" "$DOCKER_DIR/audiobookshelf" "audiobookshelf"; fi
|
||
if echo "$SELECTED_SERVICES" | grep -q "EMBY"; then uninstall_service "Emby" "$DOCKER_DIR/emby" "emby"; fi
|
||
if echo "$SELECTED_SERVICES" | grep -q "ARM"; then uninstall_service "ARM" "$DOCKER_DIR/arm" "arm"; fi
|
||
if echo "$SELECTED_SERVICES" | grep -q "FILEBROWSER"; then uninstall_service "FileBrowser" "$DOCKER_DIR/filebrowser" "filebrowser"; fi
|
||
if echo "$SELECTED_SERVICES" | grep -q "MAGICMIRROR"; then uninstall_service "MagicMirror" "$DOCKER_DIR/magicmirror" "magicmirror"; fi
|
||
if echo "$SELECTED_SERVICES" | grep -q "ACTUALBUDGET"; then uninstall_service "ActualBudget" "$DOCKER_DIR/actualbudget" "actualbudget"; fi
|
||
if echo "$SELECTED_SERVICES" | grep -q "KEYCLOAK"; then uninstall_service "Keycloak" "$DOCKER_DIR/keycloak" "keycloak"; fi
|
||
if echo "$SELECTED_SERVICES" | grep -q "CADDY"; then uninstall_service "Caddy" "$DOCKER_DIR/caddy" "caddy"; fi
|
||
if echo "$SELECTED_SERVICES" | grep -q "LYRION"; then uninstall_service "Lyrion" "$DOCKER_DIR/lms" "lms"; fi
|
||
if echo "$SELECTED_SERVICES" | grep -q "MEALIE"; then uninstall_service "Mealie" "$DOCKER_DIR/mealie" "mealie"; fi
|
||
if echo "$SELECTED_SERVICES" | grep -q "MINECRAFT"; then uninstall_service "Minecraft" "$DOCKER_DIR/minecraft" "minecraft"; fi
|
||
if echo "$SELECTED_SERVICES" | grep -q "JELLYFIN"; then uninstall_service "Jellyfin" "$DOCKER_DIR/jellyfin" "jellyfin"; fi
|
||
if echo "$SELECTED_SERVICES" | grep -q "FRIGATE\""; then uninstall_service "Frigate" "$DOCKER_DIR/frigate" "frigate"; fi
|
||
if echo "$SELECTED_SERVICES" | grep -q "NTFY"; then uninstall_service "ntfy" "$DOCKER_DIR/ntfy" "ntfy"; fi
|
||
if echo "$SELECTED_SERVICES" | grep -q "UPTIMEKUMA"; then uninstall_service "Uptime Kuma" "$DOCKER_DIR/uptime-kuma" "uptime-kuma"; fi
|
||
if echo "$SELECTED_SERVICES" | grep -q "WGEASY"; then uninstall_service "wg-easy" "$DOCKER_DIR/wg-easy" "wg-easy"; fi
|
||
if echo "$SELECTED_SERVICES" | grep -q "TRACCAR"; then uninstall_service "Traccar" "$DOCKER_DIR/traccar" "traccar"; fi
|
||
if echo "$SELECTED_SERVICES" | grep -q "PORTAINER"; then uninstall_service "Portainer" "$DOCKER_DIR/portainer" "portainer"; fi
|
||
if echo "$SELECTED_SERVICES" | grep -q "MESHCENTRAL"; then uninstall_service "MeshCentral" "$DOCKER_DIR/meshcentral" "meshcentral"; fi
|
||
if echo "$SELECTED_SERVICES" | grep -q "FINDMYDEVICE"; then uninstall_service "FindMyDevice" "$DOCKER_DIR/fmd" "fmd"; fi
|
||
if echo "$SELECTED_SERVICES" | grep -q "FRIGATE_NOTIFY"; then uninstall_service "Frigate-Notify" "$DOCKER_DIR/frigate-notify" "frigate-notify"; fi
|
||
if echo "$SELECTED_SERVICES" | grep -q "WATCHTOWER"; then uninstall_service "Watchtower" "$DOCKER_DIR/watchtower" "watchtower"; fi
|
||
|
||
# Special handling for fail2ban (system package)
|
||
if echo "$SELECTED_SERVICES" | grep -q "FAIL2BAN"; then
|
||
echo "Uninstalling fail2ban..."
|
||
echo " Stopping fail2ban service..."
|
||
systemctl stop fail2ban 2>/dev/null || true
|
||
systemctl disable fail2ban 2>/dev/null || true
|
||
echo " Removing fail2ban package..."
|
||
apt-get remove --purge -y fail2ban 2>/dev/null || true
|
||
echo " ✓ fail2ban uninstalled"
|
||
echo ""
|
||
fi
|
||
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
echo " Uninstallation complete!"
|
||
echo " Backups saved to: $DOCKER_DIR/backups/"
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
echo ""
|
||
fi
|
||
|
||
# After uninstall, exit the Docker apps section
|
||
WHIPTAIL_USED=true
|
||
|
||
elif [ "$MENU_MODE" = "INSTALL" ]; then
|
||
echo ""
|
||
echo "Selected services:"
|
||
echo "$SELECTED_SERVICES" | tr '"' '\n' | grep -v '^$' | sed 's/^/ - /'
|
||
echo ""
|
||
|
||
# Mark that whiptail was used - skip all individual prompts
|
||
WHIPTAIL_USED=true
|
||
else
|
||
# Cancel or other mode
|
||
WHIPTAIL_USED=false
|
||
fi
|
||
else
|
||
# Whiptail not available - will use individual prompts
|
||
WHIPTAIL_USED=false
|
||
MENU_MODE="INSTALL"
|
||
fi
|
||
|
||
# Skip installation section if we just did uninstalls
|
||
if [ "$MENU_MODE" = "UNINSTALL" ]; then
|
||
echo "Skipping installation section (uninstall mode was selected)."
|
||
# Jump to end of Docker section by setting a flag
|
||
SKIP_DOCKER_INSTALLS=true
|
||
else
|
||
SKIP_DOCKER_INSTALLS=false
|
||
fi
|
||
|
||
# ============================================================================
|
||
# DOCKER SERVICE INSTALLATIONS
|
||
# ============================================================================
|
||
|
||
if [ "$SKIP_DOCKER_INSTALLS" != true ]; then
|
||
# ---- IMMICH ----
|
||
if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_IMMICH" ]; then
|
||
echo ""
|
||
echo "┌─────────────────────────────────────────────────────────────────┐"
|
||
echo "│ IMMICH - Self-hosted photo & video backup │"
|
||
echo "│ Like Google Photos but private. Mobile app auto-uploads. │"
|
||
echo "│ Port: 2283 │"
|
||
echo "└─────────────────────────────────────────────────────────────────┘"
|
||
prompt_yn "Install Immich? (y/n):" "n" INSTALL_IMMICH
|
||
fi
|
||
|
||
if [ "$INSTALL_IMMICH" = "y" ] || [ "$INSTALL_IMMICH" = "Y" ]; then
|
||
IMMICH_DIR="$DOCKER_DIR/immich"
|
||
|
||
# Check if already installed and ask what to do
|
||
check_service_exists "Immich" "$IMMICH_DIR" IMMICH_RECONFIGURE
|
||
|
||
if [ "$IMMICH_RECONFIGURE" = "true" ]; then
|
||
echo "Installing Immich..."
|
||
|
||
# ── Photo library setup ─────────────────────────────────────
|
||
echo ""
|
||
echo "┌─────────────────────────────────────────────────────────────────┐"
|
||
echo "│ PHOTO LIBRARY SETUP │"
|
||
echo "└─────────────────────────────────────────────────────────────────┘"
|
||
echo ""
|
||
|
||
DEFAULT_PHOTOS_DIR="$PRIMARY_DRIVE_PATH/photos"
|
||
IMMICH_STRATEGY=""
|
||
EXTERNAL_LIBRARY=""
|
||
EXISTING_PHOTOS_SOURCE=""
|
||
|
||
prompt_yn " Do you have existing photos to include? (y/n):" "n" HAS_EXISTING_PHOTOS
|
||
|
||
if [ "$HAS_EXISTING_PHOTOS" = "y" ] || [ "$HAS_EXISTING_PHOTOS" = "Y" ]; then
|
||
echo ""
|
||
echo " How should Immich handle your existing photos?"
|
||
echo ""
|
||
echo " [1] Import everything into Immich (recommended)"
|
||
echo " Immich manages all photos in one unified library."
|
||
echo " Dates preserved via EXIF metadata. Organized by date."
|
||
echo " Your original folder names are NOT kept on disk"
|
||
echo " (use Immich albums to organize instead)."
|
||
echo ""
|
||
echo " [2] Keep existing photos in place (read-only external library)"
|
||
echo " Immich indexes your existing photos without moving them."
|
||
echo " New uploads go to a separate folder."
|
||
echo " Your folder structure stays intact."
|
||
echo ""
|
||
|
||
if [ "$UNATTENDED" = true ]; then
|
||
IMMICH_STRATEGY="1"
|
||
echo " Strategy: [auto: 1]"
|
||
else
|
||
read -p " Choose [1/2]: " IMMICH_STRATEGY
|
||
IMMICH_STRATEGY="${IMMICH_STRATEGY:-1}"
|
||
fi
|
||
|
||
if [ "$IMMICH_STRATEGY" = "2" ]; then
|
||
# Strategy 2: external library - ask where existing photos are
|
||
echo ""
|
||
echo " Where are your existing photos?"
|
||
echo " Default: $DEFAULT_PHOTOS_DIR"
|
||
prompt_text " Existing photos path:" "$DEFAULT_PHOTOS_DIR" EXISTING_PHOTOS_SOURCE 2>/dev/null || EXISTING_PHOTOS_SOURCE="$DEFAULT_PHOTOS_DIR"
|
||
EXISTING_PHOTOS_SOURCE="${EXISTING_PHOTOS_SOURCE/#\~/$ACTUAL_HOME}"
|
||
EXISTING_PHOTOS_SOURCE="${EXISTING_PHOTOS_SOURCE%/}"
|
||
|
||
UPLOAD_LOCATION="$PRIMARY_DRIVE_PATH/immich-uploads"
|
||
EXTERNAL_LIBRARY="$EXISTING_PHOTOS_SOURCE"
|
||
|
||
echo ""
|
||
echo " Setup:"
|
||
echo " Existing photos: $EXISTING_PHOTOS_SOURCE (read-only)"
|
||
echo " New uploads: $UPLOAD_LOCATION"
|
||
else
|
||
# Strategy 1 with existing photos: ask for library path, will import into it
|
||
echo ""
|
||
echo " Where should Immich store your photo library?"
|
||
echo " (Existing photos will be imported here)"
|
||
echo " Default: $DEFAULT_PHOTOS_DIR"
|
||
prompt_text " Photo library path:" "$DEFAULT_PHOTOS_DIR" PHOTOS_DIR 2>/dev/null || PHOTOS_DIR="$DEFAULT_PHOTOS_DIR"
|
||
PHOTOS_DIR="${PHOTOS_DIR/#\~/$ACTUAL_HOME}"
|
||
PHOTOS_DIR="${PHOTOS_DIR%/}"
|
||
|
||
echo ""
|
||
echo " Where are your existing photos now?"
|
||
echo " Default: $PHOTOS_DIR"
|
||
prompt_text " Existing photos path:" "$PHOTOS_DIR" EXISTING_PHOTOS_SOURCE 2>/dev/null || EXISTING_PHOTOS_SOURCE="$PHOTOS_DIR"
|
||
EXISTING_PHOTOS_SOURCE="${EXISTING_PHOTOS_SOURCE/#\~/$ACTUAL_HOME}"
|
||
EXISTING_PHOTOS_SOURCE="${EXISTING_PHOTOS_SOURCE%/}"
|
||
|
||
UPLOAD_LOCATION="$PHOTOS_DIR"
|
||
EXTERNAL_LIBRARY=""
|
||
|
||
echo ""
|
||
echo " All photos (existing + new) will live in:"
|
||
echo " $PHOTOS_DIR"
|
||
echo " └── 2026/01/filename.jpg organized by date"
|
||
fi
|
||
else
|
||
# No existing photos: simplest path - just ask where to store
|
||
IMMICH_STRATEGY="1"
|
||
echo ""
|
||
echo " Where should Immich store your photo library?"
|
||
echo " Default: $DEFAULT_PHOTOS_DIR"
|
||
prompt_text " Photo library path:" "$DEFAULT_PHOTOS_DIR" PHOTOS_DIR 2>/dev/null || PHOTOS_DIR="$DEFAULT_PHOTOS_DIR"
|
||
PHOTOS_DIR="${PHOTOS_DIR/#\~/$ACTUAL_HOME}"
|
||
PHOTOS_DIR="${PHOTOS_DIR%/}"
|
||
|
||
UPLOAD_LOCATION="$PHOTOS_DIR"
|
||
EXTERNAL_LIBRARY=""
|
||
|
||
echo ""
|
||
echo " All photos will be stored in:"
|
||
echo " $PHOTOS_DIR"
|
||
echo " └── 2026/01/filename.jpg organized by date"
|
||
fi
|
||
echo ""
|
||
|
||
if [ "$DRY_RUN" = true ]; then
|
||
echo "[DRY-RUN] Would create $IMMICH_DIR"
|
||
echo "[DRY-RUN] Would store uploads at $UPLOAD_LOCATION"
|
||
[ -n "$EXTERNAL_LIBRARY" ] && echo "[DRY-RUN] Would mount external library at $EXTERNAL_LIBRARY"
|
||
echo "[DRY-RUN] Would create docker-compose.yml and .env"
|
||
else
|
||
mkdir -p "$IMMICH_DIR" 2>/dev/null || true
|
||
mkdir -p "$UPLOAD_LOCATION" 2>/dev/null || true
|
||
[ -n "$EXTERNAL_LIBRARY" ] && mkdir -p "$EXTERNAL_LIBRARY" 2>/dev/null || true
|
||
|
||
# Create required subdirectories with .immich marker files
|
||
# Immich checks these on startup and fails if they're missing
|
||
for subdir in thumbs upload backups library profile encoded-video; do
|
||
mkdir -p "$UPLOAD_LOCATION/$subdir" 2>/dev/null || true
|
||
touch "$UPLOAD_LOCATION/$subdir/.immich" 2>/dev/null || true
|
||
done
|
||
cd "$IMMICH_DIR" 2>/dev/null || cd "$DOCKER_DIR"
|
||
|
||
# Create docker-compose.yml
|
||
if [ -n "$EXTERNAL_LIBRARY" ]; then
|
||
# Strategy 2: external library mount included
|
||
cat > docker-compose.yml << 'IMMICH_COMPOSE'
|
||
name: immich
|
||
|
||
services:
|
||
immich-server:
|
||
container_name: immich_server
|
||
image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release}
|
||
volumes:
|
||
- ${UPLOAD_LOCATION}:/usr/src/app/upload
|
||
- ${EXTERNAL_LIBRARY}:/usr/src/app/external:ro
|
||
- /etc/localtime:/etc/localtime:ro
|
||
env_file:
|
||
- .env
|
||
ports:
|
||
- 2283:2283
|
||
depends_on:
|
||
- redis
|
||
- database
|
||
restart: always
|
||
healthcheck:
|
||
disable: false
|
||
|
||
immich-machine-learning:
|
||
container_name: immich_machine_learning
|
||
image: ghcr.io/immich-app/immich-machine-learning:${IMMICH_VERSION:-release}
|
||
volumes:
|
||
- model-cache:/cache
|
||
env_file:
|
||
- .env
|
||
restart: always
|
||
healthcheck:
|
||
disable: false
|
||
|
||
redis:
|
||
container_name: immich_redis
|
||
image: docker.io/valkey/valkey:9-bookworm
|
||
healthcheck:
|
||
test: valkey-cli ping || exit 1
|
||
restart: always
|
||
|
||
database:
|
||
container_name: immich_postgres
|
||
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0
|
||
environment:
|
||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||
POSTGRES_USER: ${DB_USERNAME}
|
||
POSTGRES_DB: ${DB_DATABASE_NAME}
|
||
POSTGRES_INITDB_ARGS: '--data-checksums'
|
||
volumes:
|
||
- ${DB_DATA_LOCATION}:/var/lib/postgresql/data
|
||
restart: always
|
||
|
||
volumes:
|
||
model-cache:
|
||
IMMICH_COMPOSE
|
||
else
|
||
# Strategy 1: unified library, no external mount
|
||
cat > docker-compose.yml << 'IMMICH_COMPOSE'
|
||
name: immich
|
||
|
||
services:
|
||
immich-server:
|
||
container_name: immich_server
|
||
image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release}
|
||
volumes:
|
||
- ${UPLOAD_LOCATION}:/usr/src/app/upload
|
||
- /etc/localtime:/etc/localtime:ro
|
||
env_file:
|
||
- .env
|
||
ports:
|
||
- 2283:2283
|
||
depends_on:
|
||
- redis
|
||
- database
|
||
restart: always
|
||
healthcheck:
|
||
disable: false
|
||
|
||
immich-machine-learning:
|
||
container_name: immich_machine_learning
|
||
image: ghcr.io/immich-app/immich-machine-learning:${IMMICH_VERSION:-release}
|
||
volumes:
|
||
- model-cache:/cache
|
||
env_file:
|
||
- .env
|
||
restart: always
|
||
healthcheck:
|
||
disable: false
|
||
|
||
redis:
|
||
container_name: immich_redis
|
||
image: docker.io/valkey/valkey:9-bookworm
|
||
healthcheck:
|
||
test: valkey-cli ping || exit 1
|
||
restart: always
|
||
|
||
database:
|
||
container_name: immich_postgres
|
||
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0
|
||
environment:
|
||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||
POSTGRES_USER: ${DB_USERNAME}
|
||
POSTGRES_DB: ${DB_DATABASE_NAME}
|
||
POSTGRES_INITDB_ARGS: '--data-checksums'
|
||
volumes:
|
||
- ${DB_DATA_LOCATION}:/var/lib/postgresql/data
|
||
restart: always
|
||
|
||
volumes:
|
||
model-cache:
|
||
IMMICH_COMPOSE
|
||
fi
|
||
|
||
# Generate random password
|
||
DB_PASS=$(openssl rand -base64 32 | tr -dc 'a-zA-Z0-9' | head -c 32)
|
||
|
||
# Create .env file with strategy-appropriate comments
|
||
if [ "$IMMICH_STRATEGY" = "2" ]; then
|
||
cat > .env << IMMICH_ENV
|
||
# ============================================================
|
||
# IMMICH CONFIGURATION — External Library Mode
|
||
# ============================================================
|
||
#
|
||
# STORAGE TEMPLATE (configure in Immich web UI):
|
||
# Admin → Settings → Storage Template → Enable
|
||
# Template: {{y}}/{{MM}}/{{filename}}
|
||
# This organizes new uploads into: immich-uploads/2026/01/filename.jpg
|
||
#
|
||
# EXTERNAL LIBRARY SETUP:
|
||
# Admin → External Libraries → Create Library
|
||
# Import path: /usr/src/app/external
|
||
# Click "Scan" to index your existing photos.
|
||
#
|
||
# ============================================================
|
||
|
||
# New uploads from phone/web
|
||
UPLOAD_LOCATION=$UPLOAD_LOCATION
|
||
|
||
# Existing photos (read-only, indexed by Immich)
|
||
# Mounted at /usr/src/app/external inside the container
|
||
EXTERNAL_LIBRARY=$EXTERNAL_LIBRARY
|
||
|
||
# Database location (keep on fast storage)
|
||
DB_DATA_LOCATION=./postgres
|
||
|
||
IMMICH_VERSION=release
|
||
|
||
DB_PASSWORD=$DB_PASS
|
||
DB_USERNAME=postgres
|
||
DB_DATABASE_NAME=immich
|
||
|
||
TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC")
|
||
IMMICH_ENV
|
||
else
|
||
cat > .env << IMMICH_ENV
|
||
# ============================================================
|
||
# IMMICH CONFIGURATION — Unified Library
|
||
# ============================================================
|
||
#
|
||
# All photos (imported + new uploads) are stored in one location.
|
||
# Storage template organizes files by date automatically.
|
||
#
|
||
# To import existing photos, run:
|
||
# $IMMICH_DIR/import-photos.sh
|
||
#
|
||
# The import script handles storage template configuration,
|
||
# CLI installation, and uploads with EXIF date preservation.
|
||
#
|
||
# ============================================================
|
||
|
||
# All photos stored here (uploads + imported)
|
||
UPLOAD_LOCATION=$UPLOAD_LOCATION
|
||
|
||
# Database location (keep on fast storage)
|
||
DB_DATA_LOCATION=./postgres
|
||
|
||
IMMICH_VERSION=release
|
||
|
||
DB_PASSWORD=$DB_PASS
|
||
DB_USERNAME=postgres
|
||
DB_DATABASE_NAME=immich
|
||
|
||
TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC")
|
||
IMMICH_ENV
|
||
fi
|
||
|
||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$IMMICH_DIR" 2>/dev/null || true
|
||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$UPLOAD_LOCATION" 2>/dev/null || true
|
||
[ -n "$EXTERNAL_LIBRARY" ] && chown -R "$ACTUAL_USER:$ACTUAL_USER" "$EXTERNAL_LIBRARY" 2>/dev/null || true
|
||
|
||
# Create import helper script for Strategy 1 with existing photos
|
||
if [ "$IMMICH_STRATEGY" != "2" ] && [ -n "$EXISTING_PHOTOS_SOURCE" ]; then
|
||
cat > "$IMMICH_DIR/import-photos.sh" << 'IMPORT_SCRIPT_HEAD'
|
||
#!/bin/bash
|
||
################################################################################
|
||
# Immich Photo Import Script
|
||
# Generated by ubuntu-post-install
|
||
#
|
||
# This script imports your existing photo collection into Immich with proper
|
||
# EXIF date preservation. Photos are uploaded through the API, which triggers
|
||
# Immich's metadata extraction pipeline — dates, GPS, camera info are all
|
||
# read from the original files.
|
||
#
|
||
# What this script does:
|
||
# 1. Creates admin account (if first run) or logs in
|
||
# 2. Generates an API key automatically
|
||
# 3. Configures the storage template (date-based organization)
|
||
# 4. Installs the Immich CLI (if needed)
|
||
# 5. Uploads all photos with EXIF metadata preserved
|
||
#
|
||
# Usage:
|
||
# ./import-photos.sh # interactive (prompts for everything)
|
||
# ./import-photos.sh <api-key> # 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 <api-key>"
|
||
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 <api-key>"
|
||
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 <api-key>"
|
||
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 <api-key>"
|
||
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/<module-name> && 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":"<HOST>".*"status":(?:401|403|429).*$
|
||
^.*"remote_addr":"<HOST>.*"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'
|
||
<?xml version='1.0' encoding='UTF-8'?>
|
||
|
||
<!DOCTYPE properties SYSTEM 'http://java.sun.com/dtd/properties.dtd'>
|
||
|
||
<properties>
|
||
<entry key='config.default'>./conf/default.xml</entry>
|
||
<entry key='database.driver'>org.h2.Driver</entry>
|
||
<entry key='database.url'>jdbc:h2:/opt/traccar/data/database</entry>
|
||
<entry key='database.user'>sa</entry>
|
||
<entry key='database.password'></entry>
|
||
</properties>
|
||
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 <snapshot-id> /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 <peer-name>"
|
||
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 <IP>"
|
||
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 <port>"
|
||
echo " • Deny port: sudo ufw deny <port>"
|
||
echo " • Disable: sudo ufw disable"
|
||
echo ""
|
||
fi |